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:
Claude Opus 5
2026-07-31 12:47:44 +06:00
committed by bdeshi
parent b574800adb
commit ab882980ad
8 changed files with 203 additions and 14 deletions
+10 -1
View File
@@ -28,6 +28,7 @@ func main() {
addr := flag.String("addr", "localhost:8080", "address to listen on")
base := flag.String("base", "", "canonical site origin, overriding site.yaml (e.g. https://khosra.example)")
cache := flag.String("cache", defaultCache(), "directory for generated files; never inside the site root")
dev := flag.String("dev", "", "set to 'on' to reveal drafts and future-dated bundles and reload templates")
flag.Parse()
if *site == "" {
@@ -52,6 +53,14 @@ func main() {
if err != nil {
fatal("cannot prepare the theme", err)
}
indexed := content.NewSite(bundles)
if *dev == "on" {
// Never on by default and never a bare boolean flag: revealing unpublished work is a visibility
// change, and it should be impossible to enable by fumbling an argument (ADR-0024).
indexed.Reveal()
renderer.Reload()
slog.Warn("dev mode: drafts and future-dated bundles are visible, and templates reload")
}
// Derivatives are made before the first request rather than during one (ADR-0042). A failure here is not
// fatal: pages still serve the author's originals, which is the whole point of the fallback.
@@ -65,7 +74,7 @@ func main() {
}
slog.Info("serving", "site", *site, "bundles", len(bundles), "derivatives", made, "addr", *addr)
if err := http.ListenAndServe(*addr, web.Handler(content.NewSite(bundles), renderer, fsys, derivedFS, settings)); err != nil {
if err := http.ListenAndServe(*addr, web.Handler(indexed, renderer, fsys, derivedFS, settings)); err != nil {
fatal("server stopped", err)
}
}
+9 -3
View File
@@ -69,7 +69,8 @@ overrides the defaults the binary embeds, so a bare root still renders.
- A directory starting with `_` other than `_index` is ignored, and so is a **file**: `_tools.md` is a
fragment, not a bundle. That is how a file meant only to be included avoids taking a URL of its own,
appearing in its section's listing, and turning its bundle into a one-member series.
- `draft: true` is excluded from queries and feeds but reachable at its own URL in dev.
- `draft: true` is not served at all: not in a listing, a feed or a sitemap, and not at its own URL. Neither is
any file inside its bundle (ADR-0024). `-dev on` is the only thing that changes that.
## Frontmatter
@@ -271,8 +272,13 @@ asset, everything under extras (ADR-0024). An unpublished bundle answers **404**
assets; 403 would confirm the work exists. `-dev` reveals drafts and future-dated bundles with their
assets, defaults off, and is the only thing that changes the answer.
For a future-dated bundle the 404 expires at its publish time, so it becomes visible
exactly when the bundle becomes public.
For a future-dated bundle the 404 expires at its publish time, so it becomes visible exactly when the bundle
becomes public — the clock is read per request, so nothing has to be restarted or invalidated for a scheduled
post to appear.
`-dev on` reveals drafts and future-dated bundles and reparses templates before each render, so editing a
template needs no restart. It is off by default and is not a bare boolean: revealing unpublished work should be
impossible to enable by fumbling an argument.
## Typography and localisation
+8 -5
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `f05b1c1` on 2026-07-30 — update this line every change.
**Verified against:** `0231dae` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
@@ -10,8 +10,9 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `go.mod` | module `khosra`; `goldmark`, `x/text`, `yaml.v3` direct | 10 |
| `internal/content/doc.go` | package comment | 5 |
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, partial files, permalink building | 381 |
| `internal/content/clock.go` | the one place the engine reads the wall clock, which `verify.sh` enforces by filename | 12 |
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) | 59 |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes | 395 |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility | 424 |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 409 |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 |
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
@@ -23,9 +24,9 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) | 125 |
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 |
| `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`), wiring, startup including the derivative pass — the only place things are assembled | 92 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`, `-dev`), wiring, startup including the derivative pass — the only place things are assembled | 92 |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker | 2608 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility | 2608 |
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
@@ -34,7 +35,9 @@ URL, generated derivatives under `/derived/`, Atom feeds per site,
section and tag, plus `/robots.txt` and `/sitemap.xml`.
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with
`khosra check` validates a site root and exits non-zero on anything that makes it wrong.
`khosra check` validates a site root and exits non-zero on anything that makes it wrong. A draft or
future-dated bundle is not served at all — nor is any file inside it (ADR-0024) — until `-dev on` reveals it and
reloads templates per request.
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
URLs go absolute (ADR-0039).
+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
+29 -1
View File
@@ -121,6 +121,12 @@ type Renderer struct {
files fs.FS
// settings are the site's declarations, constant for the life of the process.
settings content.Settings
// reload reparses the theme before each render, for `-dev`: editing a template should not need a restart.
// Off in a serving build, where parsing once is the point (conventions.md).
reload bool
// siteFS and extend are kept only so reload can rebuild what New built.
siteFS fs.FS
extend func(Partial) []goldmark.Extender
}
// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
@@ -204,7 +210,8 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
if err != nil {
return nil, err
}
r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS, settings: settings}
r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS, settings: settings,
siteFS: siteFS, extend: extend}
// The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone,
// because it works on the parsed tree rather than the text. That is the only change the engine makes to
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
@@ -239,6 +246,24 @@ func (r *Renderer) absolute(path string) string {
return content.Absolute(r.settings.Base, path)
}
// Reload makes every render reparse the theme first. For `-dev` only: it trades the parse-once rule for the
// ability to edit a template and refresh.
func (r *Renderer) Reload() { r.reload = true }
// fresh reparses the theme when reloading, and reports a failure without disturbing the working renderer — a
// template with a typo in it should show an error page, not replace a good set with a broken one.
func (r *Renderer) fresh() error {
if !r.reload {
return nil
}
next, err := New(r.siteFS, r.settings, r.extend)
if err != nil {
return err
}
r.page, r.list, r.partials, r.style, r.md = next.page, next.list, next.partials, next.style, next.md
return nil
}
// Partial renders one named fragment. A missing template is an error the caller degrades on, never a
// failed request (extensions.md rule 5).
func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) {
@@ -327,6 +352,9 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
// Listing renders one page of a Query result for a section.
func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error) {
if err := r.fresh(); err != nil {
return nil, err
}
l, window := r.paginate(section, lang, content.PageURL(section, lang, page), all, page,
func(p int) string { return content.PageURL(section, lang, p) })
for _, b := range window {
+99
View File
@@ -0,0 +1,99 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
// unpublishedFS holds a draft, a future-dated bundle and a live one, each a directory bundle with a file.
func unpublishedFS() fstest.MapFS {
return fstest.MapFS{
"content/art/draft/index.md": {Data: []byte("---\ntitle: Draft\ndraft: true\n---\nx\n")},
"content/art/draft/one.jpg": {Data: []byte("secret bytes")},
"content/art/future/index.md": {Data: []byte("---\ntitle: Future\ndate: 2099-01-01\n---\nx\n")},
"content/art/future/two.jpg": {Data: []byte("not yet")},
"content/art/live/index.md": {Data: []byte("---\ntitle: Live\ndate: 2020-01-01\n---\nx\n")},
"content/art/live/three.jpg": {Data: []byte("fine")},
}
}
func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
// ADR-0024: an unpublished bundle answers 404 for itself *and* for every file inside it, since 403 would
// confirm the work exists. The guard is the bundle lookup — which is why the asset route asks for the
// bundle before reading any bytes, and why this needed no second filter.
fsys := unpublishedFS()
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
hidden := Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
for path, want := range map[string]int{
"/art/draft/": http.StatusNotFound,
"/art/draft/one.jpg": http.StatusNotFound,
"/art/future/": http.StatusNotFound,
"/art/future/two.jpg": http.StatusNotFound,
"/art/live/": http.StatusOK,
"/art/live/three.jpg": http.StatusOK,
} {
rec := httptest.NewRecorder()
hidden.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != want {
t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
}
if body := rec.Body.String(); strings.Contains(body, "secret bytes") || strings.Contains(body, "not yet") {
t.Fatalf("GET %s served bytes from an unpublished bundle", path)
}
}
// Revealing them is the only thing that changes the answer.
site := content.NewSite(bundles)
site.Reveal()
shown := Handler(site, r, fsys, nil, content.Settings{})
for _, path := range []string{"/art/draft/", "/art/draft/one.jpg", "/art/future/", "/art/future/two.jpg"} {
rec := httptest.NewRecorder()
shown.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("dev mode: GET %s = %d, want 200", path, rec.Code)
}
}
}
func TestUnpublishedBundlesAreAbsentFromEverythingThatLists(t *testing.T) {
// A listing, a feed and a sitemap all go through the same Query, so hiding a draft in one place hides it
// everywhere. That is the property worth testing rather than each surface separately.
fsys := unpublishedFS()
fsys["content/art/live/index.md"] = &fstest.MapFile{Data: []byte("---\ntitle: Live\ndate: 2020-01-01\n---\nx\n")}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
settings := content.Settings{Base: "https://khosra.example", Title: "Khosra"}
r, err := render.New(nil, settings, nil)
if err != nil {
t.Fatal(err)
}
h := Handler(content.NewSite(bundles), r, fsys, nil, settings)
for _, path := range []string{"/art/", "/feed.xml", "/sitemap.xml"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
body := rec.Body.String()
if !strings.Contains(body, "live") && !strings.Contains(body, "Live") {
t.Errorf("GET %s lost the published bundle:\n%s", path, body)
}
for _, hidden := range []string{"draft", "future"} {
if strings.Contains(body, hidden) {
t.Errorf("GET %s leaked the %s bundle:\n%s", path, hidden, body)
}
}
}
}