diff --git a/docs/state.md b/docs/state.md
index 689f3e3..a3715f1 100644
--- a/docs/state.md
+++ b/docs/state.md
@@ -119,7 +119,6 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
|---|---|---|
| No mechanical check that the counters are *correct* | Accepted at the Arc 1 boundary: the coupling gate makes forgetting them impossible, which is the real failure mode, and checking the values needs code to count | The first page-level transform (queue 15), now that the transform counter means something narrower |
| No mechanical gate on the untrusted boundary (ADR-0003) | Scheduled to Arc 3: nothing untrusted is read yet. Half of it is mechanical in a different way since ADR-0060 — `verify.sh` requires `html.WithUnsafe()` in exactly one file, so a second pipeline trusting its input cannot appear unnoticed — but there is still no check that a *future* untrusted source stays out of shortcode and template evaluation | The comment path — a test that untrusted input reaches no shortcode or template evaluation |
-| `date` stays in `Extra` after being lifted onto `Bundle.Date`, unlike `title`, `aliases`, `tags` and `order`, which are deleted | Spotted while adding `order`; the theme contract says `Extra` holds what the parser does not name, so one of the two is wrong. Harmless today — a template reading `.Extra.date` gets the raw YAML value | Whatever next reads `Extra` generically: feeds (queue 14) or `check` (17) |
| A gallery's images carry no `alt` | `width`/`height` now come from the original (ADR-0042), so only alt text is missing, and a filename does not supply one. An empty `alt` is honest for a picture the page has already introduced | Captions per gallery entry — a sidecar or a frontmatter list — if the reference theme ever needs them |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
| The root listing's `
` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The first time the reference theme is worked on (Phase G4 touches it), or sooner if a feed or OpenGraph title inherits the same doubling |
diff --git a/docs/surface.md b/docs/surface.md
index b9e9d82..03162ba 100644
--- a/docs/surface.md
+++ b/docs/surface.md
@@ -22,9 +22,9 @@ check.go 45 · main.go 192 · new.go 42 · wire.go 27
- new.go:12 func runNew(args []string)
- wire.go:17 func extenders(partial render.Partial) []goldmark.Extender
-## internal/content — 1042 lines + 537 test
+## internal/content — 1043 lines + 558 test
-clock.go 12 · content.go 450 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 424
+clock.go 12 · content.go 451 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 424
- clock.go:9 var now = time.Now
- clock.go:12 func Now() time.Time { return now() }
@@ -35,28 +35,28 @@ clock.go 12 · content.go 450 · doc.go 5 · extras.go 92 · settings.go 59 · s
- content.go:94 func Scan(fsys fs.FS) ([]Bundle, error)
- content.go:103 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
- content.go:142 func Parse(name string, data []byte) (Bundle, error)
-- content.go:182 func (b Bundle) Published(at time.Time) bool
-- content.go:191 func (b Bundle) Assets() (string, bool)
-- content.go:201 func stringList(v any) []string
-- content.go:223 func asTime(v any) time.Time
-- content.go:239 func asInt(v any) int
-- content.go:252 func terms(v any) []string
-- content.go:276 func TagSlug(tag string) string
-- content.go:285 func Normalise(s string) string { return norm.NFC.String(s) }
-- content.go:291 func splitName(name string) (key, lang string, ok bool)
-- content.go:313 func isLangTag(s string) bool
-- content.go:331 func isPartial(base string) bool
-- content.go:343 func skipDir(base string) bool
-- content.go:351 func splitFrontmatter(data []byte) (front, body []byte)
-- content.go:366 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
-- content.go:383 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
-- content.go:405 const PerPage = 10
-- content.go:411 func URL(key, lang string) string
-- content.go:424 func TagURL(section, slug, lang string, page int) string
-- content.go:434 const DerivedPrefix = "/derived/"
-- content.go:437 func DerivedURL(name string) string { return DerivedPrefix + name }
-- content.go:440 const TagsSegment = "tags"
-- content.go:444 func PageURL(key, lang string, page int) string
+- content.go:183 func (b Bundle) Published(at time.Time) bool
+- content.go:192 func (b Bundle) Assets() (string, bool)
+- content.go:202 func stringList(v any) []string
+- content.go:224 func asTime(v any) time.Time
+- content.go:240 func asInt(v any) int
+- content.go:253 func terms(v any) []string
+- content.go:277 func TagSlug(tag string) string
+- content.go:286 func Normalise(s string) string { return norm.NFC.String(s) }
+- content.go:292 func splitName(name string) (key, lang string, ok bool)
+- content.go:314 func isLangTag(s string) bool
+- content.go:332 func isPartial(base string) bool
+- content.go:344 func skipDir(base string) bool
+- content.go:352 func splitFrontmatter(data []byte) (front, body []byte)
+- content.go:367 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
+- content.go:384 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
+- content.go:406 const PerPage = 10
+- content.go:412 func URL(key, lang string) string
+- content.go:425 func TagURL(section, slug, lang string, page int) string
+- content.go:435 const DerivedPrefix = "/derived/"
+- content.go:438 func DerivedURL(name string) string { return DerivedPrefix + name }
+- content.go:441 const TagsSegment = "tags"
+- content.go:445 func PageURL(key, lang string, page int) string
- extras.go:14 const ExtrasDir = "extras"
- extras.go:17 type Entry struct
- extras.go:33 func Extras(fsys fs.FS, b Bundle) []Entry
diff --git a/internal/content/content.go b/internal/content/content.go
index 7c32f4d..a689dc5 100644
--- a/internal/content/content.go
+++ b/internal/content/content.go
@@ -158,6 +158,7 @@ func Parse(name string, data []byte) (Bundle, error) {
b.Aliases = stringList(b.Extra["aliases"])
delete(b.Extra, "aliases")
b.Date = asTime(b.Extra["date"])
+ delete(b.Extra, "date")
b.Tags = terms(b.Extra["tags"])
delete(b.Extra, "tags")
b.Order = asInt(b.Extra["order"])
diff --git a/internal/content/content_test.go b/internal/content/content_test.go
index 057a224..70e9f2d 100644
--- a/internal/content/content_test.go
+++ b/internal/content/content_test.go
@@ -203,3 +203,24 @@ func TestAnUnderscoreFileIsAPartialNotABundle(t *testing.T) {
}
}
}
+
+// Extra holds what the parser does not name (ADR-0002). Every lifted key is deleted from it, and `date` was
+// the one exception — a template reading `.Extra.date` got the raw YAML value beside the parsed one.
+func TestALiftedKeyLeavesExtra(t *testing.T) {
+ b, err := Parse("posts/x.md", []byte("---\ntitle: T\ndate: 2026-03-08\ntags: [a]\norder: 10\n"+
+ "aliases: [old/x]\nslug: s\ndraft: true\nkeeps: me\n---\nbody\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, lifted := range []string{"title", "date", "tags", "order", "aliases", "slug", "draft"} {
+ if _, still := b.Extra[lifted]; still {
+ t.Errorf("%q is on the Bundle, so it must not also be in Extra: %v", lifted, b.Extra)
+ }
+ }
+ if b.Date.IsZero() {
+ t.Error("the date should still have been lifted")
+ }
+ if b.Extra["keeps"] != "me" {
+ t.Errorf("a key the parser does not name stays: %v", b.Extra)
+ }
+}