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
+12
View File
@@ -0,0 +1,12 @@
package content
import "time"
// now is the only place the engine reads the wall clock, which `verify.sh` enforces by filename: a render
// that depends on the time is only true for a while, and that is worth being able to find.
//
// A variable so a test can hold time still. Nothing else assigns it.
var now = time.Now
// Now is the current time as the engine sees it, for whoever needs to date something.
func Now() time.Time { return now() }
+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,
+22 -4
View File
@@ -16,6 +16,8 @@ type Site struct {
keyByRoute map[string]string
// problems are what indexing worked around, kept for `check` rather than only logged.
problems []Problem
// reveal serves drafts and future-dated bundles, for `-dev` only.
reveal bool
// renamed records keys that a slug moved away from, so the old path answers 404 instead of still working
// — the engine serves the new path only (ADR-0035), and an author who wants both writes an alias.
renamed map[string]bool
@@ -38,6 +40,19 @@ func NewSite(bundles []Bundle) *Site {
return s
}
// Reveal makes drafts and future-dated bundles visible. Only `-dev` calls it: in a serving build an
// unpublished bundle answers 404 for itself and for every file inside it (ADR-0024).
func (s *Site) Reveal() { s.reveal = true }
// visible reports whether a bundle may be served now.
//
// Asked in Lookup and in Run, which is every path to a bundle — so hiding a draft is one rule in one place
// rather than a filter each caller must remember. The clock is read here rather than at startup, so a
// future-dated bundle appears the moment its date arrives without anything being restarted or invalidated.
func (s *Site) visible(b Bundle) bool {
return s.reveal || b.Published(now())
}
// Problems lists what indexing worked around: a contested alias, a slug two variants disagree on, a slug
// landing where something already answers. Each was also logged.
func (s *Site) Problems() []Problem { return s.problems }
@@ -171,13 +186,14 @@ func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) {
if try == "" {
continue
}
if b, ok = s.byKeyLang[key+"\x00"+try]; ok {
if b, ok = s.byKeyLang[key+"\x00"+try]; ok && s.visible(b) {
return b, try, true
}
}
for _, l := range s.Variants(key) {
b = s.byKeyLang[key+"\x00"+l]
return b, l, true
if b = s.byKeyLang[key+"\x00"+l]; s.visible(b) {
return b, l, true
}
}
return Bundle{}, "", false
}
@@ -379,7 +395,9 @@ func (s *Site) Everything() []Bundle {
out := make([]Bundle, 0, len(s.byKeyLang))
for _, key := range s.keys() {
for _, lang := range s.Variants(key) {
out = append(out, s.byKeyLang[key+"\x00"+lang])
if b := s.byKeyLang[key+"\x00"+lang]; s.visible(b) {
out = append(out, b)
}
}
}
return out