rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package gallery
2
3import (
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
19var (
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.
51const 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.
56func 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.
72const frameStyle = `<style>body { padding: 1rem; }
73body: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; }
74body[data-gallery-document] { padding: 0; }</style>`
75
76// documentMark is what a layout document's <body> carries.
77const 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.
90func (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).
100func (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/.
193func (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.
204func (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.
226func 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.
245func (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.
292func (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.
317func 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.
328func 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.
341func 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