rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build fiestaseed
2
3// The Fiesta seed exporter for internal/designsystem. hack/seed/run.sh
4// copies this file into a scratch rastrillo worktree at the seed
5// commit and runs TestFiestaSeed there, so it has the gallery's own
6// helpers in reach: the sample tables, the partial tree, the shell
7// demos, the formats and the screens. It writes, under
8// FIESTA_SEED_OUT:
9//
10// - examples/<name>/<state>.html, one document per state, with the
11// HTML-tab markup (never the deadened preview) as its body;
12// - examples/examples.json, the manifest, and examples/assets/;
13// - i18n/<lang>.json for the twelve shipped languages;
14// - check/testdata/datetime/*.json and retagged.json;
15// - check/testdata/legacy/*-v028.css;
16// - hack/seed/sources.json, which says where each state came from.
17//
18// With FIESTA_SEED_DRYRUN set it writes nothing and prints every state
19// label that is not already an English string in this package, which
20// is what copy review needs before the export is run for real.
21package designsystem
22
23import (
24 "encoding/json"
25 "errors"
26 "fmt"
27 "html"
28 "html/template"
29 "io/fs"
30 "os"
31 "path/filepath"
32 "regexp"
33 "sort"
34 "strings"
35 "testing"
36
37 "amadan.net/rastrillo/rastrillo"
38 "amadan.net/rastrillo/rastrillo/auth"
39 "amadan.net/rastrillo/rastrillo/ui"
40)
41
42// The manifest's shape, mirrored from Fiesta's example package. The
43// tags are the contract; this side cannot import the other.
44type seedManifest struct {
45 Examples []seedEntry `json:"examples"`
46 Idioms map[string]seedIdiom `json:"idioms"`
47}
48
49type seedEntry struct {
50 Name string `json:"name"`
51 Title string `json:"title"`
52 Family string `json:"family"`
53 Kind string `json:"kind"`
54 Document bool `json:"document"`
55 Wrapper string `json:"wrapper"`
56 Scripts []string `json:"scripts"`
57 Default string `json:"default"`
58 States []seedState `json:"states"`
59}
60
61type seedState struct {
62 ID string `json:"id"`
63 Label string `json:"label"`
64 SampleURLs []string `json:"sampleURLs"`
65}
66
67type seedIdiom struct {
68 Example string `json:"example"`
69 State string `json:"state"`
70}
71
72// seedSource is one line of hack/seed/sources.json: where a state's
73// markup came from in this package.
74type seedSource struct {
75 Kind string `json:"kind"`
76 Source string `json:"source"`
77 State int `json:"state"`
78 Locale string `json:"locale"`
79 // View is a shell's index or page document, for the two shells
80 // that have both; empty for everything else.
81 View string `json:"view,omitempty"`
82}
83
84// defaultStates names the default state of an example whose default is
85// not the first state in rastrillo's order. Nothing joins this map
86// without copy review.
87var defaultStates = map[string]string{"scheme-menu": "standalone"}
88
89// standaloneLabel is the one hand-written state label. It went
90// through copy review (2026-10-27), as did everything under
91// "approved copy" below; the dry run lists any label outside rastrillo's
92// strings and this set.
93const standaloneLabel = "Standalone: the page remembers the choice"
94
95// Approved copy: where rastrillo's demo text named rastrillo, Fiesta says
96// something else, and the exporter applies it so a reseed reproduces
97// it. labelOverrides maps a rastrillo state label to its replacement (the
98// state id follows the new label). layoutBrand is the demo app's name
99// in the shell examples, layoutTitle the shell title's text, and
100// layoutFooter replaces "Made with rastrillo" wherever a sample says
101// it. Nothing joins this set without copy review.
102var (
103 labelOverrides = map[string]string{
104 "Working: what rastrillo.js writes on the way out": "Working: what busy.js writes on the way out",
105 }
106 layoutBrand = "Party planner"
107 layoutTitle = "Posts"
108 layoutFooter = "Made with Fiesta"
109 // exampleTitle is every example document's <title>: the entry's
110 // Title and the state's label. A state with no label (the idioms,
111 // layouts and formats) shows its id, default.
112 exampleTitle = "{example}: {state}"
113 // layoutIntro is the note on each chrome shell's sample page,
114 // replacing rastrillo's sentence (seedIntro below) with the shell's
115 // name filled in the same way.
116 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."
117)
118
119// seedIntro is the sentence shellTemplate's content writes, as this
120// package's prose key; layouts() replaces it and fails if it is not
121// there exactly once.
122const 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."
123
124// titleFor is exampleTitle for one state.
125func titleFor(e *seedEntry, id, label string) string {
126 state := label
127 if state == "" {
128 state = id
129 }
130 return strings.NewReplacer("{example}", e.Title, "{state}", state).Replace(exampleTitle)
131}
132
133// demoCopy applies the approved replacements to a sample's markup.
134func demoCopy(markup string) string {
135 return strings.ReplaceAll(markup, "Made with rastrillo", layoutFooter)
136}
137
138// seedScripts is previewScripts with rastrillo.js read as fiesta.js,
139// in load order.
140func seedScripts(body string) []string {
141 var out []string
142 for _, s := range previewScripts {
143 for _, hook := range s.hooks {
144 if strings.Contains(body, hook) {
145 name := s.asset
146 if name == "rastrillo.js" {
147 name = "fiesta.js"
148 }
149 out = append(out, name)
150 break
151 }
152 }
153 }
154 return out
155}
156
157type seeder struct {
158 t *testing.T
159 out string
160 dry bool
161 entries []seedEntry
162 idioms map[string]seedIdiom
163 sources map[string]seedSource
164 review []string
165 known map[string]bool
166 written int
167}
168
169func TestFiestaSeed(t *testing.T) {
170 out := os.Getenv("FIESTA_SEED_OUT")
171 dry := os.Getenv("FIESTA_SEED_DRYRUN") != ""
172 if out == "" && !dry {
173 t.Skip("FIESTA_SEED_OUT is unset; this test is the Fiesta seed exporter, run by hack/seed/run.sh")
174 }
175 s := &seeder{t: t, out: out, dry: dry, idioms: map[string]seedIdiom{}, sources: map[string]seedSource{}, known: seedKnownStrings()}
176 if !dry {
177 s.clearOutputs()
178 }
179 s.components()
180 s.idiomStates()
181 s.layouts()
182 s.formats()
183 s.screens()
184 s.manifest()
185 s.assets()
186 s.i18n()
187 s.datetime()
188 s.legacyCSS()
189 s.writeSources()
190 if dry {
191 fmt.Printf("fiesta seed dry run: %d examples, %d labels need copy review\n", len(s.entries), len(s.review))
192 for _, line := range s.review {
193 fmt.Println("review: " + line)
194 }
195 return
196 }
197 fmt.Printf("fiesta seed: wrote %d files under %s (%d examples)\n", s.written, out, len(s.entries))
198}
199
200// seedKnownStrings is every English string this package already
201// holds that could become a state label: the sample tables' State
202// labels, the screens' names and every prose key. A label outside
203// this set is new copy.
204func seedKnownStrings() map[string]bool {
205 known := map[string]bool{}
206 for _, fam := range families() {
207 for _, doc := range fam.Partials {
208 for _, st := range doc.States {
209 known[st.State] = true
210 }
211 }
212 }
213 for _, doc := range screenDocs() {
214 known[doc.Name] = true
215 }
216 for en := range prose {
217 known[en] = true
218 }
219 known[standaloneLabel] = true
220 for _, approved := range labelOverrides {
221 known[approved] = true
222 }
223 return known
224}
225
226func (s *seeder) write(rel string, body []byte) {
227 s.t.Helper()
228 if s.dry {
229 return
230 }
231 p := filepath.Join(s.out, filepath.FromSlash(rel))
232 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
233 s.t.Fatal(err)
234 }
235 if err := os.WriteFile(p, body, 0o644); err != nil {
236 s.t.Fatal(err)
237 }
238 s.written++
239}
240
241// clearOutputs removes what a previous run wrote and this exporter
242// owns, so a state that was renamed or a file rastrillo dropped does not
243// linger: every example directory (files directly under examples/,
244// the README, stay), the language files, the datetime tables and the
245// legacy stylesheets. The rendered legacy layouts beside those
246// stylesheets are the ui exporter's. A directory that is not there
247// yet is fine; any other error is not.
248func (s *seeder) clearOutputs() {
249 s.removeMatching(filepath.Join(s.out, "examples"), func(e os.DirEntry) bool { return e.IsDir() })
250 s.removeMatching(filepath.Join(s.out, "i18n"), func(e os.DirEntry) bool { return !e.IsDir() && strings.HasSuffix(e.Name(), ".json") })
251 s.removeMatching(filepath.Join(s.out, "check", "testdata", "datetime"), func(e os.DirEntry) bool { return !e.IsDir() && strings.HasSuffix(e.Name(), ".json") })
252 s.removeMatching(filepath.Join(s.out, "check", "testdata", "legacy"), func(e os.DirEntry) bool { return !e.IsDir() && strings.HasSuffix(e.Name(), "-v028.css") })
253}
254
255func (s *seeder) removeMatching(dir string, match func(os.DirEntry) bool) {
256 entries, err := os.ReadDir(dir)
257 if errors.Is(err, fs.ErrNotExist) {
258 return
259 }
260 if err != nil {
261 s.t.Fatal(err)
262 }
263 for _, e := range entries {
264 if match(e) {
265 if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
266 s.t.Fatal(err)
267 }
268 }
269 }
270}
271
272// stateID is the English label slugified, with -2, -3 on a collision;
273// an empty label gives default.
274func stateID(ids map[string]int, label string) string {
275 id := slug(label)
276 if id == "" {
277 id = "default"
278 }
279 ids[id]++
280 if n := ids[id]; n > 1 {
281 return fmt.Sprintf("%s-%d", id, n)
282 }
283 return id
284}
285
286var (
287 seedHref = regexp.MustCompile(`(?:^|\s)href="([^"]*)"`)
288 seedAction = regexp.MustCompile(`<form\b[^>]*?\saction="([^"]*)"`)
289 seedScript = regexp.MustCompile(`src="\.\./\.\./js/([^"]+)"`)
290)
291
292// sampleURLs lists, sorted, every href and form action that is neither
293// a fragment nor one of Fiesta's own files or pictures: the routes the
294// sample stands for.
295func sampleURLs(body string) []string {
296 seen := map[string]bool{}
297 add := func(v string) {
298 v = html.UnescapeString(v)
299 if v == "" || strings.HasPrefix(v, "#") || strings.HasPrefix(v, "../../") || strings.HasPrefix(v, "../assets/") {
300 return
301 }
302 seen[v] = true
303 }
304 for _, m := range seedHref.FindAllStringSubmatch(body, -1) {
305 add(m[1])
306 }
307 for _, m := range seedAction.FindAllStringSubmatch(body, -1) {
308 add(m[1])
309 }
310 out := make([]string, 0, len(seen))
311 for v := range seen {
312 out = append(out, v)
313 }
314 sort.Strings(out)
315 return out
316}
317
318// pictures rewrites a sample's picture routes to the example assets,
319// relative to examples/<name>/<state>.html.
320func pictures(body string) string {
321 return sampleSrc.ReplaceAllStringFunc(body, func(m string) string {
322 v := m[len(`src="`) : len(m)-1]
323 if asset, ok := sampleImages[v]; ok {
324 return `src="../assets/` + asset + `"`
325 }
326 return m
327 })
328}
329
330// document is the example file format: the standard head, the scripts
331// the body needs, and the body exactly as rendered.
332func document(title, htmlAttr, body string, scripts []string) string {
333 var b strings.Builder
334 b.WriteString("<!doctype html>\n")
335 b.WriteString(`<html lang="en" dir="ltr"` + htmlAttr + ">\n")
336 b.WriteString("<head>\n<meta charset=\"utf-8\">\n")
337 b.WriteString(`<meta name="viewport" content="width=device-width">` + "\n")
338 b.WriteString("<title>" + template.HTMLEscapeString(title) + "</title>\n")
339 b.WriteString(`<link rel="stylesheet" href="../../css/tokens.css">` + "\n")
340 b.WriteString(`<link rel="stylesheet" href="../../css/themes/day.css">` + "\n")
341 for _, name := range scripts {
342 b.WriteString(`<script defer src="../../js/` + name + `"></script>` + "\n")
343 }
344 b.WriteString("</head>\n<body>\n")
345 b.WriteString(body)
346 b.WriteString("\n</body>\n</html>\n")
347 return b.String()
348}
349
350// addState writes one state's document and records it on its entry.
351// label is checked against the strings this package already holds.
352func (s *seeder) addState(e *seedEntry, id, label, htmlAttr, body string, scripts []string, src seedSource) {
353 s.t.Helper()
354 title := titleFor(e, id, label)
355 for _, st := range e.States {
356 if st.ID == id {
357 s.t.Fatalf("%s: state id %q twice", e.Name, id)
358 }
359 }
360 for _, sc := range scripts {
361 if !contains(e.Scripts, sc) {
362 e.Scripts = append(e.Scripts, sc)
363 }
364 }
365 if label != "" && !s.known[label] {
366 s.review = append(s.review, fmt.Sprintf("state.%s.%s: %s", e.Name, id, label))
367 }
368 e.States = append(e.States, seedState{ID: id, Label: label, SampleURLs: sampleURLs(body)})
369 s.sources[e.Name+"/"+id] = src
370 s.write("examples/"+e.Name+"/"+id+".html", []byte(document(title, htmlAttr, body, scripts)))
371}
372
373func contains(list []string, v string) bool {
374 for _, x := range list {
375 if x == v {
376 return true
377 }
378 }
379 return false
380}
381
382func (s *seeder) finish(e seedEntry) {
383 if e.Scripts == nil {
384 e.Scripts = []string{}
385 }
386 if d, ok := defaultStates[e.Name]; ok {
387 e.Default = d
388 } else if len(e.States) > 0 {
389 e.Default = e.States[0].ID
390 }
391 found := false
392 for _, st := range e.States {
393 found = found || st.ID == e.Default
394 }
395 if !found {
396 s.t.Fatalf("%s: default %q is not a state", e.Name, e.Default)
397 }
398 s.entries = append(s.entries, e)
399}
400
401func wrapperName(w wrapper) string {
402 switch w {
403 case wrapList:
404 return "list"
405 case wrapForm:
406 return "form"
407 case wrapBox:
408 return "box"
409 case wrapStats:
410 return "stats"
411 }
412 return ""
413}
414
415// components is the 42 partial docs in families(), every state of
416// each, rendered as the HTML tab renders it and wrapped in the
417// container its partial assumes.
418func (s *seeder) components() {
419 tmpl, err := partialTree("en")
420 if err != nil {
421 s.t.Fatal(err)
422 }
423 if err := parseRawSamples(tmpl); err != nil {
424 s.t.Fatal(err)
425 }
426 for _, fam := range families() {
427 for _, doc := range fam.Partials {
428 e := seedEntry{Name: doc.Name, Title: doc.Name, Family: fam.Key, Kind: "component", Wrapper: wrapperName(doc.Wrap)}
429 ids := map[string]int{}
430 var bodies []string
431 for i, st := range doc.States {
432 h, err := renderSample(tmpl, doc.Name, i, st, "en")
433 if err != nil {
434 s.t.Fatalf("%s state %d: %v", doc.Name, i, err)
435 }
436 body := pictures(wrap(doc.Wrap, string(h)))
437 bodies = append(bodies, body)
438 label := st.State
439 if approved, ok := labelOverrides[label]; ok {
440 label = approved
441 }
442 id := stateID(ids, label)
443 s.addState(&e, id, label, "", body, seedScripts(body), seedSource{Kind: "partial", Source: doc.Name, State: i, Locale: "en"})
444 }
445 if doc.Name == "scheme-menu" {
446 // The hand-written standalone state: the first state's
447 // markup, wrapped and picture-rewritten the same way,
448 // with the forms' action removed, on a page that
449 // remembers the choice itself.
450 standalone := strings.ReplaceAll(bodies[0], ` action="/_theme"`, "")
451 if standalone == bodies[0] {
452 s.t.Fatal("scheme-menu: no action to remove")
453 }
454 s.addState(&e, "standalone", standaloneLabel, ` data-rst-scheme-store="local"`, standalone, seedScripts(standalone), seedSource{Kind: "partial", Source: doc.Name, State: 0, Locale: "en"})
455 }
456 s.finish(e)
457 }
458 }
459}
460
461// idiomStates is ui.Styleguide(): one example per idiom with one state,
462// default, except dropdown, which becomes state menu-idiom of the
463// dropdown component.
464func (s *seeder) idiomStates() {
465 samples := ui.Styleguide()
466 names := make([]string, 0, len(samples))
467 for name := range samples {
468 names = append(names, name)
469 }
470 sort.Strings(names)
471 for _, name := range names {
472 body := demoCopy(localMarkup("en", samples[name]))
473 src := seedSource{Kind: "idiom", Source: name, State: 0, Locale: "en"}
474 if name == "dropdown" {
475 for i := range s.entries {
476 if s.entries[i].Name != "dropdown" {
477 continue
478 }
479 e := &s.entries[i]
480 s.addState(e, "menu-idiom", "", "", body, seedScripts(body), src)
481 s.idioms[name] = seedIdiom{Example: "dropdown", State: "menu-idiom"}
482 }
483 if _, ok := s.idioms[name]; !ok {
484 s.t.Fatal("no dropdown component to carry the idiom")
485 }
486 continue
487 }
488 e := seedEntry{Name: name, Title: name, Family: "primitives", Kind: "idiom"}
489 s.addState(&e, "default", "", "", body, seedScripts(body), src)
490 s.idioms[name] = seedIdiom{Example: name, State: "default"}
491 s.finish(e)
492 }
493}
494
495// fiestaAsset maps a layout's asset calls to Fiesta's files, relative
496// to examples/<name>/<state>.html.
497func fiestaAsset(t *testing.T) func(string) string {
498 return func(p string) string {
499 switch p {
500 case "static/tokens.css":
501 return "../../css/tokens.css"
502 case "static/theme.css":
503 return "../../css/themes/day.css"
504 case "static/shell.css":
505 return "../../css/shell.css"
506 case "static/rastrillo.js":
507 return "../../js/fiesta.js"
508 }
509 if strings.HasPrefix(p, "static/") && strings.HasSuffix(p, ".js") {
510 return "../../js/" + strings.TrimPrefix(p, "static/")
511 }
512 t.Fatalf("a layout asks for asset %q, which Fiesta does not ship", p)
513 return ""
514 }
515}
516
517// seedShellCommon is shellCommon for Fiesta: no gallery.js in head
518// (the layout's own empty head block stands), and the content block
519// wrapped in the slot markers. The markers are placeholders here
520// because html/template drops comments from template text; they
521// become the comments once the document is rendered.
522const seedShellCommon = `
523{{define "lang"}}{{.Locale}}{{end}}
524{{define "dir"}}{{.Dir}}{{end}}
525{{define "title"}}{{.Title}}{{end}}
526{{define "content"}}@@FIESTA-SLOT@@{{template "fiesta-content" .}}@@/FIESTA-SLOT@@{{end}}
527`
528
529// seedBrand is the brand block with the approved demo name in place
530// of the gallery's. Parsed on its own after shellTemplate, so it
531// replaces that file's brand rather than colliding with it: two
532// defines of one name in a single Parse are an error, and a later
533// Parse replaces an earlier define.
534func seedBrand() string {
535 return `{{define "brand"}}<a rst-shell-brand href="{{.Index}}">` + template.HTMLEscapeString(layoutBrand) + `</a>{{end}}`
536}
537
538// seedLocales is the shell demos' language menu with site-neutral
539// links, the way localeMenuData writes them.
540func seedLocales() []localeLink {
541 catalogs := rastrillo.BaseCatalogs()
542 var out []localeLink
543 for _, code := range rastrillo.BaseLocales() {
544 out = append(out, localeLink{
545 Code: code, Name: catalogs[code]["rastrillo.ui.locale_name"], Dir: rastrillo.Dir(code),
546 Href: "/" + code + "/", Current: code == "en",
547 })
548 }
549 return out
550}
551
552// layouts is renderShell for Fiesta: ui.Layout's own template, the
553// demo's chrome blocks, and the content region between the slot
554// markers. The index/page pairs of the shells that have one are two
555// examples each.
556func (s *seeder) layouts() {
557 for _, shell := range ui.LayoutNames() {
558 src, ok := ui.Layout(shell)
559 if !ok {
560 s.t.Fatalf("no shell %q", shell)
561 }
562 funcs := galleryFuncs("en")
563 funcs["asset"] = fiestaAsset(s.t)
564 tmpl, err := template.New("designsystem").Funcs(funcs).ParseFS(ui.Templates(), "*.html")
565 if err != nil {
566 s.t.Fatal(err)
567 }
568 if _, err := tmpl.Parse(string(src)); err != nil {
569 s.t.Fatalf("parsing the %s shell: %v", shell, err)
570 }
571 overrides := shellTemplate
572 if shell == "stage" {
573 overrides = stageShellTemplate
574 }
575 overrides = strings.Replace(overrides, `{{define "content"}}`, `{{define "fiesta-content"}}`, 1)
576 if _, err := tmpl.Parse(seedShellCommon + overrides); err != nil {
577 s.t.Fatalf("parsing the shell overrides: %v", err)
578 }
579 if _, err := tmpl.Parse(seedBrand()); err != nil {
580 s.t.Fatalf("parsing the brand: %v", err)
581 }
582 data := shellData{
583 Locale: "en",
584 Dir: "ltr",
585 Name: shell,
586 Mount: "",
587 Index: "/",
588 Locales: seedLocales(),
589 Account: template.HTML(localMarkup("en", string(accountMarkup))),
590 Profile: profileMarkup,
591 Signin: auth.SigninState{Step: auth.StepAsk, BeginPath: "/signin", ForgetPath: "/signin/forget"},
592 Brand: localData("en", galleryBrand).(map[string]any),
593 PageHref: "/posts",
594 Up: "/#nav-posts",
595 }
596 type doc struct {
597 name string
598 data shellData
599 }
600 docs := []doc{{"layout-" + shell, data}}
601 if shellHasIndex(shell) {
602 index, page := data, data
603 index.View, index.Up = "index", "/"
604 page.View = "page"
605 docs = []doc{{"layout-" + shell, index}, {"layout-" + shell + "-page", page}}
606 }
607 for _, d := range docs {
608 // The title block feeds both <title> and the shell title's
609 // h1. The h1 shows the approved layoutTitle; the <title>
610 // gets exampleTitle like every other document, put in once
611 // the page is rendered.
612 d.data.Title = layoutTitle
613 run, err := tmpl.Clone()
614 if err != nil {
615 s.t.Fatal(err)
616 }
617 var buf strings.Builder
618 if err := run.ExecuteTemplate(&buf, "layout", d.data); err != nil {
619 s.t.Fatalf("%s: %v", d.name, err)
620 }
621 page := demoCopy(buf.String())
622 if strings.Count(page, "@@FIESTA-SLOT@@") != 1 || strings.Count(page, "@@/FIESTA-SLOT@@") != 1 {
623 s.t.Fatalf("%s: the content slot did not render once", d.name)
624 }
625 page = strings.Replace(page, "@@FIESTA-SLOT@@", "<!-- fiesta:slot -->", 1)
626 page = strings.Replace(page, "@@/FIESTA-SLOT@@", "<!-- /fiesta:slot -->", 1)
627 docTitle := "<title>" + template.HTMLEscapeString(layoutTitle) + "</title>"
628 if strings.Count(page, docTitle) != 1 {
629 s.t.Fatalf("%s: the document title did not render once", d.name)
630 }
631 e := seedEntry{Name: d.name, Title: proseIn("en", "The {shell} shell", "shell", shell), Family: "shells", Kind: "layout", Document: true}
632 page = strings.Replace(page, docTitle, "<title>"+template.HTMLEscapeString(titleFor(&e, "default", ""))+"</title>", 1)
633 if shell != "stage" {
634 // The chrome shells' sample page carries the intro;
635 // the stage frames the sign-in card and has none.
636 old := proseIn("en", seedIntro, "shell", shell)
637 if strings.Count(page, old) != 1 {
638 s.t.Fatalf("%s: the layout intro did not render once", d.name)
639 }
640 page = strings.Replace(page, old, template.HTMLEscapeString(strings.ReplaceAll(layoutIntro, "{shell}", shell)), 1)
641 }
642 bodyAt := strings.Index(page, "<body")
643 if bodyAt < 0 {
644 s.t.Fatalf("%s: the rendered layout has no <body", d.name)
645 }
646 head, body := page[:bodyAt], page[bodyAt:]
647 var scripts []string
648 for _, m := range seedScript.FindAllStringSubmatch(head, -1) {
649 scripts = append(scripts, m[1])
650 }
651 e.Scripts = scripts
652 e.States = append(e.States, seedState{ID: "default", Label: "", SampleURLs: sampleURLs(body)})
653 s.sources[d.name+"/default"] = seedSource{Kind: "shell", Source: shell, State: 0, Locale: "en", View: d.data.View}
654 s.write("examples/"+d.name+"/default.html", []byte(page))
655 s.finish(e)
656 }
657 }
658}
659
660// formats is formatDocs(): one example per section, one state each.
661func (s *seeder) formats() {
662 for i, doc := range formatDocs() {
663 body := formatMarkup(doc, "en")
664 e := seedEntry{Name: "format-" + doc.Key, Title: doc.Title, Family: "formats", Kind: "format"}
665 s.addState(&e, "default", "", "", body, seedScripts(body), seedSource{Kind: "format", Source: doc.Key, State: i, Locale: "en"})
666 s.finish(e)
667 }
668}
669
670// screens is screenDocs(): the states of the signin example. A screen
671// with a Signin state is the partial rendered with the data
672// ds-screen-stage hands it; a hand-written one is its markup.
673func (s *seeder) screens() {
674 tmpl, err := partialTree("en")
675 if err != nil {
676 s.t.Fatal(err)
677 }
678 e := seedEntry{Name: "signin", Title: "signin", Family: "screens", Kind: "screen"}
679 ids := map[string]int{}
680 for i, doc := range screenDocs() {
681 var body string
682 if doc.Signin == nil {
683 body = localMarkup("en", doc.Markup)
684 } else {
685 var buf strings.Builder
686 err := tmpl.ExecuteTemplate(&buf, "signin", map[string]any{
687 "State": *doc.Signin, "Brand": localData("en", galleryBrand), "Preview": true,
688 })
689 if err != nil {
690 s.t.Fatalf("screen %s: %v", doc.Key, err)
691 }
692 body = buf.String()
693 }
694 id := stateID(ids, doc.Name)
695 s.addState(&e, id, doc.Name, "", body, seedScripts(body), seedSource{Kind: "screen", Source: doc.Key, State: i, Locale: "en"})
696 }
697 s.finish(e)
698}
699
700func (s *seeder) manifest() {
701 for i := range s.entries {
702 for j := range s.entries[i].States {
703 if s.entries[i].States[j].SampleURLs == nil {
704 s.entries[i].States[j].SampleURLs = []string{}
705 }
706 }
707 }
708 b, err := json.MarshalIndent(seedManifest{Examples: s.entries, Idioms: s.idioms}, "", " ")
709 if err != nil {
710 s.t.Fatal(err)
711 }
712 s.write("examples/examples.json", append(b, '\n'))
713}
714
715func (s *seeder) assets() {
716 for _, asset := range sampleImages {
717 b, err := samplesFS.ReadFile("samples/" + asset)
718 if err != nil {
719 s.t.Fatal(err)
720 }
721 s.write("examples/assets/"+asset, b)
722 }
723}
724
725// i18nKeys are the twenty string keys of i18n/<lang>.json, each an
726// attribute's name with data-rst- removed; the catalog key is the
727// same with _ for -.
728var i18nKeys = []string{
729 "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar",
730 "date-prev-month", "date-next-month", "date-results", "date-result-one",
731 "date-quick-today", "date-quick-tomorrow", "date-quick-next-week",
732 "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day",
733 "select-filter", "select-results", "select-result-one", "select-no-matches",
734}
735
736// i18nWords are the seventeen names under date-words.
737var i18nWords = []string{
738 "today", "tomorrow", "yesterday", "next", "last", "in", "ago", "at",
739 "day", "week", "month", "hour", "minute", "noon", "midnight", "am", "pm",
740}
741
742// i18n writes one flat object per shipped language from the base
743// catalogs. A key a language has no translation for falls back to the
744// English value, and the fallback is printed so it can be reported.
745func (s *seeder) i18n() {
746 catalogs := rastrillo.BaseCatalogs()
747 en := catalogs["en"]
748 for _, code := range rastrillo.BaseLocales() {
749 own := catalogs[code]
750 value := func(key string) string {
751 if v := own[key]; v != "" {
752 return v
753 }
754 fmt.Printf("i18n: %s has no %s; the English value stands in\n", code, key)
755 if v := en[key]; v != "" {
756 return v
757 }
758 s.t.Fatalf("i18n: en has no %s", key)
759 return ""
760 }
761 obj := map[string]any{}
762 words := map[string]string{}
763 for _, w := range i18nWords {
764 words[w] = value("rastrillo.ui.date_" + w)
765 }
766 obj["date-words"] = words
767 for _, key := range i18nKeys {
768 obj[key] = value("rastrillo.ui." + strings.ReplaceAll(key, "-", "_"))
769 }
770 b, err := json.MarshalIndent(obj, "", " ")
771 if err != nil {
772 s.t.Fatal(err)
773 }
774 s.write("i18n/"+code+".json", append(b, '\n'))
775 }
776}
777
778func (s *seeder) copyDir(from, to, suffix string) {
779 entries, err := os.ReadDir(from)
780 if err != nil {
781 s.t.Fatal(err)
782 }
783 for _, e := range entries {
784 if e.IsDir() || !strings.HasSuffix(e.Name(), suffix) {
785 continue
786 }
787 b, err := os.ReadFile(filepath.Join(from, e.Name()))
788 if err != nil {
789 s.t.Fatal(err)
790 }
791 s.write(to+"/"+e.Name(), b)
792 }
793}
794
795func (s *seeder) datetime() {
796 s.copyDir("../../ui/testdata/datetime", "check/testdata/datetime", ".json")
797 b, err := json.Marshal(rastrillo.Retagged())
798 if err != nil {
799 s.t.Fatal(err)
800 }
801 s.write("check/testdata/datetime/retagged.json", append(b, '\n'))
802}
803
804func (s *seeder) legacyCSS() {
805 s.copyDir("../../ui/testdata/legacy", "check/testdata/legacy", "-v028.css")
806}
807
808func (s *seeder) writeSources() {
809 b, err := json.MarshalIndent(s.sources, "", " ")
810 if err != nil {
811 s.t.Fatal(err)
812 }
813 s.write("hack/seed/sources.json", append(b, '\n'))
814}
815