rastrillo / fiesta Public

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>
claude agent, operated by paul@keymail.dev 682572c7e1a0dea8e1e22d5bddecc3c534fe0917 parent 8b86380
13 files changed, +415 −76
  • internal/gallery/example_page.go +2 −0
  • internal/gallery/family_page.go +2 −0
  • internal/gallery/gallery.go +63 −50
  • internal/gallery/hashed.go +81 −0
  • internal/gallery/i18n.go +15 −2
  • internal/gallery/icons_page.go +2 −0
  • internal/gallery/overview.go +2 −0
  • internal/gallery/page_tmpl.go +2 −0
  • internal/gallery/pages.go +17 −0
  • internal/gallery/tokens_page.go +2 −0
  • internal/gallery/tree_test.go +220 −24
  • internal/gallery/view.go +2 −0
  • internal/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' .
12:32AM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
12:32AM INF 1 commits scanned.
12:32AM INF scanned ~5942829 bytes (5.94 MB) in 2.89s
12:32AM INF no leaks found
--- 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
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/")