| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | |
| 9 | "amadan.net/rastrillo/fiesta/example" |
| 10 | ) |
| 11 | |
| 12 | // The frame document: one example document made a page the gallery |
| 13 | // can frame, with the gallery's own assets, the page's theme and |
| 14 | // locale, kept out of search, padded unless it is a whole page, the |
| 15 | // sign-in card on its stage, a layout's links live where the interface |
| 16 | // says and dead everywhere else, and the scheme store on <html> so a |
| 17 | // document opened in a tab of its own remembers the reader's choice. |
| 18 | |
| 19 | var ( |
| 20 | headTag = regexp.MustCompile(`<head\b[^>]*>`) |
| 21 | firstLink = regexp.MustCompile(`<(link|script)\b`) |
| 22 | bodyTag = regexp.MustCompile(`<body\b[^>]*>`) |
| 23 | viewportMeta = regexp.MustCompile(`<meta name="viewport"[^>]*>`) |
| 24 | assetLink = regexp.MustCompile(`(href|src)="\.\./\.\./(css|js)/([^"]+)"`) |
| 25 | picture = regexp.MustCompile(`src="\.\./assets/([^"]+)"`) |
| 26 | sampleRef = regexp.MustCompile(`\b(href|action)="([^"]*)"`) |
| 27 | anyTag = regexp.MustCompile(`<[a-zA-Z][^>]*>`) |
| 28 | anchorTag = regexp.MustCompile(`<a\b[^>]*>`) |
| 29 | tagEnd = regexp.MustCompile(`\s*/?>$`) |
| 30 | ltrHTML = `<html lang="en" dir="ltr"` |
| 31 | schemeLocal = ` data-rst-scheme-store="local"` |
| 32 | |
| 33 | // The stage's scene, lifted from the layout-stage example as |
| 34 | // schemeMenu lifts the header's menu from scheme-menu. |
| 35 | stageSceneTag = regexp.MustCompile(`(?s)<div rst-stage-scene aria-hidden="true">.*?</div>`) |
| 36 | // A body whose first element is the sign-in card. |
| 37 | signinFirst = regexp.MustCompile(`^\s*<[a-zA-Z][^>]*\srst-signin[\s>=]`) |
| 38 | |
| 39 | // A layout's own regions, matched on the shipped shells' markup. |
| 40 | shellBack = regexp.MustCompile(`(?s)<div rst-shell-back>\s*<a\b[^>]*>`) |
| 41 | shellNav = regexp.MustCompile(`(?s)<nav rst-shell-nav>.*?</nav>`) |
| 42 | shellBrand = regexp.MustCompile(`<a rst-shell-brand\b[^>]*>`) |
| 43 | localeMenu = regexp.MustCompile(`(?s)<details rst-dropdown rst-locale\b.*?</details>`) |
| 44 | firstNavID = regexp.MustCompile(`<nav rst-shell-nav>\s*<a\b[^>]*\sid="([^"]+)"`) |
| 45 | langAttr = regexp.MustCompile(`\slang="([^"]+)"`) |
| 46 | currentOn = regexp.MustCompile(`\saria-current="[^"]*"`) |
| 47 | ) |
| 48 | |
| 49 | // footWords are the words of a layout's foot link, the way back to the |
| 50 | // design system, as the examples write them. |
| 51 | const footWords = "Back to the design system" |
| 52 | |
| 53 | // footLink matches a layout's foot link in locale: the layout document |
| 54 | // is in the page's language by the time its links are made live, so |
| 55 | // the link is matched by its words as the walker translated them. |
| 56 | func footLink(locale string) *regexp.Regexp { |
| 57 | return regexp.MustCompile(`<a\b[^>]*>` + regexp.QuoteMeta(localMarkup(locale, "", footWords)) + `</a>`) |
| 58 | } |
| 59 | |
| 60 | // frameStyle is the one style every frame document ends its head with. |
| 61 | // A component sample gets breathing room; a whole-page sample fills the |
| 62 | // frame, because insetting a page inside a page is not what any of them |
| 63 | // look like, and the shells' rail and the stage are 100dvh tall, so |
| 64 | // padding under one is also a scrollbar that can never be got rid of. |
| 65 | // The layout-stage example's body is itself the stage, so :is() names |
| 66 | // the body; a wrapped sign-in's stage is the body's child, so :has() |
| 67 | // names the children. A layout document is a whole page by the |
| 68 | // manifest's word, whatever its markup offers a selector: the column |
| 69 | // shell has no wrapper to name, and Rastrillo's standalone shells are |
| 70 | // rendered with no padding rule at all, so frame marks a layout's body |
| 71 | // data-gallery-document (Fiesta's own mark) and the third rule fills it. |
| 72 | const frameStyle = `<style>body { padding: 1rem; } |
| 73 | body:is([rst-stage], [rst-shell-topbar], [rst-shell-sidebar], [rst-shell-console]), body:has(> [rst-shell-topbar], > [rst-shell-sidebar], > [rst-shell-console], > [rst-backdrop], > [rst-stage]) { padding: 0; } |
| 74 | body[data-gallery-document] { padding: 0; }</style>` |
| 75 | |
| 76 | // documentMark is what a layout document's <body> carries. |
| 77 | const documentMark = ` data-gallery-document` |
| 78 | |
| 79 | // frame turns one example document into a frame document: the |
| 80 | // gallery's hashed assets in place of the relative links, the page's |
| 81 | // theme in place of the example's, frame.js first in the head, a robots |
| 82 | // meta after the viewport's, the frame style last in the head, the |
| 83 | // scheme store on <html>, the page's language and direction on <html> |
| 84 | // when it is not English, the sign-in card on its stage, a layout's |
| 85 | // body marked a whole page and its links live where the interface |
| 86 | // says, and every other href and action in the body pointed at "#". |
| 87 | // The example's title and everything else in it are kept. e is the |
| 88 | // example the document belongs to: whether it is a layout decides |
| 89 | // whether its body is a page and its links come alive. |
| 90 | func (s *site) frame(doc, theme, locale string, e example.Entry, state string) (string, error) { |
| 91 | return s.framed(doc, theme, locale, e, func(inner string) (string, error) { |
| 92 | return s.layoutLinks(inner, e, state, theme, locale) |
| 93 | }) |
| 94 | } |
| 95 | |
| 96 | // framed is frame with the layout's links left to the caller: links |
| 97 | // rewrites a layout document's body before the deadening pass, and a |
| 98 | // nil links leaves the body as written, for a document that marks its |
| 99 | // own live links data-gallery-nav (the demo application's). |
| 100 | func (s *site) framed(doc, theme, locale string, e example.Entry, links func(string) (string, error)) (string, error) { |
| 101 | head := headTag.FindStringIndex(doc) |
| 102 | body := bodyTag.FindStringIndex(doc) |
| 103 | if head == nil || body == nil || body[0] < head[1] { |
| 104 | return "", errors.New("no <head> and <body> to rewrite") |
| 105 | } |
| 106 | // frame.js goes before the first stylesheet or script of the head, |
| 107 | // after the charset and viewport metas, which stay first. |
| 108 | at := firstLink.FindStringIndex(doc[head[1]:body[0]]) |
| 109 | if at == nil { |
| 110 | return "", errors.New("no stylesheet in the head to put frame.js before") |
| 111 | } |
| 112 | at[0] += head[1] |
| 113 | top := doc[:at[0]] + `<script src="` + s.asset("frame.js") + `"></script>` + "\n" + doc[at[0]:body[0]] |
| 114 | top = assetLink.ReplaceAllStringFunc(top, func(m string) string { |
| 115 | sub := assetLink.FindStringSubmatch(m) |
| 116 | file := sub[2] + "/" + sub[3] |
| 117 | if strings.HasPrefix(file, "css/themes/") { |
| 118 | file = "css/themes/" + theme + ".css" |
| 119 | } |
| 120 | return sub[1] + `="` + s.asset(file) + `"` |
| 121 | }) |
| 122 | top = s.pictures(top) |
| 123 | if locale != "en" { |
| 124 | if !strings.Contains(top, ltrHTML) { |
| 125 | return "", fmt.Errorf("no %s to localise", ltrHTML) |
| 126 | } |
| 127 | top = strings.Replace(top, ltrHTML, `<html lang="`+locale+`" dir="`+Dir(locale)+`"`, 1) |
| 128 | } |
| 129 | // A frame is a fragment, not a page: thousands of them sit under |
| 130 | // the gallery's directories, and a search engine listing them would |
| 131 | // send a reader to a lone field with nothing around it. |
| 132 | vp := viewportMeta.FindStringIndex(top) |
| 133 | if vp == nil { |
| 134 | return "", errors.New("no viewport meta in the head to put the robots meta after") |
| 135 | } |
| 136 | top = top[:vp[1]] + "\n" + `<meta name="robots" content="noindex">` + top[vp[1]:] |
| 137 | // The scheme store, so fiesta.js in a document opened in a tab of |
| 138 | // its own remembers the choice where the gallery's pages do. The |
| 139 | // scheme-menu example's documents already say so. |
| 140 | tag := htmlTag.FindStringIndex(top) |
| 141 | if tag == nil { |
| 142 | return "", errors.New("no <html> to put the scheme store on") |
| 143 | } |
| 144 | if !schemeStore.MatchString(top[tag[0]:tag[1]]) { |
| 145 | top = top[:tag[1]-1] + schemeLocal + top[tag[1]-1:] |
| 146 | } |
| 147 | shut := strings.LastIndex(top, "</head>") |
| 148 | if shut < 0 { |
| 149 | return "", errors.New("no </head> to end with the frame style") |
| 150 | } |
| 151 | top = top[:shut] + frameStyle + "\n" + top[shut:] |
| 152 | open := doc[body[0]:body[1]] |
| 153 | if e.Document { |
| 154 | open = open[:len(open)-1] + documentMark + ">" |
| 155 | } |
| 156 | |
| 157 | rest := doc[body[1]:] |
| 158 | end := strings.LastIndex(rest, "</body>") |
| 159 | if end < 0 { |
| 160 | return "", errors.New("no </body>") |
| 161 | } |
| 162 | inner, tail := rest[:end], rest[end:] |
| 163 | if e.Document { |
| 164 | if links != nil { |
| 165 | var err error |
| 166 | if inner, err = links(inner); err != nil { |
| 167 | return "", err |
| 168 | } |
| 169 | } |
| 170 | } else if signinFirst.MatchString(inner) { |
| 171 | inner = "\n" + stage(s.scene, inner) + "\n" |
| 172 | } |
| 173 | inner = s.pictures(inner) |
| 174 | // Every href and action that is not the gallery's own goes to "#": |
| 175 | // a sample's link goes nowhere and a sample's form requests nothing. |
| 176 | inner = anyTag.ReplaceAllStringFunc(inner, func(tag string) string { |
| 177 | if strings.Contains(tag, " data-gallery-nav") { |
| 178 | return tag |
| 179 | } |
| 180 | return sampleRef.ReplaceAllStringFunc(tag, func(m string) string { |
| 181 | sub := sampleRef.FindStringSubmatch(m) |
| 182 | if strings.HasPrefix(sub[2], "#") { |
| 183 | return m |
| 184 | } |
| 185 | return sub[1] + `="#"` |
| 186 | }) |
| 187 | }) |
| 188 | return top + open + inner + tail, nil |
| 189 | } |
| 190 | |
| 191 | // pictures points every sample picture at its hashed copy under |
| 192 | // assets/images/. |
| 193 | func (s *site) pictures(html string) string { |
| 194 | return picture.ReplaceAllStringFunc(html, func(m string) string { |
| 195 | name := picture.FindStringSubmatch(m)[1] |
| 196 | return `src="` + s.asset("images/"+name) + `"` |
| 197 | }) |
| 198 | } |
| 199 | |
| 200 | // stageScene is the stage's scene, the <div rst-stage-scene> with its |
| 201 | // art, lifted from the layout-stage example's default state, so the |
| 202 | // staged sign-in screens show the stage the layout documents. newSite |
| 203 | // calls it once and keeps the scene on the site. |
| 204 | func (s *site) stageScene() (string, error) { |
| 205 | const name = "layout-stage" |
| 206 | state, ok := s.m.Default(name) |
| 207 | if !ok { |
| 208 | return "", fmt.Errorf("the manifest has no %s example", name) |
| 209 | } |
| 210 | doc, err := example.Document(s.src, name, state) |
| 211 | if err != nil { |
| 212 | return "", err |
| 213 | } |
| 214 | scene := stageSceneTag.FindString(doc) |
| 215 | if scene == "" { |
| 216 | return "", fmt.Errorf("%s/%s carries no scene to lift", name, state) |
| 217 | } |
| 218 | return scene, nil |
| 219 | } |
| 220 | |
| 221 | // stage puts a body whose first element is the sign-in card on the |
| 222 | // stage: the scene, then the card in the page column, as the stage |
| 223 | // shell lays a screen out. Ten of the twelve sign-in states; the two |
| 224 | // rst-box screens stay bare, as Rastrillo keeps them. The HTML tab |
| 225 | // still shows the bare card, which is what a reader copies. |
| 226 | func stage(scene, body string) string { |
| 227 | return `<div rst-stage>` + scene + `<div rst-page>` + strings.TrimSpace(body) + `</div></div>` |
| 228 | } |
| 229 | |
| 230 | // layoutLinks makes a layout document's own links live, so a shell demo |
| 231 | // opened full width works as the app it stands for: the back strip's |
| 232 | // way up goes to the shell's index document at its first nav id; the |
| 233 | // nav goes to the shell's page document where the manifest has one; |
| 234 | // the brand and the foot's "Back to the design system" go to the |
| 235 | // Overview; and the language menu's links go to the same layout |
| 236 | // document in each locale, the page's own marked current, lang and dir |
| 237 | // kept. Every link rewritten carries data-gallery-nav, which is what |
| 238 | // marks it the gallery's: the deadening pass leaves it alone and the |
| 239 | // URL gates resolve it. The rest of the body's links are a sample's |
| 240 | // and go to "#" afterwards. |
| 241 | // |
| 242 | // The shell of a layout is its name without layout- and -page: the |
| 243 | // index is layout-<shell>, the page layout-<shell>-page. The document |
| 244 | // in another locale is the same state's, the one being framed. |
| 245 | func (s *site) layoutLinks(doc string, e example.Entry, state, theme, locale string) (string, error) { |
| 246 | shell := strings.TrimSuffix(strings.TrimPrefix(e.Name, "layout-"), "-page") |
| 247 | index := "layout-" + shell |
| 248 | indexState, ok := s.m.Default(index) |
| 249 | if !ok { |
| 250 | return "", fmt.Errorf("%s: the manifest has no %s, the shell's index document", e.Name, index) |
| 251 | } |
| 252 | own := func(l string) string { return s.href(theme, l, "frames/"+e.Name+"/"+state+".html") } |
| 253 | overview := s.href(theme, locale, s.fileOf("overview")) |
| 254 | var err error |
| 255 | |
| 256 | if shellBack.MatchString(doc) { |
| 257 | indexDoc, derr := example.Document(s.src, index, indexState) |
| 258 | if derr != nil { |
| 259 | return "", derr |
| 260 | } |
| 261 | id := firstNavID.FindStringSubmatch(indexDoc) |
| 262 | if id == nil { |
| 263 | return "", fmt.Errorf("%s: %s has no nav id for the way up to land on", e.Name, index) |
| 264 | } |
| 265 | up := s.href(theme, locale, "frames/"+index+"/"+indexState+".html") + "#" + id[1] |
| 266 | doc = shellBack.ReplaceAllStringFunc(doc, func(m string) string { |
| 267 | return anchorTag.ReplaceAllStringFunc(m, func(a string) string { return liveLink(a, up) }) |
| 268 | }) |
| 269 | } |
| 270 | if pageState, ok := s.m.Default(index + "-page"); ok { |
| 271 | page := s.href(theme, locale, "frames/"+index+"-page/"+pageState+".html") |
| 272 | doc = shellNav.ReplaceAllStringFunc(doc, func(m string) string { |
| 273 | return anchorTag.ReplaceAllStringFunc(m, func(a string) string { return liveLink(a, page) }) |
| 274 | }) |
| 275 | } |
| 276 | doc = shellBrand.ReplaceAllStringFunc(doc, func(a string) string { return liveLink(a, overview) }) |
| 277 | if doc, err = s.localeLinks(doc, locale, own); err != nil { |
| 278 | return "", fmt.Errorf("%s: %w", e.Name, err) |
| 279 | } |
| 280 | doc = footLink(locale).ReplaceAllStringFunc(doc, func(m string) string { |
| 281 | return anchorTag.ReplaceAllString(m, liveLink(anchorTag.FindString(m), overview)) |
| 282 | }) |
| 283 | return doc, nil |
| 284 | } |
| 285 | |
| 286 | // localeLinks points every link of a document's language menu at href |
| 287 | // of the link's own locale, marked the gallery's, with the seeded |
| 288 | // current mark removed and the page's own locale marked instead; lang |
| 289 | // and dir are kept. A link with no lang, or a lang the gallery renders |
| 290 | // no locale for, is an error. The layouts' frame documents and the demo |
| 291 | // application both write their menus through it. |
| 292 | func (s *site) localeLinks(doc, locale string, href func(locale string) string) (string, error) { |
| 293 | var err error |
| 294 | doc = localeMenu.ReplaceAllStringFunc(doc, func(m string) string { |
| 295 | return anchorTag.ReplaceAllStringFunc(m, func(a string) string { |
| 296 | lang := langAttr.FindStringSubmatch(a) |
| 297 | if lang == nil { |
| 298 | err = errors.New("a language link carries no lang") |
| 299 | return a |
| 300 | } |
| 301 | if !known(lang[1]) { |
| 302 | err = fmt.Errorf("the language menu names %s, which the gallery has no locale for", lang[1]) |
| 303 | return a |
| 304 | } |
| 305 | a = currentOn.ReplaceAllString(a, "") |
| 306 | a = liveLink(a, href(lang[1])) |
| 307 | if lang[1] == locale { |
| 308 | a = tagEnd.ReplaceAllString(a, ` aria-current="true">`) |
| 309 | } |
| 310 | return a |
| 311 | }) |
| 312 | }) |
| 313 | return doc, err |
| 314 | } |
| 315 | |
| 316 | // liveLink points one <a> tag at href and marks it the gallery's own. |
| 317 | func liveLink(tag, href string) string { |
| 318 | tag = sampleRef.ReplaceAllStringFunc(tag, func(m string) string { |
| 319 | if sampleRef.FindStringSubmatch(m)[1] != "href" { |
| 320 | return m |
| 321 | } |
| 322 | return `href="` + href + `"` |
| 323 | }) |
| 324 | return tagEnd.ReplaceAllString(tag, ` data-gallery-nav>`) |
| 325 | } |
| 326 | |
| 327 | // known says whether the gallery renders the locale. |
| 328 | func known(locale string) bool { |
| 329 | for _, l := range Locales { |
| 330 | if l == locale { |
| 331 | return true |
| 332 | } |
| 333 | } |
| 334 | return false |
| 335 | } |
| 336 | |
| 337 | // exampleDocument builds a document shaped like a seeded example, with |
| 338 | // the relative asset links the seeds carry, so frame treats it like |
| 339 | // any example: a title, the two stylesheets, the scripts by file name, |
| 340 | // one <style> where style is not empty, and the body. |
| 341 | func exampleDocument(title string, scripts []string, style, body string) string { |
| 342 | var b strings.Builder |
| 343 | b.WriteString("<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width\">\n") |
| 344 | b.WriteString("<title>" + title + "</title>\n") |
| 345 | b.WriteString("<link rel=\"stylesheet\" href=\"../../css/tokens.css\">\n<link rel=\"stylesheet\" href=\"../../css/themes/day.css\">\n") |
| 346 | for _, sc := range scripts { |
| 347 | b.WriteString(`<script defer src="../../js/` + sc + `"></script>` + "\n") |
| 348 | } |
| 349 | if style != "" { |
| 350 | b.WriteString("<style>" + style + "</style>\n") |
| 351 | } |
| 352 | b.WriteString("</head>\n<body>\n" + body + "\n</body>\n</html>\n") |
| 353 | return b.String() |
| 354 | } |
| 355 | |