hide drafts and future-dated bundles; reveal them with -dev on

A draft is now not served at all, and neither is a bundle whose date has not
arrived. The filter sits in `Site.Lookup` and `Site.Run`, which is every path to a
bundle — so the files inside an unpublished bundle inherit its status for free,
which is what ADR-0024 asks for and what the asset route was written to allow. A
test asserts the 404 for the bundle *and* its picture, and that nothing leaks into
a listing, a feed or a sitemap.

The clock is read per request rather than at startup, so a scheduled post appears
exactly when its date arrives with nothing to restart and nothing to invalidate.
That first clock read created internal/content/clock.go, which is the only place
`verify.sh` allows `time.Now` — a render that depends on the time is worth being
able to find.

`-dev on` reveals both and reparses the theme before each render. Deliberately not
a bare boolean flag: turning unpublished work into public work should not be one
fumbled argument away. A reload that fails to parse leaves the working template set
in place, so a typo shows an error rather than replacing a good set with a broken one.
This commit is contained in:
2026-08-01 02:23:36 +06:00
parent 53d679728d
commit a2ccb45b0d
8 changed files with 203 additions and 14 deletions
+14
View File
@@ -49,6 +49,9 @@ type Bundle struct {
// Route is the path this bundle is served at: its Key, unless a slug renamed the last segment. Set by
// NewSite, which is the only place that can see whether every variant agrees.
Route string
// Draft is true when frontmatter says so. A draft is not served at all until `-dev` reveals it, and
// neither are the files inside its bundle (ADR-0024).
Draft bool
// Order is this bundle's position in the series it is nested under, zero when frontmatter omits it.
// The convention is sparse (10, 20, 30), so zero is not a position: an unordered member sorts by name
// after every ordered one (ADR-0033).
@@ -159,6 +162,8 @@ func Parse(name string, data []byte) (Bundle, error) {
delete(b.Extra, "tags")
b.Order = asInt(b.Extra["order"])
delete(b.Extra, "order")
b.Draft, _ = b.Extra["draft"].(bool)
delete(b.Extra, "draft")
if slug, isStr := b.Extra["slug"].(string); isStr {
// One segment, normalised like every other identifier (ADR-0015). Slashes would let a slug move the
// bundle to another section, which is a move, not a rename.
@@ -169,6 +174,15 @@ func Parse(name string, data []byte) (Bundle, error) {
return b, nil
}
// Published reports whether a bundle is visible to a reader at the given moment.
//
// Two ways not to be: marked a draft, or dated in the future. The second is the same rule seen from the other
// side — a bundle becomes public exactly when its own date arrives, with nothing to run and nothing to
// invalidate (content-model.md).
func (b Bundle) Published(at time.Time) bool {
return !b.Draft && !b.Date.After(at)
}
// Assets is the directory holding a bundle's own local files, and false for a bundle that has none.
//
// Only a directory bundle has one. A single-file bundle's neighbours belong to its section rather than to it,