//go:build fiestaseed // The Fiesta seed exporter for internal/designsystem. hack/seed/run.sh // copies this file into a scratch rastrillo worktree at the seed // commit 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//.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/.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" "errors" "fmt" "html" "html/template" "io/fs" "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"` // View is a shell's index or page document, for the two shells // that have both; empty for everything else. View string `json:"view,omitempty"` } // 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 went // through copy review (2026-10-27), as did everything under // "approved copy" below; the dry run lists any label outside rastrillo's // strings and this set. const standaloneLabel = "Standalone: the page remembers the choice" // Approved copy: where rastrillo's demo text named rastrillo, Fiesta says // something else, and the exporter applies it so a reseed reproduces // it. labelOverrides maps a rastrillo state label to its replacement (the // state id follows the new label). layoutBrand is the demo app's name // in the shell examples, layoutTitle the shell title's text, and // layoutFooter replaces "Made with rastrillo" wherever a sample says // it. Nothing joins this set without copy review. var ( labelOverrides = map[string]string{ "Working: what rastrillo.js writes on the way out": "Working: what busy.js writes on the way out", } layoutBrand = "Party planner" layoutTitle = "Posts" layoutFooter = "Made with Fiesta" // exampleTitle is every example document's : the entry's // Title and the state's label. A state with no label (the idioms, // layouts and formats) shows its id, default. exampleTitle = "{example}: {state}" // layoutIntro is the note on each chrome shell's sample page, // replacing rastrillo's sentence (seedIntro below) with the shell's // name filled in the same way. layoutIntro = "This is the {shell} shell, one of the shells Fiesta ships. A screen is a column: a page header, then a section heading and its card, then the next one. Everything you see here is the shell, tokens.css and two components." ) // seedIntro is the sentence shellTemplate's content writes, as this // package's prose key; layouts() replaces it and fails if it is not // there exactly once. const seedIntro = "This is the {shell} shell, one of the shells ui.Layout ships. A screen is a column: a page header, then a section heading and its card, then the next one. Everything you see here is the shell, tokens.css and two partials." // titleFor is exampleTitle for one state. func titleFor(e *seedEntry, id, label string) string { state := label if state == "" { state = id } return strings.NewReplacer("{example}", e.Title, "{state}", state).Replace(exampleTitle) } // demoCopy applies the approved replacements to a sample's markup. func demoCopy(markup string) string { return strings.ReplaceAll(markup, "Made with rastrillo", layoutFooter) } // 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.clearOutputs() } 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 } known[standaloneLabel] = true for _, approved := range labelOverrides { known[approved] = 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++ } // clearOutputs removes what a previous run wrote and this exporter // owns, so a state that was renamed or a file rastrillo dropped does not // linger: every example directory (files directly under examples/, // the README, stay), the language files, the datetime tables and the // legacy stylesheets. The rendered legacy layouts beside those // stylesheets are the ui exporter's. A directory that is not there // yet is fine; any other error is not. func (s *seeder) clearOutputs() { s.removeMatching(filepath.Join(s.out, "examples"), func(e os.DirEntry) bool { return e.IsDir() }) s.removeMatching(filepath.Join(s.out, "i18n"), func(e os.DirEntry) bool { return !e.IsDir() && strings.HasSuffix(e.Name(), ".json") }) s.removeMatching(filepath.Join(s.out, "check", "testdata", "datetime"), func(e os.DirEntry) bool { return !e.IsDir() && strings.HasSuffix(e.Name(), ".json") }) s.removeMatching(filepath.Join(s.out, "check", "testdata", "legacy"), func(e os.DirEntry) bool { return !e.IsDir() && strings.HasSuffix(e.Name(), "-v028.css") }) } func (s *seeder) removeMatching(dir string, match func(os.DirEntry) bool) { entries, err := os.ReadDir(dir) if errors.Is(err, fs.ErrNotExist) { return } if err != nil { s.t.Fatal(err) } for _, e := range entries { if match(e) { 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) + "\n") b.WriteString(`` + "\n") b.WriteString(`` + "\n") for _, name := range scripts { b.WriteString(`` + "\n") } b.WriteString("\n\n") b.WriteString(body) b.WriteString("\n\n\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, htmlAttr, body string, scripts []string, src seedSource) { s.t.Helper() title := titleFor(e, id, label) 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{} var bodies []string 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))) bodies = append(bodies, body) label := st.State if approved, ok := labelOverrides[label]; ok { label = approved } id := stateID(ids, label) s.addState(&e, id, label, "", body, seedScripts(body), seedSource{Kind: "partial", Source: doc.Name, State: i, Locale: "en"}) } if doc.Name == "scheme-menu" { // The hand-written standalone state: the first state's // markup, wrapped and picture-rewritten the same way, // with the forms' action removed, on a page that // remembers the choice itself. standalone := strings.ReplaceAll(bodies[0], ` action="/_theme"`, "") if standalone == bodies[0] { s.t.Fatal("scheme-menu: no action to remove") } s.addState(&e, "standalone", standaloneLabel, ` data-rst-scheme-store="local"`, standalone, seedScripts(standalone), seedSource{Kind: "partial", Source: doc.Name, State: 0, 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 := demoCopy(localMarkup("en", samples[name])) src := seedSource{Kind: "idiom", Source: name, State: 0, Locale: "en"} if name == "dropdown" { for i := range s.entries { if s.entries[i].Name != "dropdown" { continue } e := &s.entries[i] s.addState(e, "menu-idiom", "", "", 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", "", "", 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//.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}} ` // seedBrand is the brand block with the approved demo name in place // of the gallery's. Parsed on its own after shellTemplate, so it // replaces that file's brand rather than colliding with it: two // defines of one name in a single Parse are an error, and a later // Parse replaces an earlier define. func seedBrand() string { return `{{define "brand"}}` + template.HTMLEscapeString(layoutBrand) + `{{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) } if _, err := tmpl.Parse(seedBrand()); err != nil { s.t.Fatalf("parsing the brand: %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 { // The title block feeds both and the shell title's // h1. The h1 shows the approved layoutTitle; the <title> // gets exampleTitle like every other document, put in once // the page is rendered. d.data.Title = layoutTitle 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 := demoCopy(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) docTitle := "<title>" + template.HTMLEscapeString(layoutTitle) + "" if strings.Count(page, docTitle) != 1 { s.t.Fatalf("%s: the document title did not render once", d.name) } e := seedEntry{Name: d.name, Title: proseIn("en", "The {shell} shell", "shell", shell), Family: "shells", Kind: "layout", Document: true} page = strings.Replace(page, docTitle, ""+template.HTMLEscapeString(titleFor(&e, "default", ""))+"", 1) if shell != "stage" { // The chrome shells' sample page carries the intro; // the stage frames the sign-in card and has none. old := proseIn("en", seedIntro, "shell", shell) if strings.Count(page, old) != 1 { s.t.Fatalf("%s: the layout intro did not render once", d.name) } page = strings.Replace(page, old, template.HTMLEscapeString(strings.ReplaceAll(layoutIntro, "{shell}", shell)), 1) } bodyAt := strings.Index(page, ".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')) }