Give every stream its own seam: templates, assets, the modal route and the family order
Astra's review of 908bc6b found six Important and two Minor, every one a seam Tasks 3 to 7 need before they can edit only their own files; all eight are here. Templates: i18n.go listed every template constant, which Task 6 could not extend from started.go. templateSources and registerTemplate replace the list, and each constant registers itself in an init in the file that owns it; templates(locale) parses the registry. TestARegisteredTemplateIsParsedForEveryLocale holds the eight names present in en and ar, and registers a scratch define, renders it in ga and withdraws it. Assets: the tree gate derived its expectation from s.hashed, which emission populates, so a forgotten asset went unnoticed. hashed.go declares the list apart from writing it: assetNames reads the source's css/ and js/, the examples' pictures and the declared gallery files (declareAsset, called from gallery.go's init for gallery.css, gallery.js and frame.js); assets writes exactly those names; TestTreeShapeIsComplete expects exactly those names, plain and hashed from the plain file's bytes. Mutations: a declared asset whose plain copy the render omits (the seams name assets/css/shell.css and the render wrote no such file) and an undeclared putAsset (the render wrote assets/stray.css, which no seam names). TestTheAssetDeclarationDrivesTheTree registers a scratch asset, finds it in the tree plain and hashed, and sees a name declared twice refused. The modal route: the modal example's page is modal.html, so Task 7's standalone modal would have collided with it at put. reservedDocuments names the five files extraDocuments will write, demo-modal.html among them, and theme refuses a page kind or example that takes one. TestTheReservedDocumentsCollideWithNoPage holds the five, holds modal.html unreserved, and refuses a scratch manifest with an example named demo. The plan's route contract moves accordingly. The family order: the sequence ended on signin.html because groupFamilies kept the manifest's first-appearance order. familyOrder in pages.go is the gallery's own order, the spec's, and explain refuses a family it does not place; examples/ is untouched. TestEveryPageEndsWithItsPlaceInTheSequence now asserts the literal order and follows the rendered Next links from the Overview, requiring the walk to end on layout-stage.html after as many pages as the sequence has, independently of sequence(). Mutation: shells before formats, reported as both the order and the endpoint. Tests Tasks 3 and 5 would have failed: contentHeadings is the one heading selector, excluding the shell's <h1 rst-shell-title> and anything inside a shell-title element, read by both heading tests and held by TestContentHeadingsSkipTheShellTitle; the framed-document test parses lang and dir off <html> and allows other attributes. TestEveryLookupIsTraced compares total callback counts (mutation: a no-op remover, 309960 calls after removal). TestTheReleaseBaseIsTheSitesOrigin is labelled already passing in its comment, with the mutation recorded: ReleaseBase ending in /2/, reported by name. The first commit's body listed it among the red-first tests; this corrects that. Committed with git commit --only on Task 2's paths while Task 1B's commit sat beneath it; gated with make ci in a detached /var/tmp worktree at HEAD with only these thirteen files copied in: every step green, browser roles on /usr/bin/chromium.
Human
Prompt
Fix Astra's eight findings on 908bc6b: a template-registration seam, one content-heading selector, lang and dir parsed, the declared asset list, the modal route at demo-modal.html, the gallery's own family order ending on layout-stage.html, the tracer-removal count, and the ReleaseBase test relabelled.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Templates register themselves from the file that owns them (registerTemplate), so Task 6 adds its body from started.go; the assets the tree ships are declared apart from being written (declareAsset, assetNames) and the tree gate reads the declaration; the demo and modal documents' names are reserved, the modal route at demo-modal.html beside the modal example's own page; the families are read in the gallery's own order, ending on the shells and layout-stage.html; the shared heading tests skip the shell's title element and its descendants; the frame test parses lang and dir; the tracer test counts calls; the ReleaseBase test is labelled already passing with its mutation.
Agent notes
The plan's modal route moves from modal.html to demo-modal.html (Task 7's modalHref, the Demos item and the modal page's live link); extraDocumentNames entries must be in reservedDocuments. Every seam added here has a caller in this commit; staticcheck U1000 runs over the tests too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 files changed,
+415
−76
internal/gallery/example_page.go+2 −0internal/gallery/family_page.go+2 −0internal/gallery/gallery.go+63 −50internal/gallery/hashed.go+81 −0internal/gallery/i18n.go+15 −2internal/gallery/icons_page.go+2 −0internal/gallery/overview.go+2 −0internal/gallery/page_tmpl.go+2 −0internal/gallery/pages.go+17 −0internal/gallery/tokens_page.go+2 −0internal/gallery/tree_test.go+220 −24internal/gallery/view.go+2 −0internal/www/www_test.go+5 −0
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.100s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN TestTheOneLinkIsUnder25KiBGzipped
build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok amadan.net/rastrillo/fiesta/internal/build 0.019s
=== suite ===
--- shard 0 of 12 ---
==> shard 0 of 12: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok amadan.net/rastrillo/fiesta 0.782s
ok amadan.net/rastrillo/fiesta/check 3.961s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 35.314s
ok amadan.net/rastrillo/fiesta/example 0.212s
ok amadan.net/rastrillo/fiesta/internal/axe 0.094s
ok amadan.net/rastrillo/fiesta/internal/build 1.044s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.022s
ok amadan.net/rastrillo/fiesta/internal/gallery 22.981s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.016s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.314s
ok amadan.net/rastrillo/fiesta/internal/site 2.515s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.009s
ok amadan.net/rastrillo/fiesta/internal/www 30.140s
--- shard 1 of 12 ---
==> shard 1 of 12: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 12 ---
==> shard 2 of 12: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 12 ---
==> shard 3 of 12: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
[90m12:32AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m12:32AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m12:32AM[0m [32mINF[0m [1mscanned ~5942829 bytes (5.94 MB) in 2.89s[0m
[90m12:32AM[0m [32mINF[0m [1mno leaks found[0m
--- shard 4 of 12 ---
==> shard 4 of 12: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok amadan.net/rastrillo/fiesta/internal/harness 11.083s
--- shard 5 of 12 ---
==> shard 5 of 12: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 198.637s
--- shard 6 of 12 ---
==> shard 6 of 12: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 207.931s
--- shard 7 of 12 ---
==> shard 7 of 12: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok amadan.net/rastrillo/fiesta/check 207.223s
--- shard 8 of 12 ---
==> shard 8 of 12: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 50 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 81.360s
--- shard 9 of 12 ---
==> shard 9 of 12: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 49 of 50 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 54.864s
--- shard 10 of 12 ---
==> shard 10 of 12: make browser.internal.www~1of2
./hack/gotest.sh browser 'internal.www~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/www/ runs 17 of 34 tests
ok amadan.net/rastrillo/fiesta/internal/www 65.205s
--- shard 11 of 12 ---
==> shard 11 of 12: make browser.internal.www~2of2
./hack/gotest.sh browser 'internal.www~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/www/ runs 17 of 34 tests
ok amadan.net/rastrillo/fiesta/internal/www 60.855s
Intent
diff --git a/internal/gallery/example_page.go b/internal/gallery/example_page.go| index c36ac2e..1ad83d6 100644 |
| --- a/internal/gallery/example_page.go |
| +++ b/internal/gallery/example_page.go |
| @@ -107,3 +107,5 @@ func (s *site) example(p page, e example.Entry) (body []byte, frames map[string] |
| } |
| return []byte(out), frames, nil |
| } |
| + |
| +func init() { registerTemplate(exampleBody) } |
diff --git a/internal/gallery/family_page.go b/internal/gallery/family_page.go| index adcef50..cd246a2 100644 |
| --- a/internal/gallery/family_page.go |
| +++ b/internal/gallery/family_page.go |
| @@ -59,3 +59,5 @@ func (s *site) familyIn(locale string, f family) family { |
| } |
| return out |
| } |
| + |
| +func init() { registerTemplate(familyBody) } |
diff --git a/internal/gallery/gallery.go b/internal/gallery/gallery.go| index ea74507..d472df7 100644 |
| --- a/internal/gallery/gallery.go |
| +++ b/internal/gallery/gallery.go |
| @@ -222,61 +222,34 @@ func (s *site) put(name string, body []byte) error { |
| // url is the absolute URL of one file of the tree. |
| func (s *site) url(rel string) string { return s.mount + "/" + rel } |
| -// assets writes the files the frames and pages link, each hashed and |
| -// plain: css/ and js/ under assets/, the examples' pictures under |
| -// assets/images/, and the gallery's own three files beside them, the |
| -// stylesheet and the script each the join of its two halves. |
| +// The gallery's own assets, declared where their bytes are: the |
| +// stylesheet and the script each the join of its two halves, and the |
| +// frame's script. |
| +func init() { |
| + declareAsset("gallery.css", func(*site) ([]byte, error) { return joined(galleryCSS, viewCSS), nil }) |
| + declareAsset("gallery.js", func(*site) ([]byte, error) { return joined(galleryJS, viewJS), nil }) |
| + declareAsset("frame.js", func(*site) ([]byte, error) { return frameJS, nil }) |
| +} |
| + |
| +// assets writes every declared asset, hashed and plain, under assets/: |
| +// exactly the names assetNames lists, nothing else. |
| func (s *site) assets() error { |
| - for _, dir := range []string{"css", "js"} { |
| - err := fs.WalkDir(s.src, dir, func(p string, d fs.DirEntry, err error) error { |
| - if err != nil { |
| - return err |
| - } |
| - if d.IsDir() { |
| - return nil |
| - } |
| - b, err := fs.ReadFile(s.src, p) |
| - if err != nil { |
| - return err |
| - } |
| - return s.putAsset(p, b) |
| - }) |
| - if err != nil { |
| - return err |
| - } |
| - } |
| - for _, theme := range s.themes() { |
| - if _, ok := s.hashed["css/themes/"+theme+".css"]; !ok { |
| - return fmt.Errorf("no css/themes/%s.css in the source", theme) |
| - } |
| - } |
| - pictures, err := fs.ReadDir(s.src, "examples/assets") |
| + names, err := s.assetNames() |
| if err != nil { |
| return err |
| } |
| - for _, e := range pictures { |
| - if e.IsDir() { |
| - continue |
| - } |
| - b, err := fs.ReadFile(s.src, "examples/assets/"+e.Name()) |
| + for _, name := range names { |
| + b, err := s.assetBody(name) |
| if err != nil { |
| - return err |
| + return fmt.Errorf("%s: %w", name, err) |
| } |
| - if err := s.putAsset("images/"+e.Name(), b); err != nil { |
| + if err := s.putAsset(name, b); err != nil { |
| return err |
| } |
| } |
| - own := []struct { |
| - name string |
| - body []byte |
| - }{ |
| - {"gallery.css", joined(galleryCSS, viewCSS)}, |
| - {"gallery.js", joined(galleryJS, viewJS)}, |
| - {"frame.js", frameJS}, |
| - } |
| - for _, a := range own { |
| - if err := s.putAsset(a.name, a.body); err != nil { |
| - return err |
| + for _, theme := range s.themes() { |
| + if _, ok := s.hashed["css/themes/"+theme+".css"]; !ok { |
| + return fmt.Errorf("no css/themes/%s.css in the source", theme) |
| } |
| } |
| return nil |
| @@ -348,8 +321,11 @@ func (s *site) schemeMenu() error { |
| return nil |
| } |
| -// groupFamilies lists the families in the order the manifest first |
| -// names them, each with its examples in manifest order. |
| +// groupFamilies lists the families in familyOrder, each with its |
| +// examples in manifest order. A family the order does not name comes |
| +// last, in the order the manifest first names it, and explain refuses |
| +// it by name: the gallery's reading order is its own, not the |
| +// manifest's. |
| func groupFamilies(entries []example.Entry) []family { |
| var out []family |
| at := map[string]int{} |
| @@ -362,6 +338,15 @@ func groupFamilies(entries []example.Entry) []family { |
| } |
| out[i].Examples = append(out[i].Examples, e) |
| } |
| + rank := func(key string) int { |
| + for i, k := range familyOrder { |
| + if k == key { |
| + return i |
| + } |
| + } |
| + return len(familyOrder) |
| + } |
| + sort.SliceStable(out, func(i, j int) bool { return rank(out[i].Key) < rank(out[j].Key) }) |
| return out |
| } |
| @@ -373,6 +358,9 @@ func (s *site) explain() error { |
| at := map[string]int{} |
| for i := range s.families { |
| f := &s.families[i] |
| + if !inFamilyOrder(f.Key) { |
| + return fmt.Errorf("the manifest has a %s family, which familyOrder does not place", f.Key) |
| + } |
| fp, ok := s.prose.Families[f.Key] |
| if !ok || fp.Title == "" { |
| return fmt.Errorf("%s has no title for the %s family", proseFile, f.Key) |
| @@ -499,6 +487,9 @@ func (s *site) newPage(theme, locale, kind, file, name string) page { |
| func (s *site) theme(theme, locale string) error { |
| dir := theme + "/" + locale + "/" |
| for _, pk := range s.pageKinds() { |
| + if reserved(pk.File) { |
| + return fmt.Errorf("the %s page kind takes %s, which is reserved for an extra document", pk.Kind, pk.File) |
| + } |
| p := s.newPage(theme, locale, pk.Kind, pk.File, s.titleOf(locale, pk)) |
| p.TitleID = pk.Kind |
| if pk.Kind != "overview" { |
| @@ -515,6 +506,9 @@ func (s *site) theme(theme, locale string) error { |
| } |
| } |
| for _, e := range s.m.Examples { |
| + if reserved(e.Name + ".html") { |
| + return fmt.Errorf("the %s example takes %s.html, which is reserved for an extra document", e.Name, e.Name) |
| + } |
| p := s.newPage(theme, locale, e.Family, e.Name+".html", e.Name) |
| p.Up = p.Home + "#nav-" + e.Family |
| body, frames, err := s.example(p, e) |
| @@ -559,14 +553,33 @@ func (s *site) theme(theme, locale string) error { |
| return nil |
| } |
| +// reservedDocuments are the file names, under a theme and locale |
| +// directory, that the documents extraDocuments will write are to take: |
| +// the demo application's four and the modal route's. They are reserved |
| +// here so no page kind or example page can take one first: the modal |
| +// example's own page is modal.html, so the standalone modal route is |
| +// demo-modal.html beside the demo's documents. theme checks every page |
| +// it writes against this list. |
| +var reservedDocuments = []string{"demo.html", "demo-dashboard.html", "demo-requests.html", "demo-request.html", "demo-modal.html"} |
| + |
| +// reserved says whether file is one of reservedDocuments. |
| +func reserved(file string) bool { |
| + for _, r := range reservedDocuments { |
| + if r == file { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // extraDocuments is every document of one theme and locale that is |
| // neither a page kind's, an example's nor a frame's, keyed by path |
| // under the directory. Nothing yet: the demo application and the modal |
| -// route land here. |
| +// route land here, under names from reservedDocuments. |
| func (s *site) extraDocuments(theme, locale string) (map[string][]byte, error) { return nil, nil } |
| // extraDocumentNames lists the paths extraDocuments writes, so the |
| -// tree gate expects them. |
| +// tree gate expects them; each is one of reservedDocuments. |
| func (s *site) extraDocumentNames() []string { return nil } |
| // write executes the page template around p and puts the result at |
diff --git a/internal/gallery/hashed.go b/internal/gallery/hashed.go| index aedf100..98111c2 100644 |
| --- a/internal/gallery/hashed.go |
| +++ b/internal/gallery/hashed.go |
| @@ -4,7 +4,9 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "fmt" |
| + "io/fs" |
| "path" |
| + "sort" |
| "strings" |
| ) |
| @@ -16,6 +18,85 @@ import ( |
| // page of new markup drawn by old rules. A name that changes whenever |
| // the bytes do cannot be served stale. |
| +// The declaration. What the tree ships under assets/ is declared apart |
| +// from being written: the source's css/ and js/ files and the examples' |
| +// pictures, which assetNames reads off the source, and the gallery's |
| +// own files, which the file that owns each declares at init through |
| +// declareAsset. assets writes exactly the declared names, and the tree |
| +// gate expects exactly them, so an asset the render forgot and one it |
| +// wrote unasked both fail the gate. |
| + |
| +// assetSource is one declared asset of the gallery's own: its plain |
| +// name under assets/ and how to read its bytes. |
| +type assetSource struct { |
| + Name string |
| + Body func(s *site) ([]byte, error) |
| +} |
| + |
| +var declaredAssets []assetSource |
| + |
| +// declareAsset registers one of the gallery's own assets. Call it from |
| +// an init function in the file that owns the bytes. |
| +func declareAsset(name string, body func(s *site) ([]byte, error)) { |
| + declaredAssets = append(declaredAssets, assetSource{Name: name, Body: body}) |
| +} |
| + |
| +// assetNames is every plain name the tree ships under assets/, sorted: |
| +// css/ and js/ as the source has them, images/<picture> for every file |
| +// under examples/assets, and the declared ones. It reads the source and |
| +// the declaration only, never what has been written. |
| +func (s *site) assetNames() ([]string, error) { |
| + var names []string |
| + for _, dir := range []string{"css", "js"} { |
| + err := fs.WalkDir(s.src, dir, func(p string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if !d.IsDir() { |
| + names = append(names, p) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + } |
| + pictures, err := fs.ReadDir(s.src, "examples/assets") |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, e := range pictures { |
| + if !e.IsDir() { |
| + names = append(names, "images/"+e.Name()) |
| + } |
| + } |
| + for _, a := range declaredAssets { |
| + names = append(names, a.Name) |
| + } |
| + sort.Strings(names) |
| + for i := 1; i < len(names); i++ { |
| + if names[i] == names[i-1] { |
| + return nil, fmt.Errorf("the asset %s is declared twice", names[i]) |
| + } |
| + } |
| + return names, nil |
| +} |
| + |
| +// assetBody reads one declared asset's bytes: a source file by its path, |
| +// a picture from examples/assets, or the gallery's own through its |
| +// declaration. |
| +func (s *site) assetBody(name string) ([]byte, error) { |
| + for _, a := range declaredAssets { |
| + if a.Name == name { |
| + return a.Body(s) |
| + } |
| + } |
| + if strings.HasPrefix(name, "images/") { |
| + return fs.ReadFile(s.src, "examples/assets/"+strings.TrimPrefix(name, "images/")) |
| + } |
| + return fs.ReadFile(s.src, name) |
| +} |
| + |
| // hashedName is the name an asset is linked by: its stem, sixteen hex |
| // digits of the sha256 of its bytes, and its extension. |
| func hashedName(name string, body []byte) string { |
diff --git a/internal/gallery/i18n.go b/internal/gallery/i18n.go| index 41f29e9..5da7861 100644 |
| --- a/internal/gallery/i18n.go |
| +++ b/internal/gallery/i18n.go |
| @@ -49,7 +49,20 @@ func (s *site) textOf(locale, id string, args ...string) string { |
| return s.proseIn(locale, id, s.prose.english(id)) |
| } |
| -// templates is every template constant parsed into one tree with say |
| +// templateSources is every template the pages are drawn from, each |
| +// registered by the file that owns it at init, so a page kind in a |
| +// file of its own brings its body template with it and nothing here |
| +// lists it. Go runs a package's init functions in file-name order, so |
| +// the order is fixed; it does not matter to the parse, since each |
| +// source is a {{define}} block of its own. |
| +var templateSources []string |
| + |
| +// registerTemplate adds one template source to the tree every locale's |
| +// templates are parsed from. Call it from an init function beside the |
| +// constant it registers. |
| +func registerTemplate(src string) { templateSources = append(templateSources, src) } |
| + |
| +// templates is every registered template parsed into one tree with say |
| // bound to locale, built once per locale and kept for the render. |
| func (s *site) templates(locale string) (*template.Template, error) { |
| if t, ok := s.tmpl[locale]; ok { |
| @@ -61,7 +74,7 @@ func (s *site) templates(locale string) (*template.Template, error) { |
| "anchor": anchorID, |
| } |
| t := template.New("gallery").Funcs(funcs) |
| - for _, src := range []string{pageTemplate, viewTemplate, overviewBody, familyBody, exampleBody, tokensBody, iconsBody} { |
| + for _, src := range templateSources { |
| if _, err := t.Parse(src); err != nil { |
| return nil, err |
| } |
diff --git a/internal/gallery/icons_page.go b/internal/gallery/icons_page.go| index 3780b74..ba66e19 100644 |
| --- a/internal/gallery/icons_page.go |
| +++ b/internal/gallery/icons_page.go |
| @@ -33,3 +33,5 @@ func (s *site) iconMarkup(slug string) template.HTML { |
| } |
| return "" |
| } |
| + |
| +func init() { registerTemplate(iconsBody) } |
diff --git a/internal/gallery/overview.go b/internal/gallery/overview.go| index 7526476..1c18d07 100644 |
| --- a/internal/gallery/overview.go |
| +++ b/internal/gallery/overview.go |
| @@ -33,3 +33,5 @@ func (s *site) body(locale, name string, data any) (template.HTML, error) { |
| } |
| return template.HTML(b.String()), nil |
| } |
| + |
| +func init() { registerTemplate(overviewBody) } |
diff --git a/internal/gallery/page_tmpl.go b/internal/gallery/page_tmpl.go| index 378d091..2f49839 100644 |
| --- a/internal/gallery/page_tmpl.go |
| +++ b/internal/gallery/page_tmpl.go |
| @@ -48,3 +48,5 @@ const pageTemplate = `{{define "ds-controls"}}<nav rst-seg-tabs aria-label="{{sa |
| </body> |
| </html> |
| {{end}}` |
| + |
| +func init() { registerTemplate(pageTemplate) } |
diff --git a/internal/gallery/pages.go b/internal/gallery/pages.go| index 362faf5..a97884e 100644 |
| --- a/internal/gallery/pages.go |
| +++ b/internal/gallery/pages.go |
| @@ -20,6 +20,23 @@ import ( |
| // internal/www holds it to its own origin. |
| const ReleaseBase = "https://fiesta.rastrillo.org/1/" |
| +// familyOrder is the order the families are read in: the gallery's |
| +// own, as the spec gives it, and not the order the manifest happens to |
| +// name them in. It is the family rows of pageKinds, the sections of |
| +// the rail and the walk of the sequence, which ends on the last shell. |
| +// A family in the manifest and not here fails the render by name. |
| +var familyOrder = []string{"list-screen", "display", "form", "choices-and-actions", "date-and-time", "route", "primitives", "formats", "screens", "shells"} |
| + |
| +// inFamilyOrder says whether familyOrder places key. |
| +func inFamilyOrder(key string) bool { |
| + for _, k := range familyOrder { |
| + if k == key { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // pageKind is one row of the table. Title and Blurb are ids: a |
| // gallery.* id into text, or a prose slot id (families.<key>.title); |
| // textOf resolves either. Blurb is the one sentence the Overview says |
diff --git a/internal/gallery/tokens_page.go b/internal/gallery/tokens_page.go| index 7e2910d..280a413 100644 |
| --- a/internal/gallery/tokens_page.go |
| +++ b/internal/gallery/tokens_page.go |
| @@ -54,3 +54,5 @@ func (s *site) tokensBody(p page) (template.HTML, error) { |
| Groups []tokenGroupView |
| }{p, groups}) |
| } |
| + |
| +func init() { registerTemplate(tokensBody) } |
diff --git a/internal/gallery/tree_test.go b/internal/gallery/tree_test.go| index 4f79b85..590204a 100644 |
| --- a/internal/gallery/tree_test.go |
| +++ b/internal/gallery/tree_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "fmt" |
| "io/fs" |
| "regexp" |
| + "slices" |
| "sort" |
| "strings" |
| "testing" |
| @@ -88,12 +89,22 @@ func TestTreeShapeIsComplete(t *testing.T) { |
| want[dir+n] = true |
| } |
| } |
| - if len(s.hashed) == 0 { |
| - t.Fatal("the site lists no assets; the check below would expect none") |
| + // The assets: the declaration, not what was written. Each declared |
| + // name is expected plain and under one hashed sibling with the same |
| + // bytes; the hashed name is worked out from the plain file's bytes, |
| + // so a declared asset the render skipped is a missing plain file. |
| + names, err := s.assetNames() |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - for plain, hashed := range s.hashed { |
| - want["assets/"+plain] = true |
| - want["assets/"+hashed] = true |
| + if len(names) == 0 { |
| + t.Fatal("the site declares no assets; the check below would expect none") |
| + } |
| + for _, name := range names { |
| + want["assets/"+name] = true |
| + if body, ok := files["assets/"+name]; ok { |
| + want["assets/"+hashedName(name, body)] = true |
| + } |
| } |
| // By fixed name: what no seam may lose. |
| @@ -133,7 +144,84 @@ func TestTreeShapeIsComplete(t *testing.T) { |
| t.Errorf("the tree holds %d files, the seams name %d", len(files), len(want)) |
| } |
| t.Logf("%d files: %d directories of %d pages, %d example pages and %d frames each; %d assets twice; the root index", |
| - len(files), len(dirs()), len(s.pageKinds()), len(m.Examples), len(fixed)/len(dirs())-3-len(groupFamilies(m.Examples))-len(m.Examples), len(s.hashed)) |
| + len(files), len(dirs()), len(s.pageKinds()), len(m.Examples), len(fixed)/len(dirs())-3-len(groupFamilies(m.Examples))-len(m.Examples), len(names)) |
| +} |
| + |
| +// TestTheAssetDeclarationDrivesTheTree: the assets the tree ships are |
| +// the declared ones, read from the source and the registry, not from |
| +// what was written; an asset declared from any file is in the tree |
| +// under its plain and its hashed name the next render. The control is |
| +// a scratch declaration, registered and then withdrawn. |
| +func TestTheAssetDeclarationDrivesTheTree(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), "") |
| + names, err := s.assetNames() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, want := range []string{"css/tokens.css", "css/themes/day.css", "css/shell.css", "js/fiesta.js", "images/sample-logo.svg", "gallery.css", "gallery.js", "frame.js"} { |
| + if !slices.Contains(names, want) { |
| + t.Errorf("assetNames does not declare %s", want) |
| + } |
| + } |
| + saved := declaredAssets |
| + t.Cleanup(func() { declaredAssets = saved }) |
| + declareAsset("scratch/test.css", func(*site) ([]byte, error) { return []byte("/* scratch */"), nil }) |
| + files, err := Render(fiesta.FS(), Options{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hashed := hashedName("scratch/test.css", []byte("/* scratch */")) |
| + if _, ok := files["assets/scratch/test.css"]; !ok { |
| + t.Error("a declared asset is not in the tree under its plain name") |
| + } |
| + if _, ok := files["assets/"+hashed]; !ok { |
| + t.Errorf("a declared asset is not in the tree under %s", hashed) |
| + } |
| + declareAsset("scratch/test.css", func(*site) ([]byte, error) { return nil, nil }) |
| + if _, err := Render(fiesta.FS(), Options{}); err == nil || !strings.Contains(err.Error(), "declared twice") { |
| + t.Errorf("a name declared twice did not fail the render by name: %v", err) |
| + } |
| +} |
| + |
| +// TestTheReservedDocumentsCollideWithNoPage: the names the demo |
| +// application and the modal route will take are reserved, and no page |
| +// kind or example page takes one; the modal example's page is |
| +// modal.html, so the route is demo-modal.html. The control is a |
| +// scratch manifest with an example named demo, which the render |
| +// refuses by name. |
| +func TestTheReservedDocumentsCollideWithNoPage(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), "") |
| + for _, want := range []string{"demo.html", "demo-dashboard.html", "demo-requests.html", "demo-request.html", "demo-modal.html"} { |
| + if !reserved(want) { |
| + t.Errorf("%s is not reserved", want) |
| + } |
| + } |
| + if reserved("modal.html") { |
| + t.Error("modal.html is reserved, and it is the modal example's own page") |
| + } |
| + for _, pk := range s.pageKinds() { |
| + if reserved(pk.File) { |
| + t.Errorf("the %s page kind takes the reserved %s", pk.Kind, pk.File) |
| + } |
| + } |
| + for _, e := range s.m.Examples { |
| + if reserved(e.Name + ".html") { |
| + t.Errorf("the %s example takes a reserved name", e.Name) |
| + } |
| + } |
| + for _, n := range s.extraDocumentNames() { |
| + if !reserved(n) { |
| + t.Errorf("extraDocumentNames lists %s, which is not reserved", n) |
| + } |
| + } |
| + raw, err := fs.ReadFile(fiesta.FS(), "examples/examples.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + scratch := overlay{fiesta.FS(), map[string][]byte{"examples/examples.json": bytes.Replace(raw, []byte(`"name": "button"`), []byte(`"name": "demo"`), 1)}} |
| + if _, err := Render(scratch, Options{}); err == nil || !strings.Contains(err.Error(), "reserved") { |
| + t.Errorf("an example named demo did not fail the render as reserved: %v", err) |
| + } |
| } |
| // TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot: index.html at |
| @@ -235,6 +323,37 @@ func TestEveryPageEndsWithItsPlaceInTheSequence(t *testing.T) { |
| } |
| } |
| } |
| + // The family order is the gallery's own and fixed, and the walk ends |
| + // on the last shell: asserted against the literal list, and by |
| + // following the rendered Next links from the Overview to the end, |
| + // independently of sequence(). |
| + var keys []string |
| + for _, f := range s.families { |
| + keys = append(keys, f.Key) |
| + } |
| + if want := []string{"list-screen", "display", "form", "choices-and-actions", "date-and-time", "route", "primitives", "formats", "screens", "shells"}; !slices.Equal(keys, want) { |
| + t.Errorf("the families are ordered %v, want %v", keys, want) |
| + } |
| + at := theme + "/" + locale + "/index.html" |
| + visited := map[string]bool{} |
| + for { |
| + visited[at] = true |
| + next, ok := parse(t, files[at]).first(byClass("ds-updown__next")) |
| + if !ok { |
| + break |
| + } |
| + href, _ := next.attr("href") |
| + at = strings.TrimPrefix(href, "/") |
| + if visited[at] { |
| + t.Fatalf("the Next links loop back to %s", at) |
| + } |
| + } |
| + if want := theme + "/" + locale + "/layout-stage.html"; at != want { |
| + t.Errorf("following Next from the Overview ends on %s, want %s", at, want) |
| + } |
| + if len(visited) != len(seq) { |
| + t.Errorf("following Next visits %d pages, the sequence has %d", len(visited), len(seq)) |
| + } |
| // The shape the spec describes, read off the expectation the |
| // implementation was held to above. |
| for _, f := range s.families { |
| @@ -398,17 +517,7 @@ func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) { |
| continue |
| } |
| pages++ |
| - h1s := doc.find(func(n node) bool { |
| - if n.n.Data != "h1" { |
| - return false |
| - } |
| - for p := n.n.Parent; p != nil; p = p.Parent { |
| - if _, shellTitle := (node{p}).attr("rst-shell-title"); shellTitle { |
| - return false |
| - } |
| - } |
| - return true |
| - }) |
| + h1s := contentHeadings(doc) |
| if len(h1s) != 1 { |
| t.Errorf("%s has %d <h1> outside the shell title, want 1", name, len(h1s)) |
| } |
| @@ -420,6 +529,24 @@ func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) { |
| } |
| } |
| +// contentHeadings is every <h1> of the page's content: not the shell's |
| +// own title, <h1 rst-shell-title>, nor anything inside it. The one |
| +// selector both heading tests read, so the shell's index heading is |
| +// never counted as the page's. |
| +func contentHeadings(doc node) []node { |
| + return doc.find(func(n node) bool { |
| + if n.n.Data != "h1" { |
| + return false |
| + } |
| + for p := n.n; p != nil; p = p.Parent { |
| + if _, shellTitle := (node{p}).attr("rst-shell-title"); shellTitle { |
| + return false |
| + } |
| + } |
| + return true |
| + }) |
| +} |
| + |
| // anchorIDs is the set of ids on the elements carrying data-ds-anchor. |
| func anchorIDs(t *testing.T, doc []byte) []string { |
| t.Helper() |
| @@ -493,11 +620,12 @@ func TestEveryPagesH1IsItsOwnTitle(t *testing.T) { |
| } |
| doc := parse(t, body) |
| title := titleOf(t, doc) |
| - h1, ok := doc.first(func(n node) bool { return n.n.Data == "h1" }) |
| - if !ok { |
| + h1s := contentHeadings(doc) |
| + if len(h1s) == 0 { |
| t.Errorf("%s has no <h1>", name) |
| continue |
| } |
| + h1 := h1s[0] |
| page, _, _ := strings.Cut(title, " · ") |
| if h1.text() != page { |
| t.Errorf("%s: the <h1> reads %q, the title opens %q", name, h1.text(), page) |
| @@ -742,8 +870,17 @@ func TestAnExampleDocumentFramesLikeASeededOne(t *testing.T) { |
| if err != nil { |
| t.Fatalf("frame: %v", err) |
| } |
| + html, ok := parse(t, []byte(framed)).first(func(n node) bool { return n.n.Data == "html" }) |
| + if !ok { |
| + t.Fatal("the framed document has no <html>") |
| + } |
| + if lang, _ := html.attr("lang"); lang != "ar" { |
| + t.Errorf("the framed document says lang=%q, want ar", lang) |
| + } |
| + if dir, _ := html.attr("dir"); dir != "rtl" { |
| + t.Errorf("the framed document says dir=%q, want rtl", dir) |
| + } |
| for _, want := range []string{ |
| - `<html lang="ar" dir="rtl">`, |
| `<title>A test</title>`, |
| `<script src="` + s.asset("frame.js") + `"></script>`, |
| `href="` + s.asset("css/tokens.css") + `"`, |
| @@ -772,7 +909,8 @@ func TestAnExampleDocumentFramesLikeASeededOne(t *testing.T) { |
| // read. |
| func TestEveryLookupIsTraced(t *testing.T) { |
| seen := map[string]int{} |
| - stop := setTrace(func(id string) { seen[id]++ }) |
| + calls := 0 |
| + stop := setTrace(func(id string) { seen[id]++; calls++ }) |
| if _, err := Render(fiesta.FS(), Options{}); err != nil { |
| t.Fatal(err) |
| } |
| @@ -787,12 +925,70 @@ func TestEveryLookupIsTraced(t *testing.T) { |
| t.Errorf("a render asked for %s, which is gone", id) |
| } |
| } |
| - before := len(seen) |
| + if calls == 0 { |
| + t.Fatal("the tracer heard nothing") |
| + } |
| + before := calls |
| if _, err := Render(fiesta.FS(), Options{}); err != nil { |
| t.Fatal(err) |
| } |
| - if len(seen) != before { |
| - t.Errorf("the tracer heard %d ids after it was removed", len(seen)-before) |
| + if calls != before { |
| + t.Errorf("the tracer was called %d more times after it was removed", calls-before) |
| + } |
| +} |
| + |
| +// TestARegisteredTemplateIsParsedForEveryLocale: a template registered |
| +// from any file, through registerTemplate, is in the tree every |
| +// locale's pages are drawn from, as the five body templates are. The |
| +// control is a scratch define, registered and then withdrawn. |
| +func TestARegisteredTemplateIsParsedForEveryLocale(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), "") |
| + for _, locale := range []string{"en", "ar"} { |
| + tmpl, err := s.templates(locale) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"ds-page", "ds-controls", "ds-view", "ds-body-overview", "ds-body-family", "ds-body-example", "ds-body-tokens", "ds-body-icons"} { |
| + if tmpl.Lookup(name) == nil { |
| + t.Errorf("%s: no template %s", locale, name) |
| + } |
| + } |
| + if tmpl.Lookup("ds-body-scratch") != nil { |
| + t.Fatal("a scratch template is already registered") |
| + } |
| + } |
| + saved := templateSources |
| + t.Cleanup(func() { templateSources = saved }) |
| + registerTemplate(`{{define "ds-body-scratch"}}<p>{{say "gallery.brand"}}</p>{{end}}`) |
| + fresh := newTestSite(t, fiesta.FS(), "") |
| + tmpl, err := fresh.templates("ga") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if tmpl.Lookup("ds-body-scratch") == nil { |
| + t.Error("a template registered after the others is not parsed") |
| + } |
| + var b strings.Builder |
| + if err := tmpl.ExecuteTemplate(&b, "ds-body-scratch", nil); err != nil || b.String() != "<p>Fiesta</p>" { |
| + t.Errorf("the registered template rendered %q, %v", b.String(), err) |
| + } |
| +} |
| + |
| +// TestContentHeadingsSkipTheShellTitle: the heading selector the two |
| +// heading tests share counts a content <h1> and not the shell's own |
| +// <h1 rst-shell-title>, nor a heading inside a shell-title element. |
| +func TestContentHeadingsSkipTheShellTitle(t *testing.T) { |
| + doc := parse(t, []byte(`<body><div rst-shell-sidebar="index"><h1 rst-shell-title><span></span><span>Fiesta</span></h1><div rst-shell-title><h1>inner</h1></div><main><h1 id="x">Tokens</h1></main></div></body>`)) |
| + got := contentHeadings(doc) |
| + if len(got) != 1 || got[0].text() != "Tokens" { |
| + var texts []string |
| + for _, n := range got { |
| + texts = append(texts, n.text()) |
| + } |
| + t.Errorf("contentHeadings found %v, want [Tokens]", texts) |
| + } |
| + if n := len(doc.find(func(n node) bool { return n.n.Data == "h1" })); n < 3 { |
| + t.Fatalf("the fixture parsed to %d h1 elements; the selector had nothing to skip", n) |
| } |
| } |
diff --git a/internal/gallery/view.go b/internal/gallery/view.go| index bc66a95..025b10b 100644 |
| --- a/internal/gallery/view.go |
| +++ b/internal/gallery/view.go |
| @@ -80,3 +80,5 @@ const viewTemplate = `{{define "ds-view"}}<div class="ds-view{{.Class}}"{{with . |
| {{end}}<pre class="ds-src rst-mono" tabindex="0"{{if .NoCopy}} data-ds-nocopy{{end}}><code>{{.Source}}</code></pre> |
| </details> |
| {{end}}</div>{{end}}` |
| + |
| +func init() { registerTemplate(viewTemplate) } |
diff --git a/internal/www/www_test.go b/internal/www/www_test.go| index f33e76a..55d7126 100644 |
| --- a/internal/www/www_test.go |
| +++ b/internal/www/www_test.go |
| @@ -105,6 +105,11 @@ func TestTheDesignSystemIsMounted(t *testing.T) { |
| // TestTheReleaseBaseIsTheSitesOrigin: the base the design system |
| // writes its release-file links under is this site's origin and its |
| // /1/ rewrite, so a link to a shipped file is one of the site's own. |
| +// |
| +// Already passing when written: the constant was declared at its final |
| +// value. Proved live by a named mutation, gallery.ReleaseBase changed |
| +// to end in /2/, which it reported as "is https://fiesta.rastrillo.org/2/, |
| +// want https://fiesta.rastrillo.org/1/". |
| func TestTheReleaseBaseIsTheSitesOrigin(t *testing.T) { |
| if gallery.ReleaseBase != www.Origin+"/1/" { |
| t.Errorf("gallery.ReleaseBase is %q, want %q", gallery.ReleaseBase, www.Origin+"/1/") |