rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package gallery renders Fiesta's own gallery: the design system in
2// every theme and twelve languages, with an Overview, a Tokens page,
3// an Icons page, a page per family and a page per example, every
4// state of every example in a frame of its own with its markup beside
5// it. It is a development and test artefact: the accessibility drives
6// scan what it renders, fiesta gallery writes it out for looking at,
7// and internal/www mounts it on the site.
8//
9// Render returns the whole tree in memory, keyed by path relative to
10// the tree root, and it is deterministic: the same tree from the same
11// source every time, so two renders can be compared.
12//
13// index.html day/en/index.html again
14// <theme>/<locale>/index.html the Overview
15// <theme>/<locale>/tokens.html the theme's palette and
16// the structure every
17// theme shares
18// <theme>/<locale>/icons.html every icon
19// <theme>/<locale>/<family>.html one page per family
20// <theme>/<locale>/<example>.html one page per example
21// <theme>/<locale>/frames/<example>/<state>.html
22// one document per state,
23// the example's own with
24// the gallery's assets
25// and dead links
26// <theme>/<locale>/demo.html, demo-dashboard.html, demo-requests.html,
27// demo-request.html the demo application
28// <theme>/<locale>/demo-modal.html the modal route
29// assets/<name>.<hash>.<ext>, assets/<name>.<ext>
30// every asset hashed and
31// plain: css/, js/, the
32// examples' pictures under
33// images/, and gallery.css,
34// gallery.js and frame.js
35//
36// Two kinds of URL. The gallery owns every stylesheet, script, image
37// and frame URL and every link marked data-gallery-nav: each is an
38// absolute path under the mount, and the tests check every one names a
39// file the render wrote. A sample's own links and forms are the
40// example's: the HTML tab shows them as written, and the frame
41// documents rewrite each href and action to "#", with frame.js
42// cancelling every submit, so a click or a submit inside a frame
43// requests nothing.
44package gallery
45
46import (
47 "bytes"
48 _ "embed"
49 "errors"
50 "fmt"
51 "html/template"
52 "io/fs"
53 "path"
54 "regexp"
55 "sort"
56 "strings"
57
58 "amadan.net/rastrillo/fiesta"
59 "amadan.net/rastrillo/fiesta/example"
60 "amadan.net/rastrillo/fiesta/internal/tokens"
61)
62
63// Options is what a caller decides about the tree.
64type Options struct {
65 // Mount is the URL path the tree is served from; "" means "/".
66 // Every URL in the output is an absolute path under it, so a tree
67 // rendered for one mount cannot be served at another.
68 Mount string
69}
70
71// The gallery's own furniture, embedded beside this file so each is
72// edited as what it is. None is part of the design system: no page
73// but the gallery's own links them. The stylesheet and the script are
74// each two source files, the chrome's and the preview widget's, joined
75// at render into the one gallery.css and gallery.js every page loads.
76//
77//go:embed gallery.css
78var galleryCSS []byte
79
80//go:embed view.css
81var viewCSS []byte
82
83//go:embed gallery.js
84var galleryJS []byte
85
86//go:embed view.js
87var viewJS []byte
88
89//go:embed frame.js
90var frameJS []byte
91
92// cleanMount normalises the mount to "" for the root or a rooted,
93// cleaned path with no slash at the end, so URLs are mount + "/" + rel
94// in either case.
95func cleanMount(mount string) string {
96 if mount == "" {
97 return ""
98 }
99 if !strings.HasPrefix(mount, "/") {
100 mount = "/" + mount
101 }
102 clean := path.Clean(mount)
103 if clean == "/" {
104 return ""
105 }
106 return clean
107}
108
109// site is one render in progress: the source, the manifest, the mount
110// and the output.
111type site struct {
112 src fs.FS
113 m *example.Manifest
114 mount string
115 out map[string][]byte
116 // scheme is the header's scheme menu: the scheme-menu example's
117 // standalone state, whose <html> attributes every gallery page
118 // takes as well, so fiesta.js stores the choice where that state
119 // says to.
120 scheme template.HTML
121 // schemes is the scheme menu in each locale it has been asked for,
122 // through localMarkup, as the bar of every page in that locale
123 // carries it.
124 schemes map[string]template.HTML
125 htmlAttrs template.HTMLAttr
126 // schemeScripts are the scripts that state's head asks for, by
127 // file name.
128 schemeScripts []string
129 // prose is the design system's explanations, from examples/prose.json.
130 prose *prose
131 families []family
132 icons []icon
133 // tokens is each theme's token groups, palette then structure.
134 tokens map[string][]tokens.Group
135 // hashed maps each asset's plain name under assets/ to the hashed
136 // name it is linked by.
137 hashed map[string]string
138 // tmpl is the parsed templates, one tree per locale.
139 tmpl map[string]*template.Template
140 // weighed is the release build of the source, each file weighed:
141 // what Getting started lists and its rail links, built once here
142 // rather than on each of the thousands of pages whose rail asks.
143 weighed release
144 // scene is the stage's scene, lifted once from the layout-stage
145 // example for every staged sign-in frame.
146 scene string
147}
148
149// family is one family of examples: its heading and the sentence
150// under it, the rules quoted at its head, and its examples.
151type family struct {
152 Key string
153 Title string
154 Blurb string
155 Rules []idiomRule
156 Examples []example.Entry
157}
158
159type icon struct {
160 Slug string
161 Markup template.HTML
162}
163
164// Render builds the whole tree in memory: path relative to the tree
165// root to file content. src is fiesta.FS() or any tree laid out the
166// same way.
167func Render(src fs.FS, opts Options) (map[string][]byte, error) {
168 s, err := newSite(src, opts.Mount)
169 if err != nil {
170 return nil, err
171 }
172 for _, theme := range s.themes() {
173 for _, locale := range Locales {
174 if err := s.theme(theme, locale); err != nil {
175 return nil, fmt.Errorf("gallery: %s/%s: %w", theme, locale, err)
176 }
177 }
178 }
179 // The tree root is the default theme's English Overview again,
180 // copied rather than aliased so callers get one slice per path.
181 root, ok := s.out[s.themes()[0]+"/en/index.html"]
182 if !ok {
183 return nil, errors.New("gallery: no index to serve as the tree root")
184 }
185 if err := s.put("index.html", append([]byte(nil), root...)); err != nil {
186 return nil, err
187 }
188 return s.out, nil
189}
190
191// newSite reads everything a render needs before a page is written:
192// the manifest, the prose, the catalogs, the assets, the scheme menu,
193// the families, the icons, the tokens, the weighed release and the
194// stage's scene.
195func newSite(src fs.FS, mount string) (*site, error) {
196 m, err := example.Load(src)
197 if err != nil {
198 return nil, fmt.Errorf("gallery: %w", err)
199 }
200 s := &site{src: src, m: m, mount: cleanMount(mount), out: map[string][]byte{}, hashed: map[string]string{}}
201 if s.prose, err = loadProse(src); err != nil {
202 return nil, fmt.Errorf("gallery: %w", err)
203 }
204 if _, err := catalogs(); err != nil {
205 return nil, fmt.Errorf("gallery: %w", err)
206 }
207 if _, err := sampleCatalogs(); err != nil {
208 return nil, fmt.Errorf("gallery: %w", err)
209 }
210 if _, err := moneyTable(); err != nil {
211 return nil, fmt.Errorf("gallery: i18n/money.json: %w", err)
212 }
213 if _, err := controlStrings(); err != nil {
214 return nil, fmt.Errorf("gallery: %w", err)
215 }
216 if err := s.assets(); err != nil {
217 return nil, fmt.Errorf("gallery: %w", err)
218 }
219 if err := s.schemeMenu(); err != nil {
220 return nil, fmt.Errorf("gallery: %w", err)
221 }
222 s.families = groupFamilies(m.Examples)
223 if err := s.explain(); err != nil {
224 return nil, fmt.Errorf("gallery: %w", err)
225 }
226 if err := s.readIcons(); err != nil {
227 return nil, fmt.Errorf("gallery: %w", err)
228 }
229 if err := s.readTokens(); err != nil {
230 return nil, fmt.Errorf("gallery: %w", err)
231 }
232 if s.weighed, err = s.weighRelease(); err != nil {
233 return nil, fmt.Errorf("gallery: %w", err)
234 }
235 if s.scene, err = s.stageScene(); err != nil {
236 return nil, fmt.Errorf("gallery: %w", err)
237 }
238 return s, nil
239}
240
241// themes are the theme names, the default first.
242func (s *site) themes() []string { return fiesta.ThemeNames() }
243
244// put adds one file to the tree and refuses a second writer: a name
245// one page took from another would replace a document with no test
246// noticing.
247func (s *site) put(name string, body []byte) error {
248 if _, dup := s.out[name]; dup {
249 return fmt.Errorf("gallery: %s is produced twice", name)
250 }
251 s.out[name] = body
252 return nil
253}
254
255// url is the absolute URL of one file of the tree.
256func (s *site) url(rel string) string { return s.mount + "/" + rel }
257
258// The gallery's own assets, declared where their bytes are: the
259// stylesheet and the script each the join of its two halves, and the
260// frame's script.
261func init() {
262 declareAsset("gallery.css", func(*site) ([]byte, error) { return joined(galleryCSS, viewCSS), nil })
263 declareAsset("gallery.js", func(*site) ([]byte, error) { return joined(galleryJS, viewJS), nil })
264 declareAsset("frame.js", func(*site) ([]byte, error) { return frameJS, nil })
265}
266
267// assets writes every declared asset, hashed and plain, under assets/:
268// exactly the names assetNames lists, nothing else.
269func (s *site) assets() error {
270 names, err := s.assetNames()
271 if err != nil {
272 return err
273 }
274 for _, name := range names {
275 b, err := s.assetBody(name)
276 if err != nil {
277 return fmt.Errorf("%s: %w", name, err)
278 }
279 if err := s.putAsset(name, b); err != nil {
280 return err
281 }
282 }
283 for _, theme := range s.themes() {
284 if _, ok := s.hashed["css/themes/"+theme+".css"]; !ok {
285 return fmt.Errorf("no css/themes/%s.css in the source", theme)
286 }
287 }
288 return nil
289}
290
291// joined is two source files as one, a newline between them.
292func joined(a, b []byte) []byte {
293 out := make([]byte, 0, len(a)+1+len(b))
294 out = append(out, a...)
295 out = append(out, '\n')
296 return append(out, b...)
297}
298
299var (
300 htmlTag = regexp.MustCompile(`<html\b[^>]*>`)
301 schemeStore = regexp.MustCompile(`\sdata-rst-scheme-store="[^"]*"`)
302 scriptSrc = regexp.MustCompile(`<script[^>]*\ssrc="\.\./\.\./js/([^"]+)"`)
303 ariaCurrent = regexp.MustCompile(`\saria-current="true"`)
304 formTag = regexp.MustCompile(`(?s)<form\b.*?</form>`)
305)
306
307// schemeMenu reads the header's scheme menu from the scheme-menu
308// example's standalone state: the [rst-scheme] element of its markup,
309// whatever row holds it, the scripts its head asks for, and the
310// attribute on its <html> that tells fiesta.js to remember the choice,
311// which every gallery page carries too.
312//
313// The state ships with one scheme marked current; the gallery starts
314// on System, with no scheme stored, so the mark is moved to the System
315// button. Nothing else in the markup changes.
316func (s *site) schemeMenu() error {
317 const name = "scheme-menu"
318 state, ok := s.m.Default(name)
319 if !ok {
320 return fmt.Errorf("the manifest has no %s example", name)
321 }
322 doc, err := example.Document(s.src, name, state)
323 if err != nil {
324 return err
325 }
326 tag := htmlTag.FindString(doc)
327 store := schemeStore.FindString(tag)
328 if store == "" {
329 return fmt.Errorf("%s/%s carries no data-rst-scheme-store on its <html>", name, state)
330 }
331 s.htmlAttrs = template.HTMLAttr(strings.TrimSpace(store))
332 for _, m := range scriptSrc.FindAllStringSubmatch(doc, -1) {
333 s.schemeScripts = append(s.schemeScripts, m[1])
334 }
335 body, err := example.Markup(s.src, name, state)
336 if err != nil {
337 return err
338 }
339 // The menu alone: a state that shows it in a row (the column's
340 // footer) would otherwise put that row inside the gallery's bar.
341 markup, err := example.Element(body, "rst-scheme")
342 if err != nil {
343 return fmt.Errorf("%s/%s: %w", name, state, err)
344 }
345 if !ariaCurrent.MatchString(markup) {
346 return fmt.Errorf("%s/%s marks no scheme current", name, state)
347 }
348 markup = ariaCurrent.ReplaceAllString(markup, "")
349 marked := false
350 markup = formTag.ReplaceAllStringFunc(markup, func(form string) string {
351 if marked || !strings.Contains(form, `value="system"`) {
352 return form
353 }
354 marked = true
355 return strings.Replace(form, `<button type="submit"`, `<button type="submit" aria-current="true"`, 1)
356 })
357 if !marked {
358 return fmt.Errorf("%s/%s has no form for the system scheme", name, state)
359 }
360 s.scheme = template.HTML(markup)
361 return nil
362}
363
364// schemeIn is the scheme menu in the page's language: the lifted
365// markup through localMarkup as the scheme-menu example's sample, made
366// once per locale.
367func (s *site) schemeIn(locale string) template.HTML {
368 if m, ok := s.schemes[locale]; ok {
369 return m
370 }
371 if s.schemes == nil {
372 s.schemes = map[string]template.HTML{}
373 }
374 m := template.HTML(localMarkup(locale, "scheme-menu", string(s.scheme)))
375 s.schemes[locale] = m
376 return m
377}
378
379// groupFamilies lists the families in familyOrder, each with its
380// examples in manifest order. A family the order does not name comes
381// last, in the order the manifest first names it, and explain refuses
382// it by name: the gallery's reading order is its own, not the
383// manifest's.
384func groupFamilies(entries []example.Entry) []family {
385 var out []family
386 at := map[string]int{}
387 for _, e := range entries {
388 i, ok := at[e.Family]
389 if !ok {
390 i = len(out)
391 at[e.Family] = i
392 out = append(out, family{Key: e.Family})
393 }
394 out[i].Examples = append(out[i].Examples, e)
395 }
396 rank := func(key string) int {
397 for i, k := range familyOrder {
398 if k == key {
399 return i
400 }
401 }
402 return len(familyOrder)
403 }
404 sort.SliceStable(out, func(i, j int) bool { return rank(out[i].Key) < rank(out[j].Key) })
405 return out
406}
407
408// explain gives each family its heading and blurb from the prose, and
409// puts each idiom rule at the head of the family that holds the idiom
410// it names, which is the primitives. A family the prose has no heading
411// for is an error: the page would have no name.
412func (s *site) explain() error {
413 at := map[string]int{}
414 for i := range s.families {
415 f := &s.families[i]
416 if !inFamilyOrder(f.Key) {
417 return fmt.Errorf("the manifest has a %s family, which familyOrder does not place", f.Key)
418 }
419 fp, ok := s.prose.Families[f.Key]
420 if !ok || fp.Title == "" {
421 return fmt.Errorf("%s has no title for the %s family", proseFile, f.Key)
422 }
423 f.Title, f.Blurb = fp.Title, fp.Blurb
424 at[f.Key] = i
425 }
426 for _, rule := range s.prose.Pages.Idioms {
427 name, _, ok := s.m.Idiom(rule.Idiom)
428 if !ok {
429 return fmt.Errorf("%s quotes a rule for the %s idiom, which the manifest does not have", proseFile, rule.Idiom)
430 }
431 e, _ := s.m.Entry(name)
432 f := &s.families[at[e.Family]]
433 f.Rules = append(f.Rules, rule)
434 }
435 return nil
436}
437
438// readIcons lists every icon in the source, by slug.
439func (s *site) readIcons() error {
440 entries, err := fs.ReadDir(s.src, "icons")
441 if err != nil {
442 return err
443 }
444 for _, e := range entries {
445 if e.IsDir() || !strings.HasSuffix(e.Name(), ".svg") {
446 continue
447 }
448 b, err := fs.ReadFile(s.src, "icons/"+e.Name())
449 if err != nil {
450 return err
451 }
452 s.icons = append(s.icons, icon{Slug: strings.TrimSuffix(e.Name(), ".svg"), Markup: template.HTML(b)})
453 }
454 sort.Slice(s.icons, func(i, j int) bool { return s.icons[i].Slug < s.icons[j].Slug })
455 if len(s.icons) == 0 {
456 return errors.New("no icons in the source")
457 }
458 return nil
459}
460
461// page is what every gallery page's template is executed with: every
462// field any page renders, so the template and the bodies read one
463// value.
464type page struct {
465 s *site
466 // Theme and Locale place the page in the tree; Lang and Dir are
467 // what its <html> says.
468 Theme, Locale, Lang, Dir string
469 // File is the page's name within its directory; Kind the page kind
470 // it is, or the family of the example it is; Name its own name for
471 // its <title> and <h1>; TitleID the id the <h1> carries, if any.
472 File, Kind, Name, TitleID string
473 // Home is the Overview's address and Up a content page's way back
474 // on a phone: the Overview with this page's row as the fragment.
475 Home, Up string
476 // Body is the page's own markup.
477 Body template.HTML
478 // Prev and Next are the pair at the foot of the page; either is nil
479 // at the ends of the sequence.
480 Prev, Next *link
481 // Routes is the Overview's way into every other page.
482 Routes []route
483 // Nav is the rail.
484 Nav []navSection
485 // Rows and Demos are the phone index, the Overview's alone.
486 Rows []indexRow
487 Demos navSection
488 // Bar is the theme, scheme and language controls.
489 Bar controls
490}
491
492// Title is the document's <title>.
493func (p page) Title() string {
494 return p.s.say(p.Locale, "gallery.title", "page", p.Name, "theme", p.Theme)
495}
496
497// HTMLAttrs are the attributes every gallery page's <html> carries
498// beyond lang and dir.
499func (p page) HTMLAttrs() template.HTMLAttr { return p.s.htmlAttrs }
500
501// Icon is one of Fiesta's icons, inline, for the gallery's own
502// furniture.
503func (p page) Icon(slug string) template.HTML { return p.s.iconMarkup(slug) }
504
505// Scripts are the scripts every gallery page loads, for the scheme
506// menu.
507func (p page) Scripts() []string { return p.s.schemeScripts }
508
509// Href is the URL of file in this page's theme and locale.
510func (p page) Href(file string) string { return p.s.href(p.Theme, p.Locale, file) }
511
512// Mono says whether the page's heading is an identifier, drawn in the
513// mono face: an example's page is, named by the example.
514func (p page) Mono() bool {
515 _, ok := p.s.m.Entry(strings.TrimSuffix(p.File, ".html"))
516 return ok
517}
518
519// link is one entry of a switcher, or one step of the sequence.
520type link struct {
521 Href string
522 Label string
523 Current bool
524}
525
526// newPage is a page of one theme and locale with everything the
527// template reads filled in but its body.
528func (s *site) newPage(theme, locale, kind, file, name string) page {
529 p := page{s: s, Theme: theme, Locale: locale, Lang: locale, Dir: Dir(locale), File: file, Kind: kind, Name: name}
530 p.Home = s.href(theme, locale, "index.html")
531 p.Prev, p.Next = s.steps(theme, locale, file)
532 p.Routes = s.routes(theme, locale, kind)
533 p.Nav = s.galleryNav(p)
534 p.Bar = s.controls(p)
535 return p
536}
537
538// theme renders one theme in one locale: a page per page kind, then a
539// page and its frame documents per example, then the documents that
540// are neither. It is the one loop that writes to the tree; everything
541// else hands its output back through the maps it returns.
542func (s *site) theme(theme, locale string) error {
543 dir := theme + "/" + locale + "/"
544 for _, pk := range s.pageKinds() {
545 if reserved(pk.File) {
546 return fmt.Errorf("the %s page kind takes %s, which is reserved for an extra document", pk.Kind, pk.File)
547 }
548 p := s.newPage(theme, locale, pk.Kind, pk.File, s.titleOf(locale, pk))
549 p.TitleID = pk.Kind
550 if pk.Kind != "overview" {
551 p.Up = p.Home + "#nav-" + pk.Kind
552 }
553 s.phoneIndex(&p)
554 body, err := pk.Body(p)
555 if err != nil {
556 return fmt.Errorf("%s: %w", pk.File, err)
557 }
558 p.Body = body
559 if err := s.write(dir, p); err != nil {
560 return err
561 }
562 }
563 for _, e := range s.m.Examples {
564 if reserved(e.Name + ".html") {
565 return fmt.Errorf("the %s example takes %s.html, which is reserved for an extra document", e.Name, e.Name)
566 }
567 p := s.newPage(theme, locale, e.Family, e.Name+".html", e.Name)
568 p.Up = p.Home + "#nav-" + e.Family
569 body, frames, err := s.example(p, e)
570 if err != nil {
571 return err
572 }
573 want := s.frameFiles(e)
574 if len(frames) != len(want) {
575 return fmt.Errorf("%s: example wrote %d frame documents, frameFiles names %d", e.Name, len(frames), len(want))
576 }
577 for _, f := range want {
578 doc, ok := frames[f]
579 if !ok {
580 return fmt.Errorf("%s: frameFiles names %s, which example did not write", e.Name, f)
581 }
582 if err := s.put(dir+f, doc); err != nil {
583 return err
584 }
585 }
586 p.Body = template.HTML(body)
587 if err := s.write(dir, p); err != nil {
588 return err
589 }
590 }
591 extra, err := s.extraDocuments(theme, locale)
592 if err != nil {
593 return err
594 }
595 names := s.extraDocumentNames()
596 if len(extra) != len(names) {
597 return fmt.Errorf("extraDocuments wrote %d documents, extraDocumentNames lists %d", len(extra), len(names))
598 }
599 for _, n := range names {
600 doc, ok := extra[n]
601 if !ok {
602 return fmt.Errorf("extraDocumentNames lists %s, which extraDocuments did not write", n)
603 }
604 if err := s.put(dir+n, doc); err != nil {
605 return err
606 }
607 }
608 return nil
609}
610
611// reservedDocuments are the file names, under a theme and locale
612// directory, that the documents extraDocuments will write are to take:
613// the demo application's four and the modal route's. They are reserved
614// here so no page kind or example page can take one first: the modal
615// example's own page is modal.html, so the standalone modal route is
616// demo-modal.html beside the demo's documents. theme checks every page
617// it writes against this list.
618var reservedDocuments = []string{"demo.html", "demo-dashboard.html", "demo-requests.html", "demo-request.html", "demo-modal.html"}
619
620// reserved says whether file is one of reservedDocuments.
621func reserved(file string) bool {
622 for _, r := range reservedDocuments {
623 if r == file {
624 return true
625 }
626 }
627 return false
628}
629
630// extraDocuments is every document of one theme and locale that is
631// neither a page kind's, an example's nor a frame's, keyed by path
632// under the directory: the demo application's four documents and the
633// modal route, under their names from reservedDocuments.
634func (s *site) extraDocuments(theme, locale string) (map[string][]byte, error) {
635 out, err := s.demo(theme, locale)
636 if err != nil {
637 return nil, fmt.Errorf("the demo application: %w", err)
638 }
639 if out["demo-modal.html"], err = s.modal(theme, locale); err != nil {
640 return nil, fmt.Errorf("the modal route: %w", err)
641 }
642 return out, nil
643}
644
645// extraDocumentNames lists the paths extraDocuments writes, so the
646// tree gate expects them; each is one of reservedDocuments.
647func (s *site) extraDocumentNames() []string {
648 var out []string
649 for _, view := range demoViews {
650 out = append(out, demoFile(view))
651 }
652 return append(out, "demo-modal.html")
653}
654
655// write executes the page template around p and puts the result at
656// p.File within dir.
657func (s *site) write(dir string, p page) error {
658 t, err := s.templates(p.Locale)
659 if err != nil {
660 return err
661 }
662 var b bytes.Buffer
663 if err := t.ExecuteTemplate(&b, "ds-page", p); err != nil {
664 return err
665 }
666 return s.put(dir+p.File, b.Bytes())
667}
668