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) + } +}