| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "embed" |
| 6 | "fmt" |
| 7 | "html/template" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | |
| 11 | "amadan.net/rastrillo/fiesta/example" |
| 12 | ) |
| 13 | |
| 14 | // The demo application. The question a first-time reader arrives with |
| 15 | // is "what does an app built with this look like?", and every other |
| 16 | // page of the gallery answers a smaller one, so the Overview frames an |
| 17 | // application before it says a word about any of them: a dashboard, a |
| 18 | // list of requests and one request open. |
| 19 | // |
| 20 | // Four documents, one per view, each a real page, the way the app it |
| 21 | // stands for would serve them: demo.html is the index (on a phone the |
| 22 | // rail is the whole page; on a desktop the dashboard sits beside it), |
| 23 | // and demo-dashboard.html, demo-requests.html and demo-request.html are |
| 24 | // content pages whose back control returns to demo.html#nav-<row>. No |
| 25 | // script is needed to move around it, which the no-script drive holds. |
| 26 | // |
| 27 | // Rastrillo builds the four from its sidebar layout template; Fiesta's |
| 28 | // shells are the layout examples' documents, so these are those |
| 29 | // documents with the content slotted in (example.Slot) and the chrome |
| 30 | // rewritten for the demo: the brand, the rail, the way up, the person, |
| 31 | // the language menu. The content of each view is Rastrillo's demo in |
| 32 | // the markup of the Fiesta example that documents each piece, in |
| 33 | // demo/*.html. The documents go through the same framing as every |
| 34 | // sample document (the gallery's assets, noindex, the scheme store, |
| 35 | // frame.js first), with the layouts' own link rewrite skipped: the demo |
| 36 | // marks every live link of its own data-gallery-nav. |
| 37 | |
| 38 | //go:embed demo/*.html |
| 39 | var demoSource embed.FS |
| 40 | |
| 41 | // demoTemplates are the three views' content, by file name. |
| 42 | var demoTemplates = template.Must(template.ParseFS(demoSource, "demo/*.html")) |
| 43 | |
| 44 | // demoViews are the demo's four documents, the index first. |
| 45 | var demoViews = []string{"index", "dashboard", "requests", "request"} |
| 46 | |
| 47 | // demoFile is one view's file name under the theme and locale |
| 48 | // directory: demo.html for the index, demo-<view>.html for the rest. |
| 49 | // Each is one of reservedDocuments. |
| 50 | func demoFile(view string) string { |
| 51 | if view == "index" { |
| 52 | return "demo.html" |
| 53 | } |
| 54 | return "demo-" + view + ".html" |
| 55 | } |
| 56 | |
| 57 | // demoHref is the demo application's address: its index. |
| 58 | func (s *site) demoHref(theme, locale string) string { |
| 59 | return s.href(theme, locale, demoFile("index")) |
| 60 | } |
| 61 | |
| 62 | // demoPageHref is one content page of the demo application. |
| 63 | func (s *site) demoPageHref(theme, locale, view string) string { |
| 64 | return s.href(theme, locale, demoFile(view)) |
| 65 | } |
| 66 | |
| 67 | // demoData is what a view's content executes against: the three pages |
| 68 | // it links, the Overview it leaves for, and the two sentences of the |
| 69 | // gallery's own it carries (the way back, and the request view's |
| 70 | // closing callout). |
| 71 | type demoData struct { |
| 72 | Dashboard, Requests, Request string |
| 73 | Overview, Back, Callout string |
| 74 | } |
| 75 | |
| 76 | // demoBackHeld and demoCalloutHeld stand in a view's content for the |
| 77 | // two sentences of the gallery's own it carries while the content goes |
| 78 | // through localMarkup: they are in the page's language already, from |
| 79 | // say, and are not sample strings to look up. Neither is a letter, so |
| 80 | // the walker leaves them as written; demo puts the sentences in after. |
| 81 | const ( |
| 82 | demoBackHeld = "\ue001" |
| 83 | demoCalloutHeld = "\ue002" |
| 84 | ) |
| 85 | |
| 86 | // demoContent is one view's content executed against data, as the |
| 87 | // layout's slot takes it. |
| 88 | func demoContent(view string, data demoData) (string, error) { |
| 89 | var b bytes.Buffer |
| 90 | if err := demoTemplates.ExecuteTemplate(&b, view+".html", data); err != nil { |
| 91 | return "", err |
| 92 | } |
| 93 | return strings.TrimSpace(b.String()), nil |
| 94 | } |
| 95 | |
| 96 | // demoRail is the demo's rail: the two rows, the view each opens, and |
| 97 | // its label, a sample string the rail shows in the page's language. |
| 98 | var demoRail = []struct{ id, view, label string }{ |
| 99 | {"nav-dashboard", "dashboard", "Dashboard"}, |
| 100 | {"nav-requests", "requests", "Requests"}, |
| 101 | } |
| 102 | |
| 103 | // demoCSS is the demo's own stylesheet: one margin, which the stat band |
| 104 | // needs above the box under it and the layout's column does not give. |
| 105 | const demoCSS = `<style>[rst-stats] { margin-block-end: var(--rst-sp-5); }</style>` |
| 106 | |
| 107 | var ( |
| 108 | // The layout's scripts the demo has no control for: no form of its |
| 109 | // own sends (busy), and no field is enhanced (select, calendar, |
| 110 | // datetime). |
| 111 | demoUnused = regexp.MustCompile(`<script defer src="\.\./\.\./js/(busy|select|calendar|datetime)\.js"></script>\n?`) |
| 112 | titleTag = regexp.MustCompile(`<title>[^<]*</title>`) |
| 113 | // The brand, both copies, with its words. |
| 114 | brandLink = regexp.MustCompile(`<a rst-shell-brand\b[^>]*>[^<]*</a>`) |
| 115 | // The page's title in the shell's heading, shown when the brand is |
| 116 | // blank. |
| 117 | shellTitle = regexp.MustCompile(`(<h1 rst-shell-title><span>.*?</span><span>)[^<]*(</span></h1>)`) |
| 118 | // The way up, the back strip's one link. |
| 119 | backTag = regexp.MustCompile(`<div rst-shell-back><a\b[^>]*>`) |
| 120 | ) |
| 121 | |
| 122 | // demoPerson is the layout's person, as each of its two copies writes |
| 123 | // it, and the demo's in its place. |
| 124 | const ( |
| 125 | layoutPerson = `<span rst-person-av aria-hidden="true">G</span><span rst-person-name>Grace Hopper</span><span rst-person-email>grace@example.com</span>` |
| 126 | demoPerson = `<span rst-person-av aria-hidden="true">A</span><span rst-person-name>Ada Lovelace</span><span rst-person-email>ada@example.com</span>` |
| 127 | ) |
| 128 | |
| 129 | // demo builds one theme and locale's copy of the demo application: its |
| 130 | // four documents, keyed by file name. The layout's body and each view's |
| 131 | // content are put in the page's language before the content is slotted |
| 132 | // in, so what demoChrome writes afterwards, the gallery's own words and |
| 133 | // the brand, the person and the rail, is never looked up as a sample. |
| 134 | func (s *site) demo(theme, locale string) (map[string][]byte, error) { |
| 135 | data := demoData{ |
| 136 | Dashboard: s.demoPageHref(theme, locale, "dashboard"), |
| 137 | Requests: s.demoPageHref(theme, locale, "requests"), |
| 138 | Request: s.demoPageHref(theme, locale, "request"), |
| 139 | Overview: s.href(theme, locale, s.fileOf("overview")), |
| 140 | Back: s.say(locale, "gallery.back"), |
| 141 | Callout: s.say(locale, "gallery.demo.callout.body"), |
| 142 | } |
| 143 | out := make(map[string][]byte, len(demoViews)) |
| 144 | for _, view := range demoViews { |
| 145 | // The index is the shell's index document, the rest its page |
| 146 | // document; the index shows the dashboard, which is what a |
| 147 | // desktop sees beside the rail. |
| 148 | layout, content := "layout-sidebar-page", view |
| 149 | if view == "index" { |
| 150 | layout, content = "layout-sidebar", "dashboard" |
| 151 | } |
| 152 | e, ok := s.m.Entry(layout) |
| 153 | if !ok { |
| 154 | return nil, fmt.Errorf("the manifest has no %s to build the demo application in", layout) |
| 155 | } |
| 156 | doc, err := example.Document(s.src, layout, e.Default) |
| 157 | if err != nil { |
| 158 | return nil, err |
| 159 | } |
| 160 | if doc, err = localBody(locale, layout, doc); err != nil { |
| 161 | return nil, fmt.Errorf("%s: %w", layout, err) |
| 162 | } |
| 163 | held := data |
| 164 | held.Back, held.Callout = demoBackHeld, demoCalloutHeld |
| 165 | body, err := demoContent(content, held) |
| 166 | if err != nil { |
| 167 | return nil, err |
| 168 | } |
| 169 | body = localMarkup(locale, "demo", body) |
| 170 | body = strings.NewReplacer(demoBackHeld, template.HTMLEscapeString(data.Back), demoCalloutHeld, template.HTMLEscapeString(data.Callout)).Replace(body) |
| 171 | if doc, err = example.Slot(doc, "\n"+body+"\n"); err != nil { |
| 172 | return nil, fmt.Errorf("%s: %w", layout, err) |
| 173 | } |
| 174 | if doc, err = s.demoChrome(doc, theme, locale, view); err != nil { |
| 175 | return nil, fmt.Errorf("%s from %s: %w", demoFile(view), layout, err) |
| 176 | } |
| 177 | framed, err := s.framed(doc, theme, locale, e, nil) |
| 178 | if err != nil { |
| 179 | return nil, fmt.Errorf("%s: %w", demoFile(view), err) |
| 180 | } |
| 181 | out[demoFile(view)] = []byte(framed) |
| 182 | } |
| 183 | return out, nil |
| 184 | } |
| 185 | |
| 186 | // demoChrome rewrites the layout's chrome for one view of the demo: the |
| 187 | // title, the scripts and the demo's one rule in the head; the brand |
| 188 | // Harbour going to the demo's index; the page's name in the shell's |
| 189 | // heading; a rail of Dashboard and Requests with the view's marked; the |
| 190 | // way up to the row just left (the index's own on the index); Ada |
| 191 | // Lovelace for the layout's person; and the language menu going to this |
| 192 | // view in each locale. Every link it writes is marked data-gallery-nav. |
| 193 | // A piece of chrome the layout no longer carries is an error, so a |
| 194 | // changed layout fails the render rather than shipping a demo with the |
| 195 | // layout's own brand in it. |
| 196 | func (s *site) demoChrome(doc, theme, locale, view string) (string, error) { |
| 197 | app := s.say(locale, "gallery.demos.app") |
| 198 | home := s.demoHref(theme, locale) |
| 199 | up, current := home+"#nav-requests", "nav-requests" |
| 200 | switch view { |
| 201 | case "index": |
| 202 | up, current = home, "nav-dashboard" |
| 203 | case "dashboard": |
| 204 | up, current = home+"#nav-dashboard", "nav-dashboard" |
| 205 | } |
| 206 | var nav strings.Builder |
| 207 | nav.WriteString("<nav rst-shell-nav>") |
| 208 | for _, item := range demoRail { |
| 209 | nav.WriteString(`<a id="` + item.id + `" data-gallery-nav href="` + s.demoPageHref(theme, locale, item.view) + `"`) |
| 210 | if item.id == current { |
| 211 | nav.WriteString(` aria-current="page"`) |
| 212 | } |
| 213 | nav.WriteString(">" + localMarkup(locale, "demo", item.label) + "</a>") |
| 214 | } |
| 215 | nav.WriteString("</nav>") |
| 216 | |
| 217 | steps := []struct { |
| 218 | what string |
| 219 | re *regexp.Regexp |
| 220 | with func(string) string |
| 221 | }{ |
| 222 | {"the title", titleTag, func(string) string { |
| 223 | return "<title>" + template.HTMLEscapeString(s.say(locale, "gallery.title", "page", app, "theme", theme)) + "</title>" |
| 224 | }}, |
| 225 | {"the brand", brandLink, func(string) string { |
| 226 | return `<a rst-shell-brand data-gallery-nav href="` + home + `">Harbour</a>` |
| 227 | }}, |
| 228 | {"the shell's heading", shellTitle, func(m string) string { |
| 229 | sub := shellTitle.FindStringSubmatch(m) |
| 230 | return sub[1] + template.HTMLEscapeString(app) + sub[2] |
| 231 | }}, |
| 232 | {"the rail", shellNav, func(string) string { return nav.String() }}, |
| 233 | {"the way up", backTag, func(m string) string { |
| 234 | return `<div rst-shell-back>` + liveLink(strings.TrimPrefix(m, `<div rst-shell-back>`), up) |
| 235 | }}, |
| 236 | } |
| 237 | for _, step := range steps { |
| 238 | if !step.re.MatchString(doc) { |
| 239 | return "", fmt.Errorf("the layout has no %s to rewrite", step.what) |
| 240 | } |
| 241 | doc = step.re.ReplaceAllStringFunc(doc, step.with) |
| 242 | } |
| 243 | if !strings.Contains(doc, layoutPerson) { |
| 244 | return "", fmt.Errorf("the layout has no person to replace") |
| 245 | } |
| 246 | doc = strings.ReplaceAll(doc, layoutPerson, demoPerson) |
| 247 | if !localeMenu.MatchString(doc) { |
| 248 | return "", fmt.Errorf("the layout has no language menu") |
| 249 | } |
| 250 | var err error |
| 251 | if doc, err = s.localeLinks(doc, locale, func(l string) string { return s.demoPageHref(theme, l, view) }); err != nil { |
| 252 | return "", err |
| 253 | } |
| 254 | doc = demoUnused.ReplaceAllString(doc, "") |
| 255 | shut := strings.LastIndex(doc, "</head>") |
| 256 | if shut < 0 { |
| 257 | return "", fmt.Errorf("no </head> for the demo's rule") |
| 258 | } |
| 259 | return doc[:shut] + demoCSS + "\n" + doc[shut:], nil |
| 260 | } |
| 261 | |
| 262 | // demoView is the widget the Overview frames the demo application in: |
| 263 | // the preview widget every example uses, at the page width, loading the |
| 264 | // real index rather than a copy of it, and with no HTML tab: an |
| 265 | // application is not a snippet to paste. |
| 266 | func (s *site) demoView(p page) previewView { |
| 267 | return previewView{ |
| 268 | Group: "demo-app-0", |
| 269 | Style: previewStyle(p.Locale, "demo"), |
| 270 | Class: " ds-view--page", |
| 271 | Src: s.demoHref(p.Theme, p.Locale), |
| 272 | Title: s.say(p.Locale, "gallery.demos.app"), |
| 273 | } |
| 274 | } |
| 275 | |