rastrillo / fiesta Public

The example package and the seed exporters, with the generated tree held for copy review

Package example is the public reader of examples/: the manifest
(examples/examples.json), one state's body (Markup, which uses the
x/net tokenizer only to find <body> and </body> and returns the bytes
between them as written), a whole layout document (Document), the
layout slot (Slot) and every non-document state in manifest order
(All). Its tests run on a synthetic tree and on the real one.

hack/seed/_rastrillo/ holds the two exporters, package designsystem
and package ui, behind the fiestaseed build tag; hack/seed/run.sh
copies them into a scratch rastrillo worktree at SEED, runs them with
FIESTA_SEED_OUT pointing here, and removes the worktree on exit
whatever happened. FIESTA_SEED_DRYRUN=1 writes nothing and lists the
state labels that are not already English strings in rastrillo's
gallery: one, the scheme menu's standalone state.

check/i18n_test.go holds the shape of i18n/<lang>.json: twelve files,
twenty-one keys, seventeen date words, no empty value, with a control
leg that proves the check catches a broken file.

norastrillo_test.go now lists ./example among the public packages
that link no browser.

Summary

Adds the public example package (Load, Entry, Default, Idiom, Markup, Document, Slot, All) with its tests, the two seed exporters that run inside a scratch rastrillo worktree at SEED, hack/seed/run.sh, the i18n shape test, and golang.org/x/net v0.58.0. The generated examples, fixtures and language files are not in this commit.
Agent notes
Phase A of Task 5. The tests over the real examples/ and i18n/ fail on this commit alone: the generated tree waits for Paul's copy review of one state label and the two READMEs, and lands in Phase B together with the fiesta.go embed of examples/. x/net is pinned at v0.58.0, the newest that keeps go 1.25.0 in go.mod; v0.60.0 and later need 1.26.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f2ec990adc0a81fd5f3771062783730b37488f7b parent 3e1b1d8
9 files changed, +1974 −9
  • check/i18n_test.go +158 −0
  • example/example.go +229 −0
  • example/example_test.go +454 −0
  • go.mod +2 −1
  • go.sum +4 −2
  • hack/seed/_rastrillo/designsystem_fiestaseed_test.go +707 −0
  • hack/seed/_rastrillo/ui_fiestaseed_test.go +370 −0
  • hack/seed/run.sh +45 −0
  • norastrillo_test.go +5 −6

CI failed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.938s
=== suite ===
--- shard 0 of 6 ---
==> shard 0 of 6: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	0.184s
--- FAIL: TestEveryLanguageFileHasTheShape (0.00s)
    i18n_test.go:131: reading i18n/: open ../i18n: no such file or directory
FAIL
FAIL	amadan.net/rastrillo/fiesta/check	0.289s
--- FAIL: TestDefaultIsAState (0.00s)
    example_test.go:110: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
--- FAIL: TestTheManifestMatchesTheFiles (0.00s)
    example_test.go:135: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
--- FAIL: TestExamplesLinkOnlyFiestasOwnFiles (0.00s)
    example_test.go:245: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
--- FAIL: TestEveryIdiomIsMapped (0.00s)
    example_test.go:286: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
--- FAIL: TestSampleURLsAreDeclared (0.00s)
    example_test.go:320: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
--- FAIL: TestEveryStateIsValidOnItsOwn (0.00s)
    example_test.go:353: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
--- FAIL: TestTheStandaloneSchemeStateOwnsItsStorage (0.00s)
    example_test.go:385: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
--- FAIL: TestAllWalksTheManifestInOrder (0.00s)
    example_test.go:424: the embedded tree has no examples/examples.json: open examples/examples.json: file does not exist
FAIL
FAIL	amadan.net/rastrillo/fiesta/example	0.016s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.010s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.018s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.265s
FAIL
make[1]: *** [Makefile:100: root] Error 1
make: *** [Makefile:63: suite] Error 2

amadan: step suite shard 0 of 6 failed: exit status 2
--- shard 1 of 6 ---
==> shard 1 of 6: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 6 ---
==> shard 2 of 6: 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 0 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 6 ---
==> shard 3 of 6: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
9:38PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
9:38PM INF 1 commits scanned.
9:38PM INF scanned ~1849367 bytes (1.85 MB) in 1.51s
9:38PM INF no leaks found
--- shard 4 of 6 ---
==> shard 4 of 6: 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	9.349s
--- shard 5 of 6 ---
==> shard 5 of 6: make browser.check
./hack/gotest.sh browser 'check'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
--- FAIL: TestEveryLanguageFileHasTheShape (0.00s)
    i18n_test.go:131: reading i18n/: open ../i18n: no such file or directory
FAIL
FAIL	amadan.net/rastrillo/fiesta/check	6.362s
FAIL
make[1]: *** [Makefile:175: browser.check] Error 1
make: *** [Makefile:63: suite] Error 2

amadan: step suite shard 5 of 6 failed: exit status 2

amadan: job failed at step suite shards 0, 5 of 6 — its output is above, not at the tail of this log
diff --git a/check/i18n_test.go b/check/i18n_test.go
new file mode 100644
index 0000000..4903dea
--- /dev/null
+++ b/check/i18n_test.go
@@ -0,0 +1,158 @@
+package check
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "slices"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// The interface strings datetime.js and select.js put on screen, one
+// flat JSON object per shipped language under i18n/, seeded from
+// rastrillo's catalogs at SEED. Task 6 reads them; this holds their
+// shape, so a language file that lost a key fails here rather than as
+// an English label on a Spanish page.
+
+// languages are the twelve files, en first. pt is not a file: it is
+// the test alias in check/testdata/datetime/retagged.json.
+var languages = []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"}
+
+// languageKeys are the twenty string keys, each an attribute's name
+// with data-rst- removed.
+var languageKeys = []string{
+ "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar",
+ "date-prev-month", "date-next-month", "date-results", "date-result-one",
+ "date-quick-today", "date-quick-tomorrow", "date-quick-next-week",
+ "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day",
+ "select-filter", "select-results", "select-result-one", "select-no-matches",
+}
+
+// dateWords are the seventeen names under date-words.
+var dateWords = []string{
+ "today", "tomorrow", "yesterday", "next", "last", "in", "ago", "at",
+ "day", "week", "month", "hour", "minute", "noon", "midnight", "am", "pm",
+}
+
+// languageFileProblems reads one language file and lists what is
+// wrong with its shape: a missing or extra key, a missing or extra
+// word, an empty value, or a hint with no {example}.
+func languageFileProblems(data []byte) []string {
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return []string{"not a JSON object: " + err.Error()}
+ }
+ var problems []string
+ want := append([]string{"date-words"}, languageKeys...)
+ var got []string
+ for k := range obj {
+ got = append(got, k)
+ }
+ sort.Strings(got)
+ for _, k := range want {
+ if _, ok := obj[k]; !ok {
+ problems = append(problems, "missing key "+k)
+ }
+ }
+ for _, k := range got {
+ if !slices.Contains(want, k) {
+ problems = append(problems, "unexpected key "+k)
+ }
+ }
+ for _, k := range languageKeys {
+ raw, ok := obj[k]
+ if !ok {
+ continue
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err != nil {
+ problems = append(problems, k+" is not a string")
+ } else if s == "" {
+ problems = append(problems, k+" is empty")
+ }
+ }
+ if raw, ok := obj["date-hint"]; ok {
+ var s string
+ if json.Unmarshal(raw, &s) == nil && s != "" && !strings.Contains(s, "{example}") {
+ problems = append(problems, "date-hint has no {example}")
+ }
+ }
+ if raw, ok := obj["date-words"]; ok {
+ var words map[string]string
+ if err := json.Unmarshal(raw, &words); err != nil {
+ return append(problems, "date-words is not an object of strings: "+err.Error())
+ }
+ for _, w := range dateWords {
+ v, ok := words[w]
+ if !ok {
+ problems = append(problems, "date-words is missing "+w)
+ } else if v == "" {
+ problems = append(problems, "date-words."+w+" is empty")
+ }
+ }
+ for w := range words {
+ if !slices.Contains(dateWords, w) {
+ problems = append(problems, "date-words has an unexpected "+w)
+ }
+ }
+ }
+ sort.Strings(problems)
+ return problems
+}
+
+// TestEveryLanguageFileHasTheShape: twelve files, twenty-one keys,
+// seventeen words, no empty value. The first leg is the check's own
+// control: a file with one key dropped, one word empty and a hint
+// with no placeholder must be caught, or the pass below means nothing.
+func TestEveryLanguageFileHasTheShape(t *testing.T) {
+ t.Run("control", func(t *testing.T) {
+ bad := map[string]any{"date-words": map[string]string{}}
+ for _, w := range dateWords {
+ bad["date-words"].(map[string]string)[w] = w
+ }
+ bad["date-words"].(map[string]string)["noon"] = ""
+ for _, k := range languageKeys[1:] {
+ bad[k] = k
+ }
+ bad["date-hint"] = "Try:"
+ bad["extra"] = "x"
+ data, _ := json.Marshal(bad)
+ got := languageFileProblems(data)
+ want := []string{"date-hint has no {example}", "date-words.noon is empty", "missing key date-set", "unexpected key extra"}
+ if !slices.Equal(got, want) {
+ t.Fatalf("the control file's problems are %v, want %v", got, want)
+ }
+ })
+
+ entries, err := os.ReadDir(filepath.Join("..", "i18n"))
+ if err != nil {
+ t.Fatalf("reading i18n/: %v", err)
+ }
+ var files []string
+ for _, e := range entries {
+ if filepath.Ext(e.Name()) == ".json" {
+ files = append(files, e.Name())
+ }
+ }
+ var want []string
+ for _, l := range languages {
+ want = append(want, l+".json")
+ }
+ sort.Strings(files)
+ sort.Strings(want)
+ if !slices.Equal(files, want) {
+ t.Errorf("i18n/ holds %v, want %v", files, want)
+ }
+ for _, l := range languages {
+ data, err := os.ReadFile(filepath.Join("..", "i18n", l+".json"))
+ if err != nil {
+ t.Errorf("%s: %v", l, err)
+ continue
+ }
+ for _, p := range languageFileProblems(data) {
+ t.Errorf("i18n/%s.json: %s", l, p)
+ }
+ }
+}
diff --git a/example/example.go b/example/example.go
new file mode 100644
index 0000000..1aa1e97
--- /dev/null
+++ b/example/example.go
@@ -0,0 +1,229 @@
+// Package example reads the design system's examples: one document
+// per component state under examples/, listed by examples/examples.json.
+// It is public so that an application which embeds Fiesta can read the
+// same examples its gallery and its tests do, and it links no browser.
+//
+// Every state is its own document, so its ids, labels and fixed
+// overlays never meet another state's. Markup returns the body of one,
+// exactly as the file holds it; Document returns the whole file, for
+// the layouts, whose <html> and <head> are part of what they show;
+// Slot fills a layout's content region.
+package example
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+// Manifest is examples/examples.json: every example in gallery order,
+// and the idiom table that says which example and state each of the
+// design system's markup idioms became.
+type Manifest struct {
+ Examples []Entry `json:"examples"`
+ Idioms map[string]Idiom `json:"idioms"`
+}
+
+// Entry is one example: a directory under examples/ holding one
+// document per state.
+//
+// Kind is one of component, idiom, layout, format and screen. Document
+// marks a layout, whose states are whole pages with a slot rather than
+// markup to drop into one. Wrapper names the container a component's
+// markup keeps as its outermost element: "", list, form, box or stats.
+// Scripts lists the optional scripts the markup needs, by file name.
+// Default is the state later readers use.
+type Entry struct {
+ Name string `json:"name"`
+ Title string `json:"title"`
+ Family string `json:"family"`
+ Kind string `json:"kind"`
+ Document bool `json:"document"`
+ Wrapper string `json:"wrapper"`
+ Scripts []string `json:"scripts"`
+ Default string `json:"default"`
+ States []State `json:"states"`
+}
+
+// State is one state of an example: its id (the file's stem), its
+// English label, and the application routes its markup links to,
+// sorted.
+type State struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ SampleURLs []string `json:"sampleURLs"`
+}
+
+// Idiom names the example and state an idiom key maps to.
+type Idiom struct {
+ Example string `json:"example"`
+ State string `json:"state"`
+}
+
+// Piece is one state's markup with its address.
+type Piece struct {
+ Name, State, Markup string
+}
+
+// Load reads examples/examples.json from fsys, which is fiesta.FS() or
+// any tree laid out the same way.
+func Load(fsys fs.FS) (*Manifest, error) {
+ b, err := fs.ReadFile(fsys, "examples/examples.json")
+ if err != nil {
+ return nil, fmt.Errorf("example: %w", err)
+ }
+ var m Manifest
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, fmt.Errorf("example: examples/examples.json: %w", err)
+ }
+ return &m, nil
+}
+
+// Entry returns the example called name.
+func (m *Manifest) Entry(name string) (Entry, bool) {
+ for _, e := range m.Examples {
+ if e.Name == name {
+ return e, true
+ }
+ }
+ return Entry{}, false
+}
+
+// Default returns the id of the state later readers use for name: the
+// one the manifest names as its default.
+func (m *Manifest) Default(name string) (string, bool) {
+ e, ok := m.Entry(name)
+ if !ok {
+ return "", false
+ }
+ return e.Default, true
+}
+
+// Idiom returns the example and state the idiom key maps to, so
+// Idiom("dropdown") gives ("dropdown", "menu-idiom", true).
+func (m *Manifest) Idiom(key string) (name, state string, ok bool) {
+ ref, ok := m.Idioms[key]
+ if !ok {
+ return "", "", false
+ }
+ return ref.Example, ref.State, true
+}
+
+// file is the path of one state's document inside the tree. A name or
+// state holding a path separator is refused rather than resolved: the
+// two are identifiers from the manifest, never paths.
+func file(name, state string) (string, error) {
+ if name == "" || state == "" || strings.ContainsAny(name, "/\\") || strings.ContainsAny(state, "/\\") {
+ return "", fmt.Errorf("example: %q/%q is not an example state", name, state)
+ }
+ return "examples/" + name + "/" + state + ".html", nil
+}
+
+// Document returns one state's whole document.
+func Document(fsys fs.FS, name, state string) (string, error) {
+ p, err := file(name, state)
+ if err != nil {
+ return "", err
+ }
+ b, err := fs.ReadFile(fsys, p)
+ if err != nil {
+ return "", fmt.Errorf("example: %w", err)
+ }
+ return string(b), nil
+}
+
+// Markup returns the bytes between <body> and </body> of one state's
+// document, exactly as written, trimmed of one leading and one
+// trailing newline. The tokenizer is used only to find the two tags,
+// so a nested container or a <template> inside the body is bytes like
+// any other, and nothing is re-serialised.
+func Markup(fsys fs.FS, name, state string) (string, error) {
+ doc, err := Document(fsys, name, state)
+ if err != nil {
+ return "", err
+ }
+ start, end, err := body(doc)
+ if err != nil {
+ return "", fmt.Errorf("example: %s/%s: %w", name, state, err)
+ }
+ return strings.TrimSuffix(strings.TrimPrefix(doc[start:end], "\n"), "\n"), nil
+}
+
+// body returns the offsets of the first byte after the <body> start
+// tag and of the </body> end tag.
+func body(doc string) (start, end int, err error) {
+ z := html.NewTokenizer(strings.NewReader(doc))
+ offset := 0
+ start, end = -1, -1
+ for {
+ tt := z.Next()
+ raw := len(z.Raw())
+ switch tt {
+ case html.ErrorToken:
+ if start < 0 {
+ return 0, 0, errors.New("no <body> tag")
+ }
+ return 0, 0, errors.New("no </body> tag")
+ case html.StartTagToken:
+ if n, _ := z.TagName(); start < 0 && string(n) == "body" {
+ start = offset + raw
+ }
+ case html.EndTagToken:
+ if n, _ := z.TagName(); start >= 0 && string(n) == "body" {
+ return start, offset, nil
+ }
+ }
+ offset += raw
+ }
+}
+
+const (
+ slotOpen = "<!-- fiesta:slot -->"
+ slotClose = "<!-- /fiesta:slot -->"
+)
+
+// Slot replaces what lies between the slot comments of a layout
+// document with content, keeping the comments, so the result can be
+// slotted again. It is an error when the comments are absent or
+// appear twice.
+func Slot(doc, content string) (string, error) {
+ if n := strings.Count(doc, slotOpen); n != 1 {
+ return "", fmt.Errorf("example: the document holds %d slots, want one", n)
+ }
+ if n := strings.Count(doc, slotClose); n != 1 {
+ return "", fmt.Errorf("example: the document closes %d slots, want one", n)
+ }
+ i := strings.Index(doc, slotOpen)
+ j := strings.Index(doc, slotClose)
+ if j < i {
+ return "", errors.New("example: the slot closes before it opens")
+ }
+ return doc[:i+len(slotOpen)] + content + doc[j:], nil
+}
+
+// All returns every non-document state, in manifest order, each with
+// its markup.
+func All(fsys fs.FS) ([]Piece, error) {
+ m, err := Load(fsys)
+ if err != nil {
+ return nil, err
+ }
+ var out []Piece
+ for _, e := range m.Examples {
+ if e.Document {
+ continue
+ }
+ for _, s := range e.States {
+ markup, err := Markup(fsys, e.Name, s.ID)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, Piece{Name: e.Name, State: s.ID, Markup: markup})
+ }
+ }
+ return out, nil
+}
diff --git a/example/example_test.go b/example/example_test.go
new file mode 100644
index 0000000..70f19d9
--- /dev/null
+++ b/example/example_test.go
@@ -0,0 +1,454 @@
+package example_test
+
+import (
+ "io/fs"
+ "path"
+ "slices"
+ "sort"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "golang.org/x/net/html"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/example"
+)
+
+// ── The package on a synthetic tree ──────────────────────────────────
+
+// synthBody is a body with a nested container and a <template>, the
+// two shapes a tokenizer that reads structure rather than bytes gets
+// wrong: a <section rst-box> is not a <body>, and a <template>'s
+// content is inert in the DOM but is bytes on disk all the same.
+const synthBody = `<section rst-box><form rst-form method="post" action="#">
+<template id="row"><div rst-field><label rst-field-label for="t">Title</label><input rst-input id="t" name="t"></div></template>
+<div rst-field><label rst-field-label for="f_bare">Title</label><input rst-input id="f_bare" name="f_bare"></div>
+</form></section>`
+
+func synthDoc(body string) string {
+ return "<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n<head>\n<meta charset=\"utf-8\">\n" +
+ "<meta name=\"viewport\" content=\"width=device-width\">\n<title>field</title>\n" +
+ "<link rel=\"stylesheet\" href=\"../../css/tokens.css\">\n</head>\n<body>\n" + body + "\n</body>\n</html>\n"
+}
+
+func synthFS() fstest.MapFS {
+ return fstest.MapFS{
+ "examples/examples.json": {Data: []byte(`{"examples":[{"name":"field","title":"field","family":"form","kind":"component","document":false,"wrapper":"form","scripts":[],"default":"bare","states":[{"id":"bare","label":"Bare","sampleURLs":[]}]}],"idioms":{}}`)},
+ "examples/field/bare.html": {Data: []byte(synthDoc(synthBody))},
+ }
+}
+
+func TestMarkupReturnsTheBodyAsWritten(t *testing.T) {
+ got, err := example.Markup(synthFS(), "field", "bare")
+ if err != nil {
+ t.Fatalf("Markup: %v", err)
+ }
+ if got != synthBody {
+ t.Fatalf("Markup returned\n%s\nwant\n%s", got, synthBody)
+ }
+}
+
+func TestMarkupRefusesAMissingState(t *testing.T) {
+ if _, err := example.Markup(synthFS(), "field", "with-a-hint"); err == nil {
+ t.Fatal("Markup of a state with no file returned no error")
+ }
+ if _, err := example.Markup(synthFS(), "nothing", "bare"); err == nil {
+ t.Fatal("Markup of an example with no directory returned no error")
+ }
+}
+
+func TestSlotReplacesOnlyTheSlot(t *testing.T) {
+ doc := "<main>\n<!-- fiesta:slot -->\n<h1>Posts</h1>\n<!-- /fiesta:slot -->\n</main>"
+ got, err := example.Slot(doc, "<p>Hello</p>")
+ if err != nil {
+ t.Fatalf("Slot: %v", err)
+ }
+ want := "<main>\n<!-- fiesta:slot --><p>Hello</p><!-- /fiesta:slot -->\n</main>"
+ if got != want {
+ t.Fatalf("Slot returned\n%s\nwant\n%s", got, want)
+ }
+ if _, err := example.Slot("<main><h1>Posts</h1></main>", "<p>Hello</p>"); err == nil {
+ t.Fatal("Slot on a document with no slot returned no error")
+ }
+}
+
+func TestSlotRefusesTwoSlots(t *testing.T) {
+ doc := "<!-- fiesta:slot -->a<!-- /fiesta:slot --><!-- fiesta:slot -->b<!-- /fiesta:slot -->"
+ if _, err := example.Slot(doc, "c"); err == nil {
+ t.Fatal("Slot on a document with two slots returned no error")
+ }
+}
+
+// ── The real examples ────────────────────────────────────────────────
+
+// tree is the embedded design system, which carries examples/ from
+// this task on.
+func tree(t *testing.T) fs.FS {
+ t.Helper()
+ fsys := fiesta.FS()
+ if _, err := fs.Stat(fsys, "examples/examples.json"); err != nil {
+ t.Fatalf("the embedded tree has no examples/examples.json: %v", err)
+ }
+ return fsys
+}
+
+func manifest(t *testing.T) (fs.FS, *example.Manifest) {
+ t.Helper()
+ fsys := tree(t)
+ m, err := example.Load(fsys)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(m.Examples) == 0 {
+ t.Fatal("the manifest lists no examples")
+ }
+ return fsys, m
+}
+
+func TestDefaultIsAState(t *testing.T) {
+ _, m := manifest(t)
+ for _, e := range m.Examples {
+ id, ok := m.Default(e.Name)
+ if !ok {
+ t.Errorf("%s: Default reports no default", e.Name)
+ continue
+ }
+ if id != e.Default {
+ t.Errorf("%s: Default returned %q, the entry says %q", e.Name, id, e.Default)
+ }
+ if !slices.ContainsFunc(e.States, func(s example.State) bool { return s.ID == id }) {
+ t.Errorf("%s: the default state %q is not one of its states", e.Name, id)
+ }
+ }
+ if _, ok := m.Default("no-such-example"); ok {
+ t.Error("Default of an unknown example reports ok")
+ }
+}
+
+var (
+ kinds = []string{"component", "idiom", "layout", "format", "screen"}
+ wrappers = []string{"", "list", "form", "box", "stats"}
+)
+
+func TestTheManifestMatchesTheFiles(t *testing.T) {
+ fsys, m := manifest(t)
+ scripts := fiesta.ScriptNames()
+ seen := map[string]bool{}
+ for _, e := range m.Examples {
+ if seen[e.Name] {
+ t.Errorf("%s is listed twice", e.Name)
+ }
+ seen[e.Name] = true
+ if !slices.Contains(kinds, e.Kind) {
+ t.Errorf("%s: kind %q is not one of %v", e.Name, e.Kind, kinds)
+ }
+ if !slices.Contains(wrappers, e.Wrapper) {
+ t.Errorf("%s: wrapper %q is not one of %v", e.Name, e.Wrapper, wrappers)
+ }
+ for _, s := range e.Scripts {
+ if !slices.Contains(scripts, s) {
+ t.Errorf("%s: script %q is not one fiesta ships (%v)", e.Name, s, scripts)
+ }
+ }
+ entries, err := fs.ReadDir(fsys, path.Join("examples", e.Name))
+ if err != nil {
+ t.Errorf("%s: no directory: %v", e.Name, err)
+ continue
+ }
+ files := map[string]bool{}
+ for _, f := range entries {
+ files[f.Name()] = true
+ }
+ ids := map[string]bool{}
+ if len(e.States) == 0 {
+ t.Errorf("%s: no states", e.Name)
+ }
+ for _, s := range e.States {
+ if ids[s.ID] {
+ t.Errorf("%s: state id %q is used twice", e.Name, s.ID)
+ }
+ ids[s.ID] = true
+ if !files[s.ID+".html"] {
+ t.Errorf("%s: state %q has no file", e.Name, s.ID)
+ }
+ delete(files, s.ID+".html")
+ }
+ for f := range files {
+ t.Errorf("%s: %s is not a state in the manifest", e.Name, f)
+ }
+ if _, ok := m.Entry(e.Name); !ok {
+ t.Errorf("%s: Entry reports no entry", e.Name)
+ }
+ }
+ entries, err := fs.ReadDir(fsys, "examples")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range entries {
+ if d.IsDir() && d.Name() != "assets" && !seen[d.Name()] {
+ t.Errorf("examples/%s is a directory no entry names", d.Name())
+ }
+ }
+}
+
+// attr returns one attribute of a start tag, and whether it is there.
+func attr(tok html.Token, name string) (string, bool) {
+ for _, a := range tok.Attr {
+ if a.Key == name {
+ return a.Val, true
+ }
+ }
+ return "", false
+}
+
+// walk tokenizes one document and hands each start or self-closing
+// tag to fn with whether it sits inside <head>.
+func walk(t *testing.T, doc string, fn func(tok html.Token, inHead bool)) {
+ t.Helper()
+ z := html.NewTokenizer(strings.NewReader(doc))
+ inHead := false
+ for {
+ switch z.Next() {
+ case html.ErrorToken:
+ return
+ case html.StartTagToken, html.SelfClosingTagToken:
+ tok := z.Token()
+ if tok.Data == "head" {
+ inHead = true
+ }
+ fn(tok, inHead)
+ case html.EndTagToken:
+ if z.Token().Data == "head" {
+ inHead = false
+ }
+ }
+ }
+}
+
+func everyDocument(t *testing.T, fn func(e example.Entry, s example.State, doc string)) {
+ t.Helper()
+ fsys, m := manifest(t)
+ for _, e := range m.Examples {
+ for _, s := range e.States {
+ doc, err := example.Document(fsys, e.Name, s.ID)
+ if err != nil {
+ t.Errorf("%s/%s: %v", e.Name, s.ID, err)
+ continue
+ }
+ fn(e, s, doc)
+ }
+ }
+}
+
+func TestExamplesLinkOnlyFiestasOwnFiles(t *testing.T) {
+ fsys := tree(t)
+ everyDocument(t, func(e example.Entry, s example.State, doc string) {
+ where := e.Name + "/" + s.ID
+ walk(t, doc, func(tok html.Token, inHead bool) {
+ for _, name := range []string{"href", "src"} {
+ v, ok := attr(tok, name)
+ if !ok || !inHead {
+ continue
+ }
+ if !strings.HasPrefix(v, "../../css/") && !strings.HasPrefix(v, "../../js/") {
+ t.Errorf("%s: the head's %s=%q is not under ../../css or ../../js", where, name, v)
+ continue
+ }
+ if _, err := fs.Stat(fsys, strings.TrimPrefix(v, "../../")); err != nil {
+ t.Errorf("%s: the head links %s, which the tree does not have", where, v)
+ }
+ }
+ if inHead {
+ return
+ }
+ for _, name := range []string{"src", "srcset", "poster"} {
+ v, ok := attr(tok, name)
+ if !ok {
+ continue
+ }
+ for _, cand := range strings.Split(v, ",") {
+ file := strings.Fields(cand)[0]
+ if !strings.HasPrefix(file, "../assets/") {
+ t.Errorf("%s: the body's %s=%q is not under ../assets/", where, name, v)
+ continue
+ }
+ if _, err := fs.Stat(fsys, path.Join("examples", strings.TrimPrefix(file, "../"))); err != nil {
+ t.Errorf("%s: the body names %s, which examples/assets does not have", where, file)
+ }
+ }
+ }
+ })
+ })
+}
+
+func TestEveryIdiomIsMapped(t *testing.T) {
+ _, m := manifest(t)
+ if len(m.Idioms) == 0 {
+ t.Fatal("the manifest maps no idioms")
+ }
+ for key, ref := range m.Idioms {
+ name, state, ok := m.Idiom(key)
+ if !ok || name != ref.Example || state != ref.State {
+ t.Errorf("Idiom(%q) = %q, %q, %v; the manifest says %q, %q", key, name, state, ok, ref.Example, ref.State)
+ }
+ e, ok := m.Entry(ref.Example)
+ if !ok {
+ t.Errorf("idiom %q names example %q, which does not exist", key, ref.Example)
+ continue
+ }
+ if !slices.ContainsFunc(e.States, func(s example.State) bool { return s.ID == ref.State }) {
+ t.Errorf("idiom %q names state %q of %q, which does not exist", key, ref.State, ref.Example)
+ }
+ }
+ if name, state, ok := m.Idiom("dropdown"); !ok || name != "dropdown" || state != "menu-idiom" {
+ t.Errorf("Idiom(dropdown) = %q, %q, %v; want dropdown, menu-idiom, true", name, state, ok)
+ }
+ if _, _, ok := m.Idiom("no-such-idiom"); ok {
+ t.Error("Idiom of an unknown key reports ok")
+ }
+}
+
+// applicationURL says whether a link or action is one of the routes a
+// sample stands for: not a fragment, not one of Fiesta's own files and
+// not one of the example pictures.
+func applicationURL(v string) bool {
+ return v != "" && !strings.HasPrefix(v, "#") && !strings.HasPrefix(v, "../../") && !strings.HasPrefix(v, "../assets/")
+}
+
+func TestSampleURLsAreDeclared(t *testing.T) {
+ everyDocument(t, func(e example.Entry, s example.State, doc string) {
+ where := e.Name + "/" + s.ID
+ found := map[string]bool{}
+ walk(t, doc, func(tok html.Token, inHead bool) {
+ if inHead {
+ return
+ }
+ if v, ok := attr(tok, "href"); ok && applicationURL(v) {
+ found[v] = true
+ }
+ if v, ok := attr(tok, "action"); ok && tok.Data == "form" && applicationURL(v) {
+ found[v] = true
+ }
+ })
+ var want []string
+ for v := range found {
+ want = append(want, v)
+ }
+ sort.Strings(want)
+ got := s.SampleURLs
+ if got == nil {
+ got = []string{}
+ }
+ if !sort.StringsAreSorted(got) {
+ t.Errorf("%s: sampleURLs are not sorted: %v", where, got)
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("%s: sampleURLs are %v; the markup links %v", where, got, want)
+ }
+ })
+}
+
+func TestEveryStateIsValidOnItsOwn(t *testing.T) {
+ everyDocument(t, func(e example.Entry, s example.State, doc string) {
+ where := e.Name + "/" + s.ID
+ ids := map[string]bool{}
+ type ref struct{ attr, id string }
+ var refs []ref
+ walk(t, doc, func(tok html.Token, _ bool) {
+ if id, ok := attr(tok, "id"); ok {
+ if ids[id] {
+ t.Errorf("%s: id %q is used twice", where, id)
+ }
+ ids[id] = true
+ }
+ if v, ok := attr(tok, "for"); ok {
+ refs = append(refs, ref{"for", v})
+ }
+ for _, name := range []string{"aria-labelledby", "aria-describedby", "aria-controls"} {
+ if v, ok := attr(tok, name); ok {
+ for _, id := range strings.Fields(v) {
+ refs = append(refs, ref{name, id})
+ }
+ }
+ }
+ })
+ for _, r := range refs {
+ if !ids[r.id] {
+ t.Errorf("%s: %s=%q names no id in the document", where, r.attr, r.id)
+ }
+ }
+ })
+}
+
+func TestTheStandaloneSchemeStateOwnsItsStorage(t *testing.T) {
+ fsys, m := manifest(t)
+ def, ok := m.Default("scheme-menu")
+ if !ok {
+ t.Fatal("no scheme-menu example")
+ }
+ if def != "standalone" {
+ t.Errorf("scheme-menu's default is %q, want standalone", def)
+ }
+ htmlAttr := func(doc string) (string, bool) {
+ var v string
+ var found bool
+ walk(t, doc, func(tok html.Token, _ bool) {
+ if tok.Data == "html" {
+ v, found = attr(tok, "data-rst-scheme-store")
+ }
+ })
+ return v, found
+ }
+ doc, err := example.Document(fsys, "scheme-menu", "standalone")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v, ok := htmlAttr(doc); !ok || v != "local" {
+ t.Errorf("scheme-menu/standalone: <html> carries data-rst-scheme-store=%q, %v; want \"local\"", v, ok)
+ }
+ if strings.Contains(doc, " action=") {
+ t.Error("scheme-menu/standalone: a form still names an action")
+ }
+ everyDocument(t, func(e example.Entry, s example.State, doc string) {
+ if e.Name == "scheme-menu" && s.ID == "standalone" {
+ return
+ }
+ if _, ok := htmlAttr(doc); ok {
+ t.Errorf("%s/%s: <html> carries data-rst-scheme-store; only scheme-menu/standalone does", e.Name, s.ID)
+ }
+ })
+}
+
+func TestAllWalksTheManifestInOrder(t *testing.T) {
+ fsys, m := manifest(t)
+ pieces, err := example.All(fsys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var want []string
+ for _, e := range m.Examples {
+ if e.Document {
+ continue
+ }
+ for _, s := range e.States {
+ want = append(want, e.Name+"/"+s.ID)
+ }
+ }
+ var got []string
+ for _, p := range pieces {
+ got = append(got, p.Name+"/"+p.State)
+ markup, err := example.Markup(fsys, p.Name, p.State)
+ if err != nil {
+ t.Errorf("%s/%s: %v", p.Name, p.State, err)
+ } else if markup != p.Markup {
+ t.Errorf("%s/%s: All's markup differs from Markup's", p.Name, p.State)
+ }
+ if strings.Contains(p.Markup, "<body") || strings.Contains(p.Markup, "</body>") {
+ t.Errorf("%s/%s: the markup holds a body tag", p.Name, p.State)
+ }
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("All returned %d pieces in this order:\n%v\nwant %d:\n%v", len(got), got, len(want), want)
+ }
+}
diff --git a/go.mod b/go.mod
index 93105cf..c73d930 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
+ golang.org/x/net v0.58.0
)
require (
@@ -13,5 +14,5 @@ require (
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
- golang.org/x/sys v0.34.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
)
diff --git a/go.sum b/go.sum
index a62ad28..cf0290c 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/hack/seed/_rastrillo/designsystem_fiestaseed_test.go b/hack/seed/_rastrillo/designsystem_fiestaseed_test.go
new file mode 100644
index 0000000..3359f5f
--- /dev/null
+++ b/hack/seed/_rastrillo/designsystem_fiestaseed_test.go
@@ -0,0 +1,707 @@
+//go:build fiestaseed
+
+// The Fiesta seed exporter for internal/designsystem. hack/seed/run.sh
+// copies this file into a scratch rastrillo worktree at SEED and runs
+// TestFiestaSeed there, so it has the gallery's own helpers in reach:
+// the sample tables, the partial tree, the shell demos, the formats
+// and the screens. It writes, under FIESTA_SEED_OUT:
+//
+// - examples/<name>/<state>.html, one document per state, with the
+// HTML-tab markup (never the deadened preview) as its body;
+// - examples/examples.json, the manifest, and examples/assets/;
+// - i18n/<lang>.json for the twelve shipped languages;
+// - check/testdata/datetime/*.json and retagged.json;
+// - check/testdata/legacy/*-v028.css;
+// - hack/seed/sources.json, which says where each state came from.
+//
+// With FIESTA_SEED_DRYRUN set it writes nothing and prints every state
+// label that is not already an English string in this package, which
+// is what copy review needs before the export is run for real.
+package designsystem
+
+import (
+ "encoding/json"
+ "fmt"
+ "html"
+ "html/template"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/ui"
+)
+
+// The manifest's shape, mirrored from Fiesta's example package. The
+// tags are the contract; this side cannot import the other.
+type seedManifest struct {
+ Examples []seedEntry `json:"examples"`
+ Idioms map[string]seedIdiom `json:"idioms"`
+}
+
+type seedEntry struct {
+ Name string `json:"name"`
+ Title string `json:"title"`
+ Family string `json:"family"`
+ Kind string `json:"kind"`
+ Document bool `json:"document"`
+ Wrapper string `json:"wrapper"`
+ Scripts []string `json:"scripts"`
+ Default string `json:"default"`
+ States []seedState `json:"states"`
+}
+
+type seedState struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ SampleURLs []string `json:"sampleURLs"`
+}
+
+type seedIdiom struct {
+ Example string `json:"example"`
+ State string `json:"state"`
+}
+
+// seedSource is one line of hack/seed/sources.json: where a state's
+// markup came from in this package.
+type seedSource struct {
+ Kind string `json:"kind"`
+ Source string `json:"source"`
+ State int `json:"state"`
+ Locale string `json:"locale"`
+}
+
+// defaultStates names the default state of an example whose default is
+// not the first state in rastrillo's order. Nothing joins this map
+// without copy review.
+var defaultStates = map[string]string{"scheme-menu": "standalone"}
+
+// standaloneLabel is the one hand-written state label. It goes
+// through copy review before the export is run for real; the dry run
+// lists it.
+const standaloneLabel = "Standalone: the page remembers the choice"
+
+// seedScripts is previewScripts with rastrillo.js read as fiesta.js,
+// in load order.
+func seedScripts(body string) []string {
+ var out []string
+ for _, s := range previewScripts {
+ for _, hook := range s.hooks {
+ if strings.Contains(body, hook) {
+ name := s.asset
+ if name == "rastrillo.js" {
+ name = "fiesta.js"
+ }
+ out = append(out, name)
+ break
+ }
+ }
+ }
+ return out
+}
+
+type seeder struct {
+ t *testing.T
+ out string
+ dry bool
+ entries []seedEntry
+ idioms map[string]seedIdiom
+ sources map[string]seedSource
+ review []string
+ known map[string]bool
+ written int
+}
+
+func TestFiestaSeed(t *testing.T) {
+ out := os.Getenv("FIESTA_SEED_OUT")
+ dry := os.Getenv("FIESTA_SEED_DRYRUN") != ""
+ if out == "" && !dry {
+ t.Skip("FIESTA_SEED_OUT is unset; this test is the Fiesta seed exporter, run by hack/seed/run.sh")
+ }
+ s := &seeder{t: t, out: out, dry: dry, idioms: map[string]seedIdiom{}, sources: map[string]seedSource{}, known: seedKnownStrings()}
+ if !dry {
+ s.clearExamples()
+ }
+ s.components()
+ s.idiomStates()
+ s.layouts()
+ s.formats()
+ s.screens()
+ s.manifest()
+ s.assets()
+ s.i18n()
+ s.datetime()
+ s.legacyCSS()
+ s.writeSources()
+ if dry {
+ fmt.Printf("fiesta seed dry run: %d examples, %d labels need copy review\n", len(s.entries), len(s.review))
+ for _, line := range s.review {
+ fmt.Println("review: " + line)
+ }
+ return
+ }
+ fmt.Printf("fiesta seed: wrote %d files under %s (%d examples)\n", s.written, out, len(s.entries))
+}
+
+// seedKnownStrings is every English string this package already
+// holds that could become a state label: the sample tables' State
+// labels, the screens' names and every prose key. A label outside
+// this set is new copy.
+func seedKnownStrings() map[string]bool {
+ known := map[string]bool{}
+ for _, fam := range families() {
+ for _, doc := range fam.Partials {
+ for _, st := range doc.States {
+ known[st.State] = true
+ }
+ }
+ }
+ for _, doc := range screenDocs() {
+ known[doc.Name] = true
+ }
+ for en := range prose {
+ known[en] = true
+ }
+ return known
+}
+
+func (s *seeder) write(rel string, body []byte) {
+ s.t.Helper()
+ if s.dry {
+ return
+ }
+ p := filepath.Join(s.out, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ s.t.Fatal(err)
+ }
+ if err := os.WriteFile(p, body, 0o644); err != nil {
+ s.t.Fatal(err)
+ }
+ s.written++
+}
+
+// clearExamples removes every example directory from a previous run,
+// so a state that was renamed does not leave its old file behind.
+// Files directly under examples/ (the README) stay.
+func (s *seeder) clearExamples() {
+ dir := filepath.Join(s.out, "examples")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
+ s.t.Fatal(err)
+ }
+ }
+ }
+}
+
+// stateID is the English label slugified, with -2, -3 on a collision;
+// an empty label gives default.
+func stateID(ids map[string]int, label string) string {
+ id := slug(label)
+ if id == "" {
+ id = "default"
+ }
+ ids[id]++
+ if n := ids[id]; n > 1 {
+ return fmt.Sprintf("%s-%d", id, n)
+ }
+ return id
+}
+
+var (
+ seedHref = regexp.MustCompile(`(?:^|\s)href="([^"]*)"`)
+ seedAction = regexp.MustCompile(`<form\b[^>]*?\saction="([^"]*)"`)
+ seedScript = regexp.MustCompile(`src="\.\./\.\./js/([^"]+)"`)
+)
+
+// sampleURLs lists, sorted, every href and form action that is neither
+// a fragment nor one of Fiesta's own files or pictures: the routes the
+// sample stands for.
+func sampleURLs(body string) []string {
+ seen := map[string]bool{}
+ add := func(v string) {
+ v = html.UnescapeString(v)
+ if v == "" || strings.HasPrefix(v, "#") || strings.HasPrefix(v, "../../") || strings.HasPrefix(v, "../assets/") {
+ return
+ }
+ seen[v] = true
+ }
+ for _, m := range seedHref.FindAllStringSubmatch(body, -1) {
+ add(m[1])
+ }
+ for _, m := range seedAction.FindAllStringSubmatch(body, -1) {
+ add(m[1])
+ }
+ out := make([]string, 0, len(seen))
+ for v := range seen {
+ out = append(out, v)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// pictures rewrites a sample's picture routes to the example assets,
+// relative to examples/<name>/<state>.html.
+func pictures(body string) string {
+ return sampleSrc.ReplaceAllStringFunc(body, func(m string) string {
+ v := m[len(`src="`) : len(m)-1]
+ if asset, ok := sampleImages[v]; ok {
+ return `src="../assets/` + asset + `"`
+ }
+ return m
+ })
+}
+
+// document is the example file format: the standard head, the scripts
+// the body needs, and the body exactly as rendered.
+func document(title, htmlAttr, body string, scripts []string) string {
+ var b strings.Builder
+ b.WriteString("<!doctype html>\n")
+ b.WriteString(`<html lang="en" dir="ltr"` + htmlAttr + ">\n")
+ b.WriteString("<head>\n<meta charset=\"utf-8\">\n")
+ b.WriteString(`<meta name="viewport" content="width=device-width">` + "\n")
+ b.WriteString("<title>" + template.HTMLEscapeString(title) + "</title>\n")
+ b.WriteString(`<link rel="stylesheet" href="../../css/tokens.css">` + "\n")
+ b.WriteString(`<link rel="stylesheet" href="../../css/themes/day.css">` + "\n")
+ for _, name := range scripts {
+ b.WriteString(`<script defer src="../../js/` + name + `"></script>` + "\n")
+ }
+ b.WriteString("</head>\n<body>\n")
+ b.WriteString(body)
+ b.WriteString("\n</body>\n</html>\n")
+ return b.String()
+}
+
+// addState writes one state's document and records it on its entry.
+// label is checked against the strings this package already holds.
+func (s *seeder) addState(e *seedEntry, id, label, title, htmlAttr, body string, scripts []string, src seedSource) {
+ s.t.Helper()
+ for _, st := range e.States {
+ if st.ID == id {
+ s.t.Fatalf("%s: state id %q twice", e.Name, id)
+ }
+ }
+ for _, sc := range scripts {
+ if !contains(e.Scripts, sc) {
+ e.Scripts = append(e.Scripts, sc)
+ }
+ }
+ if label != "" && !s.known[label] {
+ s.review = append(s.review, fmt.Sprintf("state.%s.%s: %s", e.Name, id, label))
+ }
+ e.States = append(e.States, seedState{ID: id, Label: label, SampleURLs: sampleURLs(body)})
+ s.sources[e.Name+"/"+id] = src
+ s.write("examples/"+e.Name+"/"+id+".html", []byte(document(title, htmlAttr, body, scripts)))
+}
+
+func contains(list []string, v string) bool {
+ for _, x := range list {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *seeder) finish(e seedEntry) {
+ if e.Scripts == nil {
+ e.Scripts = []string{}
+ }
+ if d, ok := defaultStates[e.Name]; ok {
+ e.Default = d
+ } else if len(e.States) > 0 {
+ e.Default = e.States[0].ID
+ }
+ found := false
+ for _, st := range e.States {
+ found = found || st.ID == e.Default
+ }
+ if !found {
+ s.t.Fatalf("%s: default %q is not a state", e.Name, e.Default)
+ }
+ s.entries = append(s.entries, e)
+}
+
+func wrapperName(w wrapper) string {
+ switch w {
+ case wrapList:
+ return "list"
+ case wrapForm:
+ return "form"
+ case wrapBox:
+ return "box"
+ case wrapStats:
+ return "stats"
+ }
+ return ""
+}
+
+// components is the 42 partial docs in families(), every state of
+// each, rendered as the HTML tab renders it and wrapped in the
+// container its partial assumes.
+func (s *seeder) components() {
+ tmpl, err := partialTree("en")
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ if err := parseRawSamples(tmpl); err != nil {
+ s.t.Fatal(err)
+ }
+ for _, fam := range families() {
+ for _, doc := range fam.Partials {
+ e := seedEntry{Name: doc.Name, Title: doc.Name, Family: fam.Key, Kind: "component", Wrapper: wrapperName(doc.Wrap)}
+ ids := map[string]int{}
+ title := previewTitle("en", doc.Name, fam.Title)
+ for i, st := range doc.States {
+ h, err := renderSample(tmpl, doc.Name, i, st, "en")
+ if err != nil {
+ s.t.Fatalf("%s state %d: %v", doc.Name, i, err)
+ }
+ body := pictures(wrap(doc.Wrap, string(h)))
+ id := stateID(ids, st.State)
+ s.addState(&e, id, st.State, title, "", body, seedScripts(body), seedSource{Kind: "partial", Source: doc.Name, State: i, Locale: "en"})
+ if doc.Name == "scheme-menu" {
+ // The hand-written standalone state: the same markup
+ // with the forms' action removed, on a page that
+ // remembers the choice itself.
+ standalone := strings.ReplaceAll(string(h), ` action="/_theme"`, "")
+ if standalone == string(h) {
+ s.t.Fatal("scheme-menu: no action to remove")
+ }
+ s.addState(&e, "standalone", standaloneLabel, title, ` data-rst-scheme-store="local"`, standalone, seedScripts(standalone), seedSource{Kind: "partial", Source: doc.Name, State: i, Locale: "en"})
+ }
+ }
+ s.finish(e)
+ }
+ }
+}
+
+// idiomStates is ui.Styleguide(): one example per idiom with one state,
+// default, except dropdown, which becomes state menu-idiom of the
+// dropdown component.
+func (s *seeder) idiomStates() {
+ samples := ui.Styleguide()
+ names := make([]string, 0, len(samples))
+ for name := range samples {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ body := localMarkup("en", samples[name])
+ src := seedSource{Kind: "idiom", Source: name, State: 0, Locale: "en"}
+ title := previewTitle("en", name, "UI primitives")
+ if name == "dropdown" {
+ for i := range s.entries {
+ if s.entries[i].Name != "dropdown" {
+ continue
+ }
+ e := &s.entries[i]
+ s.addState(e, "menu-idiom", "", title, "", body, seedScripts(body), src)
+ s.idioms[name] = seedIdiom{Example: "dropdown", State: "menu-idiom"}
+ }
+ if _, ok := s.idioms[name]; !ok {
+ s.t.Fatal("no dropdown component to carry the idiom")
+ }
+ continue
+ }
+ e := seedEntry{Name: name, Title: name, Family: "primitives", Kind: "idiom"}
+ s.addState(&e, "default", "", title, "", body, seedScripts(body), src)
+ s.idioms[name] = seedIdiom{Example: name, State: "default"}
+ s.finish(e)
+ }
+}
+
+// fiestaAsset maps a layout's asset calls to Fiesta's files, relative
+// to examples/<name>/<state>.html.
+func fiestaAsset(t *testing.T) func(string) string {
+ return func(p string) string {
+ switch p {
+ case "static/tokens.css":
+ return "../../css/tokens.css"
+ case "static/theme.css":
+ return "../../css/themes/day.css"
+ case "static/shell.css":
+ return "../../css/shell.css"
+ case "static/rastrillo.js":
+ return "../../js/fiesta.js"
+ }
+ if strings.HasPrefix(p, "static/") && strings.HasSuffix(p, ".js") {
+ return "../../js/" + strings.TrimPrefix(p, "static/")
+ }
+ t.Fatalf("a layout asks for asset %q, which Fiesta does not ship", p)
+ return ""
+ }
+}
+
+// seedShellCommon is shellCommon for Fiesta: no gallery.js in head
+// (the layout's own empty head block stands), and the content block
+// wrapped in the slot markers. The markers are placeholders here
+// because html/template drops comments from template text; they
+// become the comments once the document is rendered.
+const seedShellCommon = `
+{{define "lang"}}{{.Locale}}{{end}}
+{{define "dir"}}{{.Dir}}{{end}}
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}@@FIESTA-SLOT@@{{template "fiesta-content" .}}@@/FIESTA-SLOT@@{{end}}
+`
+
+// seedLocales is the shell demos' language menu with site-neutral
+// links, the way localeMenuData writes them.
+func seedLocales() []localeLink {
+ catalogs := rastrillo.BaseCatalogs()
+ var out []localeLink
+ for _, code := range rastrillo.BaseLocales() {
+ out = append(out, localeLink{
+ Code: code, Name: catalogs[code]["rastrillo.ui.locale_name"], Dir: rastrillo.Dir(code),
+ Href: "/" + code + "/", Current: code == "en",
+ })
+ }
+ return out
+}
+
+// layouts is renderShell for Fiesta: ui.Layout's own template, the
+// demo's chrome blocks, and the content region between the slot
+// markers. The index/page pairs of the shells that have one are two
+// examples each.
+func (s *seeder) layouts() {
+ for _, shell := range ui.LayoutNames() {
+ src, ok := ui.Layout(shell)
+ if !ok {
+ s.t.Fatalf("no shell %q", shell)
+ }
+ funcs := galleryFuncs("en")
+ funcs["asset"] = fiestaAsset(s.t)
+ tmpl, err := template.New("designsystem").Funcs(funcs).ParseFS(ui.Templates(), "*.html")
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ if _, err := tmpl.Parse(string(src)); err != nil {
+ s.t.Fatalf("parsing the %s shell: %v", shell, err)
+ }
+ overrides := shellTemplate
+ if shell == "stage" {
+ overrides = stageShellTemplate
+ }
+ overrides = strings.Replace(overrides, `{{define "content"}}`, `{{define "fiesta-content"}}`, 1)
+ if _, err := tmpl.Parse(seedShellCommon + overrides); err != nil {
+ s.t.Fatalf("parsing the shell overrides: %v", err)
+ }
+ data := shellData{
+ Locale: "en",
+ Dir: "ltr",
+ Name: shell,
+ Mount: "",
+ Index: "/",
+ Locales: seedLocales(),
+ Account: template.HTML(localMarkup("en", string(accountMarkup))),
+ Profile: profileMarkup,
+ Signin: auth.SigninState{Step: auth.StepAsk, BeginPath: "/signin", ForgetPath: "/signin/forget"},
+ Brand: localData("en", galleryBrand).(map[string]any),
+ PageHref: "/posts",
+ Up: "/#nav-posts",
+ }
+ type doc struct {
+ name string
+ data shellData
+ }
+ docs := []doc{{"layout-" + shell, data}}
+ if shellHasIndex(shell) {
+ index, page := data, data
+ index.View, index.Up = "index", "/"
+ page.View = "page"
+ docs = []doc{{"layout-" + shell, index}, {"layout-" + shell + "-page", page}}
+ }
+ for _, d := range docs {
+ d.data.Title = previewTitle("en", d.name, "Shells")
+ run, err := tmpl.Clone()
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ var buf strings.Builder
+ if err := run.ExecuteTemplate(&buf, "layout", d.data); err != nil {
+ s.t.Fatalf("%s: %v", d.name, err)
+ }
+ page := buf.String()
+ if strings.Count(page, "@@FIESTA-SLOT@@") != 1 || strings.Count(page, "@@/FIESTA-SLOT@@") != 1 {
+ s.t.Fatalf("%s: the content slot did not render once", d.name)
+ }
+ page = strings.Replace(page, "@@FIESTA-SLOT@@", "<!-- fiesta:slot -->", 1)
+ page = strings.Replace(page, "@@/FIESTA-SLOT@@", "<!-- /fiesta:slot -->", 1)
+ head := page[:strings.Index(page, "<body")]
+ var scripts []string
+ for _, m := range seedScript.FindAllStringSubmatch(head, -1) {
+ scripts = append(scripts, m[1])
+ }
+ body := page[strings.Index(page, "<body"):]
+ e := seedEntry{Name: d.name, Title: proseIn("en", "The {shell} shell", "shell", shell), Family: "shells", Kind: "layout", Document: true, Scripts: scripts}
+ e.States = append(e.States, seedState{ID: "default", Label: "", SampleURLs: sampleURLs(body)})
+ s.sources[d.name+"/default"] = seedSource{Kind: "shell", Source: shell, State: 0, Locale: "en"}
+ s.write("examples/"+d.name+"/default.html", []byte(page))
+ s.finish(e)
+ }
+ }
+}
+
+// formats is formatDocs(): one example per section, one state each.
+func (s *seeder) formats() {
+ for i, doc := range formatDocs() {
+ body := formatMarkup(doc, "en")
+ e := seedEntry{Name: "format-" + doc.Key, Title: doc.Title, Family: "formats", Kind: "format"}
+ s.addState(&e, "default", "", previewTitle("en", doc.Key, "Dates, numbers and names"), "", body, seedScripts(body), seedSource{Kind: "format", Source: doc.Key, State: i, Locale: "en"})
+ s.finish(e)
+ }
+}
+
+// screens is screenDocs(): the states of the signin example. A screen
+// with a Signin state is the partial rendered with the data
+// ds-screen-stage hands it; a hand-written one is its markup.
+func (s *seeder) screens() {
+ tmpl, err := partialTree("en")
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ e := seedEntry{Name: "signin", Title: "signin", Family: "screens", Kind: "screen"}
+ ids := map[string]int{}
+ for i, doc := range screenDocs() {
+ var body string
+ if doc.Signin == nil {
+ body = localMarkup("en", doc.Markup)
+ } else {
+ var buf strings.Builder
+ err := tmpl.ExecuteTemplate(&buf, "signin", map[string]any{
+ "State": *doc.Signin, "Brand": localData("en", galleryBrand), "Preview": true,
+ })
+ if err != nil {
+ s.t.Fatalf("screen %s: %v", doc.Key, err)
+ }
+ body = buf.String()
+ }
+ id := stateID(ids, doc.Name)
+ s.addState(&e, id, doc.Name, previewTitle("en", doc.Key, "Screens"), "", body, seedScripts(body), seedSource{Kind: "screen", Source: doc.Key, State: i, Locale: "en"})
+ }
+ s.finish(e)
+}
+
+func (s *seeder) manifest() {
+ for i := range s.entries {
+ for j := range s.entries[i].States {
+ if s.entries[i].States[j].SampleURLs == nil {
+ s.entries[i].States[j].SampleURLs = []string{}
+ }
+ }
+ }
+ b, err := json.MarshalIndent(seedManifest{Examples: s.entries, Idioms: s.idioms}, "", " ")
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ s.write("examples/examples.json", append(b, '\n'))
+}
+
+func (s *seeder) assets() {
+ for _, asset := range sampleImages {
+ b, err := samplesFS.ReadFile("samples/" + asset)
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ s.write("examples/assets/"+asset, b)
+ }
+}
+
+// i18nKeys are the twenty string keys of i18n/<lang>.json, each an
+// attribute's name with data-rst- removed; the catalog key is the
+// same with _ for -.
+var i18nKeys = []string{
+ "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar",
+ "date-prev-month", "date-next-month", "date-results", "date-result-one",
+ "date-quick-today", "date-quick-tomorrow", "date-quick-next-week",
+ "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day",
+ "select-filter", "select-results", "select-result-one", "select-no-matches",
+}
+
+// i18nWords are the seventeen names under date-words.
+var i18nWords = []string{
+ "today", "tomorrow", "yesterday", "next", "last", "in", "ago", "at",
+ "day", "week", "month", "hour", "minute", "noon", "midnight", "am", "pm",
+}
+
+// i18n writes one flat object per shipped language from the base
+// catalogs. A key a language has no translation for falls back to the
+// English value, and the fallback is printed so it can be reported.
+func (s *seeder) i18n() {
+ catalogs := rastrillo.BaseCatalogs()
+ en := catalogs["en"]
+ for _, code := range rastrillo.BaseLocales() {
+ own := catalogs[code]
+ value := func(key string) string {
+ if v := own[key]; v != "" {
+ return v
+ }
+ fmt.Printf("i18n: %s has no %s; the English value stands in\n", code, key)
+ if v := en[key]; v != "" {
+ return v
+ }
+ s.t.Fatalf("i18n: en has no %s", key)
+ return ""
+ }
+ obj := map[string]any{}
+ words := map[string]string{}
+ for _, w := range i18nWords {
+ words[w] = value("rastrillo.ui.date_" + w)
+ }
+ obj["date-words"] = words
+ for _, key := range i18nKeys {
+ obj[key] = value("rastrillo.ui." + strings.ReplaceAll(key, "-", "_"))
+ }
+ b, err := json.MarshalIndent(obj, "", " ")
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ s.write("i18n/"+code+".json", append(b, '\n'))
+ }
+}
+
+func (s *seeder) copyDir(from, to, suffix string) {
+ entries, err := os.ReadDir(from)
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), suffix) {
+ continue
+ }
+ b, err := os.ReadFile(filepath.Join(from, e.Name()))
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ s.write(to+"/"+e.Name(), b)
+ }
+}
+
+func (s *seeder) datetime() {
+ s.copyDir("../../ui/testdata/datetime", "check/testdata/datetime", ".json")
+ b, err := json.Marshal(rastrillo.Retagged())
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ s.write("check/testdata/datetime/retagged.json", append(b, '\n'))
+}
+
+func (s *seeder) legacyCSS() {
+ s.copyDir("../../ui/testdata/legacy", "check/testdata/legacy", "-v028.css")
+}
+
+func (s *seeder) writeSources() {
+ b, err := json.MarshalIndent(s.sources, "", " ")
+ if err != nil {
+ s.t.Fatal(err)
+ }
+ s.write("hack/seed/sources.json", append(b, '\n'))
+}
diff --git a/hack/seed/_rastrillo/ui_fiestaseed_test.go b/hack/seed/_rastrillo/ui_fiestaseed_test.go
new file mode 100644
index 0000000..4b45da4
--- /dev/null
+++ b/hack/seed/_rastrillo/ui_fiestaseed_test.go
@@ -0,0 +1,370 @@
+//go:build fiestaseed && browser
+
+// The Fiesta seed exporter for ui. hack/seed/run.sh copies this file
+// into a scratch rastrillo worktree at SEED and runs TestFiestaSeed
+// there, beside the browser drives Fiesta's Tasks 9, 10 and 11 port,
+// so it can call their page builders. It writes, under FIESTA_SEED_OUT:
+//
+// - check/testdata/fixtures/sizing.html, the whole sizing fixture;
+// - check/testdata/fixtures/spelling-class.html, the whole class
+// fixture in class spelling;
+// - check/testdata/fixtures/<file>/<builder>[-<case>].html, every
+// case of every page builder in the ported files, listed in
+// check/testdata/fixtures/index.json with the builders the
+// exporter cannot run marked unseeded and why;
+// - check/testdata/legacy/{sidebar,console}.html, the pre-phone-index
+// layouts rendered the way their drives render them.
+//
+// Three kinds of fixture. A "document" is what a builder that returns
+// an http.Handler served for one request, with /rastrillo.js read as
+// /fiesta.js. A "body" is what a builder handed sizingDoc. A "piece"
+// is one rendered partial a builder glued into its own literal markup;
+// the port keeps the literal and reads the piece. Builders in Task 11's
+// files that render a layout are not seeded: Task 11 builds those pages
+// from the layout-* examples through example.Slot.
+//
+// With FIESTA_SEED_DRYRUN set it writes nothing. The browser tag is
+// needed because the builders live in browser-tagged files; nothing
+// here starts a browser.
+package ui
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+type fixtureEntry struct {
+ File string `json:"file"`
+ Builder string `json:"builder"`
+ Case string `json:"case"`
+ Path string `json:"path"`
+ Kind string `json:"kind,omitempty"`
+ Request string `json:"request,omitempty"`
+ Example string `json:"example,omitempty"`
+ Unseeded string `json:"unseeded,omitempty"`
+ Note string `json:"note,omitempty"`
+}
+
+type fixtureWriter struct {
+ t *testing.T
+ out string
+ dry bool
+ index []fixtureEntry
+ written int
+}
+
+const layoutReason = "renders a layout; Task 11 builds the page from the layout-<shell> example through example.Slot"
+
+func TestFiestaSeed(t *testing.T) {
+ out := os.Getenv("FIESTA_SEED_OUT")
+ dry := os.Getenv("FIESTA_SEED_DRYRUN") != ""
+ if out == "" && !dry {
+ t.Skip("FIESTA_SEED_OUT is unset; this test is the Fiesta seed exporter, run by hack/seed/run.sh")
+ }
+ w := &fixtureWriter{t: t, out: out, dry: dry}
+ if !dry {
+ w.clearFixtures()
+ }
+
+ // The two whole fixtures.
+ w.whole("sizing_fixture_test.go", "sizingFixture", "sizing.html", sizingFixture(t), "the whole fixture, every adversarial case included; the port wraps it in sizingDoc")
+ w.whole("markup_v3_browser_test.go", "classFixture", "spelling-class.html", classSpelling(t, classFixture(t)), "classSpelling(t, classFixture(t)): the whole fixture in class spelling; markup.Respell derives the attribute twin")
+
+ // The legacy layouts, rendered with shellLayoutPage's defaults.
+ for _, name := range []string{"sidebar", "console"} {
+ w.write("check/testdata/legacy/"+name+".html", []byte(fiestaScripts(shellLayoutPage(t, legacyLayout(t, name), "ltr"))))
+ w.add(fixtureEntry{File: "index_browser_test.go", Builder: "legacyLayout", Case: name, Path: "../legacy/" + name + ".html", Kind: "document",
+ Note: "shellLayoutPage(t, legacyLayout(t, " + name + "), \"ltr\") with no extra defines; a call with its own nav or content is not reproduced"})
+ }
+
+ // ── Task 9: browser_test.go's handlers, and scheme_browser_test.go ──
+ h, _ := page(t, 40)
+ w.document("browser_test.go", "page", "40", "/", h, "page(t, 40)")
+ h, _ = datePage(t, "field-date", "due")
+ w.document("browser_test.go", "datePage", "field-date", "/", h, `datePage(t, "field-date", "due")`)
+ w.document("browser_test.go", "datePage", "field-date-nocal", "/?nocal", h, `datePage(t, "field-date", "due"), without calendar.js`)
+ h, _ = datePage(t, "field-time", "at")
+ w.document("browser_test.go", "datePage", "field-time", "/", h, `datePage(t, "field-time", "at")`)
+ h, _ = groupPage(t)
+ w.document("browser_test.go", "groupPage", "", "/", h, "")
+ w.document("browser_test.go", "menuPage", "", "/", menuPage(t), "")
+ w.document("browser_test.go", "menuPage", "old-spelling", "/old-spelling", menuPage(t), "the mid-upgrade page in class spelling")
+ w.document("browser_test.go", "modalPage", "", "/", modalPage(t), `Styleguide()["modal"] in a bare page; the same markup is example modal/default`)
+ w.document("browser_test.go", "fieldRowPage", "ltr", "/?dir=ltr", fieldRowPage(t), "")
+ w.document("browser_test.go", "fieldRowPage", "rtl", "/?dir=rtl", fieldRowPage(t), "")
+ h, _ = busyPage(t)
+ w.document("browser_test.go", "busyPage", "", "/", h, "")
+ w.document("browser_test.go", "bidiRowPage", "", "/", bidiRowPage(t), "")
+ w.unseeded("scheme_browser_test.go", "schemeApp", "serves a column layout through rastrillo.Handler for the server-backed scheme tests, which stay in rastrillo; Task 9 ports only the three schemeFlipBody tests, whose body is a literal")
+
+ // ── Task 10: the component drives ────────────────────────────────
+ w.piece("sizing_browser_test.go", "TestTheCalendarDocksAndItsDaysAreTaps", "field",
+ render(t, "field-date", map[string]any{"Name": "due", "Label": "Due", "Value": "2026-08-28"}), "the field both pages hold")
+ w.add(fixtureEntry{File: "sizing_browser_test.go", Builder: "TestDesktopDensityIsPinned", Case: "modal", Example: "modal/default", Note: `Styleguide()["modal"]`})
+ w.add(fixtureEntry{File: "sizing_browser_test.go", Builder: "TestEveryTapTargetIsAtLeast44Pixels", Case: "modal", Example: "modal/default", Note: `Styleguide()["modal"]`})
+ w.add(fixtureEntry{File: "sizing_browser_test.go", Builder: "TestTheFloorChangesNothingButSizeOnADesktop", Case: "", Path: "sizing.html", Kind: "body", Note: "sizingFixture(t) plus a literal tail the test keeps"})
+
+ w.body("rows_browser_test.go", "rowsFixture", "", rowsFixture(t), "")
+ w.body("rows_browser_test.go", "narrowRowsPage", "", bodyOf(t, narrowRowsPage(t)["/"]), "the / page's body")
+
+ w.piece("blocks_browser_test.go", "segTabsPage", "tabs", render(t, "seg-tabs", map[string]any{"Label": "Requests", "Items": []any{
+ map[string]any{"Label": "All", "Href": "/", "Current": true},
+ map[string]any{"Label": "Open", "Href": "/"},
+ map[string]any{"Label": "Resolved", "Href": "/"},
+ }}), "the tabs both ids wrap")
+ w.piece("blocks_browser_test.go", "TestADetailListsLabelSitsOnItsValuesFirstLine", "dl", render(t, "detail-list", map[string]any{"Items": []any{
+ map[string]any{"Label": "Audience", "Value": "Members"},
+ map[string]any{"Label": "Published", "Value": "2 August 2026", "DateTime": "2026-08-02"},
+ map[string]any{"Label": "Reference", "Value": "post_01H9ZQ", "Mono": true},
+ map[string]any{"Label": "Summary", "Value": "Release notes for August: the new export, faster search, a fix for invoices that never arrived, and the rest of the month's small changes."},
+ }}), "")
+
+ for _, c := range bulkBarCases {
+ data := map[string]any{
+ "DoneHref": "/posts", "DoneLabel": "Done selecting", "Count": c.Count,
+ "MenuLabel": c.Menu, "MenuGroup": "rst-bulk-" + c.ID,
+ "Actions": []any{map[string]any{"Value": "export", "Label": "Export"}},
+ }
+ if c.Escalate != "" {
+ data["EscalateHref"], data["EscalateLabel"] = "/posts?select=all", c.Escalate
+ }
+ w.piece("bulkbar_browser_test.go", "bulkBarPage", c.ID, render(t, "bulk-bar", data), "one bulkBarCases entry; the port wraps it in <div rst-list id=\"bulk-"+c.ID+"\">")
+ }
+
+ dropdown := func() string {
+ return render(t, "dropdown", map[string]any{"Label": "Newest", "Aria": "Sort: Newest", "Items": []any{
+ map[string]any{"Href": "/o?sort=new", "Label": "Newest", "Current": true},
+ map[string]any{"Href": "/o?sort=old", "Label": "Oldest"},
+ }})
+ }
+ w.piece("dropdown_browser_test.go", "TestADropdownOpensUnderItsOwnSummary", "partial", dropdown(), "the partial; the sample is example dropdown/menu-idiom")
+ w.piece("dropdown_browser_test.go", "TestADropdownWithoutAnchorsOrScriptsStaysOnScreen", "partial", dropdown(), "the partial; the sample is example dropdown/menu-idiom")
+ w.piece("dropdown_browser_test.go", "TestAnOpenDropdownIsPlacedAgainWhenTheWindowChanges", "menu", dropdown(), "")
+
+ for _, c := range paginationCases {
+ w.piece("pagination_browser_test.go", "paginationPage", c.ID, render(t, "pagination", map[string]any{"Label": "Posts pages", "Items": c.Items}), "one paginationCases entry; the port wraps it in <div id=\"pg-"+c.ID+"\">")
+ }
+
+ row := func(id, name, post string) string {
+ return `<div rst-lrow id="row-` + id + `"><a class="rst-nm" href="/go/row-` + id + `">` + name + `</a><span class="rst-m-hide rst-cell-mut">Paid</span>` +
+ render(t, "row-menu", map[string]any{"Name": name, "Items": []any{
+ map[string]any{"Label": "View", "Href": "/go/view-" + id},
+ map[string]any{"Label": post, "Action": "/act/" + id, "Hidden": [][2]string{{"state", "archived"}, {"from", "list"}}},
+ map[string]any{"Label": "Delete order…", "Href": "/go/delete-" + id, "Danger": true},
+ }}) + `</div>`
+ }
+ w.piece("rowmenu_browser_test.go", "rowMenuPage", "row-a", row("a", "Grace Hopper", "Archive"), "one row of the list; the port joins the three in <div rst-card id=\"list\">")
+ w.piece("rowmenu_browser_test.go", "rowMenuPage", "row-b", row("b", "Alan Turing", "Archive"), "")
+ w.piece("rowmenu_browser_test.go", "rowMenuPage", "row-c", row("c", "Ada Lovelace", "Archive this order and notify the customer"), "the long POST label")
+ w.piece("rowmenu_browser_test.go", "TestARowMenuOpensBesideItsOwnKebab", "menu", render(t, "row-menu", map[string]any{"Name": "Grace Hopper", "Items": []any{
+ map[string]any{"Label": "Edit", "Href": "/go/edit"},
+ map[string]any{"Label": "Archive", "Action": "/act/a"},
+ map[string]any{"Label": "Delete order…", "Href": "/go/delete", "Danger": true},
+ }}), "")
+
+ w.body("mediagrid_browser_test.go", "mediaPage", "", bodyOf(t, mediaPage(t, "ltr")), "the ltr body; the port flips dir on the document; pictures at /images/tall.svg and /images/wide.svg")
+ w.body("apitokens_browser_test.go", "tokensPageDoc", "", bodyOf(t, tokensPageDoc(t)), "")
+
+ w.document("signin_browser_test.go", "signinPage", "", "/", signinPage(t), "")
+ var signinBody strings.Builder
+ for i, c := range signinStates() {
+ for _, preview := range []bool{false, true} {
+ if !preview && c.st.Door() == "continue" {
+ continue
+ }
+ data := signinData(c.st)
+ data["Brand"] = map[string]any{"Name": "Harbour", "Pitch": "Moorings and berths, booked in a minute."}
+ if preview {
+ data["Preview"] = true
+ }
+ fmt.Fprintf(&signinBody, `<div id="case-%d-%v" title=%q>%s</div>`, i, preview, c.name, render(t, "signin", data))
+ }
+ }
+ w.body("signin_browser_test.go", "TestSigninControlsStayInsideTheCardOnAPhone", "", signinBody.String(), "every signinStates entry, live and in Preview, as the test builds them")
+
+ // ── Task 11: the shell drives' partial renders; the layouts are not seeded ──
+ w.piece("shell_browser_test.go", "TestAMenuOpenedInsideAListCardEscapesTheCard", "bulk-bar", render(t, "bulk-bar", map[string]any{
+ "DoneHref": "/posts", "Count": "3 selected",
+ "MenuLabel": "Actions",
+ "Actions": []any{
+ map[string]any{"Value": "export", "Label": "Export"},
+ map[string]any{"Value": "unpublish", "Label": "Unpublish"},
+ map[string]any{"Value": "delete", "Label": "Delete…", "Danger": true},
+ },
+ }), "")
+ w.piece("shell_browser_test.go", "TestSelfShapedChildrenKeepTheirCornersInsideACard", "list-bar-search", render(t, "list-bar-search", map[string]any{"Action": "/posts"}), "the test strips the <search> wrapper itself")
+ var menuItems []any
+ for _, name := range []string{
+ "English", "Espanol", "Portugues", "Gaeilge", "Arabiyya", "Hindi",
+ "Bangla", "Nihongo", "Russkiy", "Tieng Viet", "Jyutping", "Zhongwen",
+ } {
+ menuItems = append(menuItems, map[string]any{"Href": "#", "Label": name})
+ }
+ for _, c := range menuCorners {
+ if c.rowMenu {
+ w.add(fixtureEntry{File: "shell_browser_test.go", Builder: "TestMenusCapTheirHeightScrollAndFlipToFitTheViewport", Case: c.id,
+ Note: "a hand-written row menu around rastrillo.Icon(\"kebab\"), which fiesta.Icon(\"kebab\") gives"})
+ continue
+ }
+ items := menuItems
+ if c.short {
+ items = items[:3]
+ }
+ w.piece("shell_browser_test.go", "TestMenusCapTheirHeightScrollAndFlipToFitTheViewport", c.id, render(t, "dropdown", map[string]any{
+ "Label": "Language", "Items": items, "MenuGroup": "rst-menu-" + c.id,
+ }), "one menuCorners entry")
+ }
+ w.piece("shell_browser_test.go", "TestClearingASearchIsALinkAndTheNativeCrossIsGone", "search", render(t, "list-bar-search", map[string]any{
+ "Action": "/posts", "Query": "sere", "Placeholder": "Search posts",
+ "Hidden": [][2]string{{"status", "paid"}},
+ }), "")
+ for _, m := range []struct {
+ id string
+ items []any
+ }{{"short", menuItems[:3]}, {"long", menuItems}} {
+ w.piece("shell_browser_test.go", "TestAMenuOpenedInsideAShortFrameIsStillUsable", m.id, render(t, "dropdown", map[string]any{
+ "Label": "Menu", "Items": m.items, "MenuGroup": "rst-menu-" + m.id,
+ }), "")
+ }
+ for _, b := range []string{"TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward", "TestTheTopbarCollapsesItsTailBehindOneDisclosure"} {
+ w.unseeded("shell_browser_test.go", b, layoutReason)
+ }
+ for _, b := range []string{"shellLayoutPage", "TestThePhoneIndexWorksWithNoScript", "TestThePhoneIndexWorksWithScripts", "TestTheWideLayoutIsUnchanged",
+ "TestTheIndexRowsRoundEachRunOfLinks", "TestTheConsoleIndexRailFillsTheScreen", "TestAWhitespaceViewIsTheIndexInTheBrowser",
+ "TestAFragmentLinkLandsBelowTheBackStrip", "TestThePhoneHeaderStripIs56Pixels"} {
+ w.unseeded("index_browser_test.go", b, layoutReason)
+ }
+ w.unseeded("index_browser_test.go", "TestAnOldSidebarLayoutKeepsItsDrawer", "renders the legacy sidebar with shellLayoutPage's defaults: ../legacy/sidebar.html is that page")
+ w.unseeded("index_browser_test.go", "TestAnOldConsoleLayoutKeepsItsRailOpen", "renders the legacy console with its own nav and content defines; ../legacy/console.html is the default render, so the port adapts its assertions or keeps the defines")
+ for _, b := range []string{"consolePage", "consolePageFrom"} {
+ w.unseeded("console_shell_browser_test.go", b, layoutReason)
+ }
+ w.unseeded("console_shell_browser_test.go", "legacyConsolePage", "renders the legacy console with each test's own nav; ../legacy/console.html is the default render")
+ for _, b := range []string{"TestTheProfileMenuOnThePhoneIndex", "TestTheProfileMenuIsADropUpAtTheRailsFoot", "TestThePersonNeverVanishesFromTheRail", "TestABlankBrandFallsBackToTheTitle"} {
+ w.unseeded("profile_browser_test.go", b, layoutReason)
+ }
+ for _, b := range []string{"topbarPageWith", "TestAModalCoversTheTopbarOnAPhone", "TestTheConsoleBarIsTheSameCard"} {
+ w.unseeded("card_browser_test.go", b, layoutReason)
+ }
+ w.unseeded("prerender_browser_test.go", "prerenderSite", layoutReason+"; its /legacy page is ../legacy/sidebar.html")
+
+ w.writeIndex()
+ if dry {
+ fmt.Printf("fiesta seed dry run: %d fixture entries\n", len(w.index))
+ return
+ }
+ fmt.Printf("fiesta seed: wrote %d fixture files under %s\n", w.written, out)
+}
+
+// fiestaScripts reads /rastrillo.js as /fiesta.js in a served document,
+// the one rename Fiesta makes to the scripts it ships.
+func fiestaScripts(doc string) string {
+ return strings.ReplaceAll(doc, `src="/rastrillo.js"`, `src="/fiesta.js"`)
+}
+
+// bodyOf is what a builder handed sizingDoc: the bytes between <body>
+// and </body>.
+func bodyOf(t *testing.T, doc string) string {
+ t.Helper()
+ i, j := strings.Index(doc, "<body>"), strings.LastIndex(doc, "</body>")
+ if i < 0 || j < i {
+ t.Fatalf("no body in\n%s", doc)
+ }
+ return doc[i+len("<body>") : j]
+}
+
+func (w *fixtureWriter) write(rel string, body []byte) {
+ w.t.Helper()
+ if w.dry {
+ return
+ }
+ p := filepath.Join(w.out, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ w.t.Fatal(err)
+ }
+ if err := os.WriteFile(p, body, 0o644); err != nil {
+ w.t.Fatal(err)
+ }
+ w.written++
+}
+
+// clearFixtures removes every per-file directory from a previous run.
+func (w *fixtureWriter) clearFixtures() {
+ dir := filepath.Join(w.out, "check", "testdata", "fixtures")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
+ w.t.Fatal(err)
+ }
+ }
+ }
+}
+
+func (w *fixtureWriter) add(e fixtureEntry) {
+ for _, x := range w.index {
+ if x.File == e.File && x.Builder == e.Builder && x.Case == e.Case {
+ w.t.Fatalf("%s %s %q listed twice", e.File, e.Builder, e.Case)
+ }
+ }
+ w.index = append(w.index, e)
+}
+
+func fixturePath(file, builder, kase string) string {
+ name := builder
+ if kase != "" {
+ name += "-" + kase
+ }
+ return strings.TrimSuffix(file, ".go") + "/" + name + ".html"
+}
+
+func (w *fixtureWriter) whole(file, builder, path, markup, note string) {
+ w.write("check/testdata/fixtures/"+path, []byte(markup))
+ w.add(fixtureEntry{File: file, Builder: builder, Path: path, Kind: "body", Note: note})
+}
+
+func (w *fixtureWriter) piece(file, builder, kase, markup, note string) {
+ p := fixturePath(file, builder, kase)
+ w.write("check/testdata/fixtures/"+p, []byte(markup))
+ w.add(fixtureEntry{File: file, Builder: builder, Case: kase, Path: p, Kind: "piece", Note: note})
+}
+
+func (w *fixtureWriter) body(file, builder, kase, markup, note string) {
+ p := fixturePath(file, builder, kase)
+ w.write("check/testdata/fixtures/"+p, []byte(markup))
+ w.add(fixtureEntry{File: file, Builder: builder, Case: kase, Path: p, Kind: "body", Note: note})
+}
+
+// document requests one path of a handler and keeps the response.
+func (w *fixtureWriter) document(file, builder, kase, request string, h http.Handler, note string) {
+ w.t.Helper()
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("GET", request, nil))
+ if rec.Code != http.StatusOK {
+ w.t.Fatalf("%s %s: GET %s answered %d:\n%s", file, builder, request, rec.Code, rec.Body.String())
+ }
+ p := fixturePath(file, builder, kase)
+ w.write("check/testdata/fixtures/"+p, []byte(fiestaScripts(rec.Body.String())))
+ w.add(fixtureEntry{File: file, Builder: builder, Case: kase, Path: p, Kind: "document", Request: request, Note: note})
+}
+
+func (w *fixtureWriter) unseeded(file, builder, reason string) {
+ w.add(fixtureEntry{File: file, Builder: builder, Unseeded: reason})
+}
+
+func (w *fixtureWriter) writeIndex() {
+ b, err := json.MarshalIndent(w.index, "", " ")
+ if err != nil {
+ w.t.Fatal(err)
+ }
+ w.write("check/testdata/fixtures/index.json", append(b, '\n'))
+}
diff --git a/hack/seed/run.sh b/hack/seed/run.sh
new file mode 100755
index 0000000..84ea925
--- /dev/null
+++ b/hack/seed/run.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+# hack/seed/run.sh <rastrillo checkout> <SEED>
+#
+# Runs the two seed exporters inside a scratch rastrillo worktree at
+# SEED and writes their output into this checkout: the examples, the
+# manifest, the fixtures, the language files and the datetime tables.
+# Fiesta never imports rastrillo, so the exporters live under
+# hack/seed/_rastrillo/ (the leading _ keeps Go's tools out) and are
+# copied into the worktree for the run.
+#
+# The worktree goes under $TMPDIR, and the trap removes it and the two
+# copied files whether the run passed or failed.
+#
+# FIESTA_SEED_DRYRUN=1 hack/seed/run.sh ~/src/rastrillo e14b745a7c08d0efa3bf221a8b6a5e75de15c46f
+#
+# writes nothing and prints the state labels that need copy review.
+set -eu
+
+if [ $# -ne 2 ]; then
+ echo "usage: $0 <rastrillo checkout> <SEED>" >&2
+ exit 2
+fi
+checkout=$1
+seed=$2
+here=$(cd "$(dirname "$0")" && pwd)
+fiesta=$(cd "$here/../.." && pwd)
+wt="${TMPDIR:-/tmp}/rastrillo-seed"
+
+cleanup() {
+ rm -f "$wt/internal/designsystem/designsystem_fiestaseed_test.go" "$wt/ui/ui_fiestaseed_test.go"
+ git -C "$checkout" worktree remove --force "$wt" 2>/dev/null || true
+ git -C "$checkout" worktree prune
+}
+trap cleanup EXIT INT TERM
+
+if [ -e "$wt" ]; then
+ echo "$wt already exists; remove it first" >&2
+ exit 1
+fi
+git -C "$checkout" worktree add --detach "$wt" "$seed"
+cp "$here/_rastrillo/designsystem_fiestaseed_test.go" "$wt/internal/designsystem/"
+cp "$here/_rastrillo/ui_fiestaseed_test.go" "$wt/ui/"
+
+cd "$wt"
+GOFLAGS=-mod=mod FIESTA_SEED_OUT="$fiesta" go test -tags 'fiestaseed,browser' -run '^TestFiestaSeed$' ./internal/designsystem/ ./ui/ -count=1 -v
diff --git a/norastrillo_test.go b/norastrillo_test.go
index f337916..5b03c3d 100644
--- a/norastrillo_test.go
+++ b/norastrillo_test.go
@@ -76,13 +76,12 @@ func TestFiestaNeverImportsRastrillo(t *testing.T) {
}
// TestThePublicPackagesLinkNoBrowser is the other half: the packages
-// a Go program imports from Fiesta, the root and (from Task 5) example,
-// never link chromedp. The browser is for check/ and the drives, and
-// an app that embeds the CSS must not take on a Chrome driver to do
-// it. Until example exists the list is the root package alone.
+// a Go program imports from Fiesta, the root and example, never link
+// chromedp. The browser is for check/ and the drives, and an app that
+// embeds the CSS must not take on a Chrome driver to do it.
func TestThePublicPackagesLinkNoBrowser(t *testing.T) {
- args := "-deps ."
- lines := listDeps(t, "-deps", ".")
+ args := "-deps . ./example"
+ lines := listDeps(t, "-deps", ".", "./example")
requireListed(t, lines, args)
for _, l := range lines {
if strings.Contains(l, "chromedp") {