// Package gallery renders Fiesta's own gallery: the design system in // every theme and twelve languages, with an Overview, a Tokens page, // an Icons page, a page per family and a page per example, every // state of every example in a frame of its own with its markup beside // it. It is a development and test artefact: the accessibility drives // scan what it renders, fiesta gallery writes it out for looking at, // and internal/www mounts it on the site. // // Render returns the whole tree in memory, keyed by path relative to // the tree root, and it is deterministic: the same tree from the same // source every time, so two renders can be compared. // // index.html day/en/index.html again // //index.html the Overview // //tokens.html the theme's palette and // the structure every // theme shares // //icons.html every icon // //.html one page per family // //.html one page per example // //frames//.html // one document per state, // the example's own with // the gallery's assets // and dead links // //demo.html, demo-dashboard.html, demo-requests.html, // demo-request.html the demo application // //demo-modal.html the modal route // assets/.., assets/. // every asset hashed and // plain: css/, js/, the // examples' pictures under // images/, and gallery.css, // gallery.js and frame.js // // Two kinds of URL. The gallery owns every stylesheet, script, image // and frame URL and every link marked data-gallery-nav: each is an // absolute path under the mount, and the tests check every one names a // file the render wrote. A sample's own links and forms are the // example's: the HTML tab shows them as written, and the frame // documents rewrite each href and action to "#", with frame.js // cancelling every submit, so a click or a submit inside a frame // requests nothing. package gallery import ( "bytes" _ "embed" "errors" "fmt" "html/template" "io/fs" "path" "regexp" "sort" "strings" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/tokens" ) // Options is what a caller decides about the tree. type Options struct { // Mount is the URL path the tree is served from; "" means "/". // Every URL in the output is an absolute path under it, so a tree // rendered for one mount cannot be served at another. Mount string } // The gallery's own furniture, embedded beside this file so each is // edited as what it is. None is part of the design system: no page // but the gallery's own links them. The stylesheet and the script are // each two source files, the chrome's and the preview widget's, joined // at render into the one gallery.css and gallery.js every page loads. // //go:embed gallery.css var galleryCSS []byte //go:embed view.css var viewCSS []byte //go:embed gallery.js var galleryJS []byte //go:embed view.js var viewJS []byte //go:embed frame.js var frameJS []byte // cleanMount normalises the mount to "" for the root or a rooted, // cleaned path with no slash at the end, so URLs are mount + "/" + rel // in either case. func cleanMount(mount string) string { if mount == "" { return "" } if !strings.HasPrefix(mount, "/") { mount = "/" + mount } clean := path.Clean(mount) if clean == "/" { return "" } return clean } // site is one render in progress: the source, the manifest, the mount // and the output. type site struct { src fs.FS m *example.Manifest mount string out map[string][]byte // scheme is the header's scheme menu: the scheme-menu example's // standalone state, whose attributes every gallery page // takes as well, so fiesta.js stores the choice where that state // says to. scheme template.HTML // schemes is the scheme menu in each locale it has been asked for, // through localMarkup, as the bar of every page in that locale // carries it. schemes map[string]template.HTML htmlAttrs template.HTMLAttr // schemeScripts are the scripts that state's head asks for, by // file name. schemeScripts []string // prose is the design system's explanations, from examples/prose.json. prose *prose families []family icons []icon // tokens is each theme's token groups, palette then structure. tokens map[string][]tokens.Group // hashed maps each asset's plain name under assets/ to the hashed // name it is linked by. hashed map[string]string // tmpl is the parsed templates, one tree per locale. tmpl map[string]*template.Template // weighed is the release build of the source, each file weighed: // what Getting started lists and its rail links, built once here // rather than on each of the thousands of pages whose rail asks. weighed release // scene is the stage's scene, lifted once from the layout-stage // example for every staged sign-in frame. scene string } // family is one family of examples: its heading and the sentence // under it, the rules quoted at its head, and its examples. type family struct { Key string Title string Blurb string Rules []idiomRule Examples []example.Entry } type icon struct { Slug string Markup template.HTML } // Render builds the whole tree in memory: path relative to the tree // root to file content. src is fiesta.FS() or any tree laid out the // same way. func Render(src fs.FS, opts Options) (map[string][]byte, error) { s, err := newSite(src, opts.Mount) if err != nil { return nil, err } for _, theme := range s.themes() { for _, locale := range Locales { if err := s.theme(theme, locale); err != nil { return nil, fmt.Errorf("gallery: %s/%s: %w", theme, locale, err) } } } // The tree root is the default theme's English Overview again, // copied rather than aliased so callers get one slice per path. root, ok := s.out[s.themes()[0]+"/en/index.html"] if !ok { return nil, errors.New("gallery: no index to serve as the tree root") } if err := s.put("index.html", append([]byte(nil), root...)); err != nil { return nil, err } return s.out, nil } // newSite reads everything a render needs before a page is written: // the manifest, the prose, the catalogs, the assets, the scheme menu, // the families, the icons, the tokens, the weighed release and the // stage's scene. func newSite(src fs.FS, mount string) (*site, error) { m, err := example.Load(src) if err != nil { return nil, fmt.Errorf("gallery: %w", err) } s := &site{src: src, m: m, mount: cleanMount(mount), out: map[string][]byte{}, hashed: map[string]string{}} if s.prose, err = loadProse(src); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if _, err := catalogs(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if _, err := sampleCatalogs(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if _, err := moneyTable(); err != nil { return nil, fmt.Errorf("gallery: i18n/money.json: %w", err) } if _, err := controlStrings(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if err := s.assets(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if err := s.schemeMenu(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } s.families = groupFamilies(m.Examples) if err := s.explain(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if err := s.readIcons(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if err := s.readTokens(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if s.weighed, err = s.weighRelease(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } if s.scene, err = s.stageScene(); err != nil { return nil, fmt.Errorf("gallery: %w", err) } return s, nil } // themes are the theme names, the default first. func (s *site) themes() []string { return fiesta.ThemeNames() } // put adds one file to the tree and refuses a second writer: a name // one page took from another would replace a document with no test // noticing. func (s *site) put(name string, body []byte) error { if _, dup := s.out[name]; dup { return fmt.Errorf("gallery: %s is produced twice", name) } s.out[name] = body return nil } // url is the absolute URL of one file of the tree. func (s *site) url(rel string) string { return s.mount + "/" + rel } // The gallery's own assets, declared where their bytes are: the // stylesheet and the script each the join of its two halves, and the // frame's script. func init() { declareAsset("gallery.css", func(*site) ([]byte, error) { return joined(galleryCSS, viewCSS), nil }) declareAsset("gallery.js", func(*site) ([]byte, error) { return joined(galleryJS, viewJS), nil }) declareAsset("frame.js", func(*site) ([]byte, error) { return frameJS, nil }) } // assets writes every declared asset, hashed and plain, under assets/: // exactly the names assetNames lists, nothing else. func (s *site) assets() error { names, err := s.assetNames() if err != nil { return err } for _, name := range names { b, err := s.assetBody(name) if err != nil { return fmt.Errorf("%s: %w", name, err) } if err := s.putAsset(name, b); err != nil { return err } } for _, theme := range s.themes() { if _, ok := s.hashed["css/themes/"+theme+".css"]; !ok { return fmt.Errorf("no css/themes/%s.css in the source", theme) } } return nil } // joined is two source files as one, a newline between them. func joined(a, b []byte) []byte { out := make([]byte, 0, len(a)+1+len(b)) out = append(out, a...) out = append(out, '\n') return append(out, b...) } var ( htmlTag = regexp.MustCompile(`]*>`) schemeStore = regexp.MustCompile(`\sdata-rst-scheme-store="[^"]*"`) scriptSrc = regexp.MustCompile(`]*\ssrc="\.\./\.\./js/([^"]+)"`) ariaCurrent = regexp.MustCompile(`\saria-current="true"`) formTag = regexp.MustCompile(`(?s)`) ) // schemeMenu reads the header's scheme menu from the scheme-menu // example's standalone state: the [rst-scheme] element of its markup, // whatever row holds it, the scripts its head asks for, and the // attribute on its that tells fiesta.js to remember the choice, // which every gallery page carries too. // // The state ships with one scheme marked current; the gallery starts // on System, with no scheme stored, so the mark is moved to the System // button. Nothing else in the markup changes. func (s *site) schemeMenu() error { const name = "scheme-menu" state, ok := s.m.Default(name) if !ok { return fmt.Errorf("the manifest has no %s example", name) } doc, err := example.Document(s.src, name, state) if err != nil { return err } tag := htmlTag.FindString(doc) store := schemeStore.FindString(tag) if store == "" { return fmt.Errorf("%s/%s carries no data-rst-scheme-store on its ", name, state) } s.htmlAttrs = template.HTMLAttr(strings.TrimSpace(store)) for _, m := range scriptSrc.FindAllStringSubmatch(doc, -1) { s.schemeScripts = append(s.schemeScripts, m[1]) } body, err := example.Markup(s.src, name, state) if err != nil { return err } // The menu alone: a state that shows it in a row (the column's // footer) would otherwise put that row inside the gallery's bar. markup, err := example.Element(body, "rst-scheme") if err != nil { return fmt.Errorf("%s/%s: %w", name, state, err) } if !ariaCurrent.MatchString(markup) { return fmt.Errorf("%s/%s marks no scheme current", name, state) } markup = ariaCurrent.ReplaceAllString(markup, "") marked := false markup = formTag.ReplaceAllStringFunc(markup, func(form string) string { if marked || !strings.Contains(form, `value="system"`) { return form } marked = true return strings.Replace(form, `