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 "encoding/json"
5 "errors"
6 "fmt"
7 "html"
8 "html/template"
9 "io/fs"
10 "regexp"
11 "strings"
12 "sync"
13 "unicode"
14
15 "amadan.net/rastrillo/fiesta"
16 "amadan.net/rastrillo/fiesta/example"
17)
18
19// ── Sample content in the page's language ────────────────────────────
20//
21// The samples speak the language of the page they are on. A Japanese
22// page with English buttons was showing how English fits a component,
23// which is the one language a reader can already picture: German runs
24// long, Arabic runs right to left, and the only way to see either inside
25// a component is to put it there. The stand-ins stay as written: a
26// person is called Grace Hopper in every language, an address is an
27// address and a route is a route.
28//
29// This is Rastrillo's markup walker, under Rastrillo's names. Fiesta's
30// samples are all markup (the examples' own documents), so Rastrillo's
31// second walker, for a partial's data, has no counterpart here. Every
32// sample string is looked up by its English in
33// i18n/samples.<locale>.json, which hack/seed/run-translations.sh
34// writes as Rastrillo's sampleProse is written, and every lookup is
35// traced as samples.<English>, so the parity gate and the leak gate
36// read what the frames asked for.
37//
38// Three things are Fiesta's own. localAttrs also writes the enhanced
39// controls' data-rst-<key> attributes from the framework's own frozen
40// i18n/<locale>.json, so the select and date fields speak the page's
41// language without a script table. An element that carries lang is in
42// that language already (the language menu's endonyms) and stays as
43// written. And a word's sense, which Rastrillo's typed data carries and
44// plain HTML cannot, is named per example in sampleSenses.
45//
46// What stays as written is decided by rule rather than listed, so a new
47// sample is translated by default and the parity gate asks for its
48// eleven translations; see keepsAsWritten. The list that exists,
49// sampleStandIns, is the names and brands no rule can recognise.
50
51// sampleStandIns are the names, brands and fixed values that stay as
52// written inside a string that is otherwise translated. "Actions for
53// Grace Hopper" is translated around the name; "Grace Hopper" alone is
54// not looked up at all. TestSampleTranslationsKeepTheirStandIns holds
55// every translation of a key that carries one to carrying it too.
56// Rastrillo's list, then the names Fiesta's seeded examples carry that
57// Rastrillo's do not.
58var sampleStandIns = []string{
59 // People.
60 "Grace Hopper", "Ada Lovelace", "Alan Turing", "Fiona Reid", "Otto Neurath",
61 "Hedy Lamarr", "Mary Sherman",
62 // Brands and products: the gallery's own sample apps, the framework,
63 // and the companies whose buttons the sign-in screen draws.
64 "Harbour", "rastrillo", "Google", "Apple", "WCAG",
65 // Fixed values a translation would only damage: a shortened count,
66 // and a postal address that is somewhere.
67 "4.1k", "42 Wharf Road, Dublin 2",
68 // Fiesta's own: the framework's name, the sample app the shells
69 // frame, the sign-in service the screens name, and the two agents
70 // the actor and token samples name. Then the fixed values: the
71 // currency code the money field shows and posts beside its amount
72 // (Rastrillo keeps it as a data key, Currency), a request's
73 // reference, and the reference an error page quotes.
74 "Fiesta", "Party planner", "Keymail", "Claude", "Codex",
75 "EUR", "REQ-4471", "k3f9tq",
76}
77
78var (
79 // sampleIdent is a machine's word: a field name, an option's value, a
80 // slug. A lowercase letter first, then no spaces.
81 sampleIdent = regexp.MustCompile(`^\p{Ll}[\p{L}\p{N}_.\-]*$`)
82 // sampleCode is a reference with a digit in it (AB3PX, D02, #4471),
83 // or a one- or two-letter initial.
84 sampleCode = regexp.MustCompile(`#?\b(?:[A-Z]*[0-9][A-Z0-9]*|[A-Z]{1,2})\b`)
85 sampleMail = regexp.MustCompile(`\S+@\S*`)
86)
87
88// keepsAsWritten says whether a sample string stays as it is in every
89// language: a URL, an address, a machine's identifier, a number with no
90// words around it, or nothing but stand-ins.
91func keepsAsWritten(s string) bool {
92 s = strings.TrimSpace(html.UnescapeString(s))
93 if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, "?") ||
94 strings.HasPrefix(s, "#") && !strings.Contains(s, " ") || strings.Contains(s, "://") {
95 return true
96 }
97 if sampleIdent.MatchString(s) {
98 return true
99 }
100 return !hasWords(s)
101}
102
103// hasWords says whether anything a reader would call a word is left in s
104// once the stand-ins, the email addresses and the codes are taken out.
105func hasWords(s string) bool {
106 for _, k := range sampleStandIns {
107 s = strings.ReplaceAll(s, k, "")
108 }
109 s = sampleMail.ReplaceAllString(s, "")
110 s = sampleCode.ReplaceAllString(s, "")
111 return strings.IndexFunc(s, unicode.IsLetter) >= 0
112}
113
114// verbSense is Rastrillo's suffix for a sample word that is an action
115// where the same English is a noun elsewhere in the samples: Archive in
116// a row's menu against the Archive a topbar links to. English spells
117// the two alike and most of the eleven do not (Archivar and Archivo),
118// so the action is looked up under its own key, the word plus
119// verbSense. English still reads the bare word.
120const verbSense = " (verb)"
121
122// nounSense is the other way round: a sample word that names a thing
123// where the catalog's bare word is the action. The seg-tabs group
124// "View" names the view a reader picks; the bare View is a row menu's
125// link, a verb in most of the eleven (Russian Открыть, open), which
126// would label the group as a command.
127const nounSense = " (noun)"
128
129// withoutSense is a sample key's English: the key with its sense, if
130// it has one, taken off.
131func withoutSense(key string) string {
132 return strings.TrimSuffix(strings.TrimSuffix(key, verbSense), nounSense)
133}
134
135// sampleSenses names, per example, the sample text whose key is not its
136// English. Rastrillo marks the sense in its typed sample data
137// (verb("Archive")); an example here is plain HTML, which cannot, so
138// the example is named instead. localMarkup and EnglishStrings both
139// apply it, so the walker and the exporter cannot disagree. Rastrillo's
140// sampleProse has two verb keys, and Fiesta's examples use both words
141// in that sense: the row menu's Archive button and the bulk bar's
142// Export action.
143var sampleSenses = map[string]map[string]string{
144 "row-menu": {"Archive": "Archive" + verbSense},
145 "bulk-bar": {"Export": "Export" + verbSense},
146 "seg-tabs": {"View": "View" + nounSense},
147}
148
149// sampleID is the id a sample string is traced under: its key, which is
150// its English or, for a word in a sense, its English and the sense.
151func sampleID(key string) string { return "samples." + key }
152
153// sampleCatalogs is every non-English locale's sample translations,
154// read once from i18n/samples.<locale>.json: an English key to its
155// translation, as Rastrillo's sampleProse rows are.
156var sampleCatalogs = sync.OnceValues(func() (map[string]catalog, error) {
157 out := map[string]catalog{}
158 for _, locale := range Locales {
159 if locale == "en" {
160 continue
161 }
162 name := "i18n/samples." + locale + ".json"
163 b, err := fs.ReadFile(catalogFiles, name)
164 if err != nil {
165 return nil, fmt.Errorf("the %s sample catalog: %w", locale, err)
166 }
167 var c catalog
168 if err := json.Unmarshal(b, &c); err != nil {
169 return nil, fmt.Errorf("%s: %w", name, err)
170 }
171 out[locale] = c
172 }
173 return out, nil
174})
175
176// samplesIn is one locale's sample translations; nil for English.
177func samplesIn(locale string) catalog {
178 cs, err := sampleCatalogs()
179 if err != nil {
180 return nil
181 }
182 return cs[locale]
183}
184
185// sampleText is one sample string in locale, by its key: the catalog's
186// translation, or the English (the key without its sense) for English
187// and for a key the catalog does not answer, which the parity gate
188// fails. Every lookup is traced.
189func sampleText(locale, key string) string {
190 trace(sampleID(key))
191 en := withoutSense(key)
192 if locale == "en" {
193 return en
194 }
195 if t := samplesIn(locale)[key]; t != "" {
196 return t
197 }
198 return en
199}
200
201// controlStrings is the framework's own catalog for each locale, as the
202// enhanced controls read it from a data-rst-<key> attribute: every
203// string as it is, and the date-words vocabulary as its JSON, compact
204// with its keys sorted, as the seeded documents write it.
205var controlStrings = sync.OnceValues(func() (map[string]map[string]string, error) {
206 out := map[string]map[string]string{}
207 for _, locale := range Locales {
208 b, ok := fiesta.Strings(locale)
209 if !ok {
210 return nil, fmt.Errorf("the framework has no i18n/%s.json", locale)
211 }
212 var raw map[string]json.RawMessage
213 if err := json.Unmarshal(b, &raw); err != nil {
214 return nil, fmt.Errorf("i18n/%s.json: %w", locale, err)
215 }
216 table := map[string]string{}
217 for k, v := range raw {
218 var s string
219 if json.Unmarshal(v, &s) == nil {
220 table[k] = s
221 continue
222 }
223 var words map[string]string
224 if err := json.Unmarshal(v, &words); err != nil {
225 return nil, fmt.Errorf("i18n/%s.json: %s is neither a string nor a table of words", locale, k)
226 }
227 j, err := json.Marshal(words)
228 if err != nil {
229 return nil, err
230 }
231 table[k] = string(j)
232 }
233 out[locale] = table
234 }
235 return out, nil
236})
237
238// ── Markup ───────────────────────────────────────────────────────────
239
240// spokenAttrs are the attributes a person reads or hears. Every other
241// attribute is the machine's, including value on anything but a text
242// input: an <option>'s value is what the form posts. Rastrillo's five,
243// and alt, which the media grid's pictures carry and Rastrillo's
244// samples do not.
245var spokenAttrs = map[string]bool{
246 "aria-label": true, "data-tip": true, "placeholder": true, "title": true, "label": true,
247 "alt": true,
248}
249
250// keptElements hold content that is never translated: code, a drawing,
251// a name (the convention throughout is that a person's name, an email
252// address or a record's own words go in <bdi>), and the slots of a
253// shell that carry the app's brand and its user.
254var keptElements = map[string]bool{"svg": true, "code": true, "bdi": true, "style": true, "script": true}
255
256// keptAttrs mark an element whose content stays as written: Rastrillo's
257// shell slots, and lang, which says the content is in a language of its
258// own already (the language menu names each language in itself).
259var keptAttrs = []string{"rst-shell-brand", "rst-person-name", "rst-person-email", "rst-person-av", "lang"}
260
261// phraseElements are the elements that sit inside a sentence. An
262// element whose content is words and these, and nothing else, is
263// translated as one sentence, markup included, so "Displaying
264// <strong>1–20</strong> of <strong>412</strong>" can become a sentence
265// with the two numbers the other way round. Translated word by word it
266// could not: "of" has no translation that works in every order.
267var phraseElements = map[string]bool{
268 "a": true, "abbr": true, "b": true, "bdi": true, "code": true, "data": true,
269 "em": true, "i": true, "output": true, "small": true, "strong": true, "time": true,
270}
271
272var voidElements = map[string]bool{
273 "area": true, "br": true, "col": true, "embed": true, "hr": true, "img": true,
274 "input": true, "link": true, "meta": true, "source": true, "track": true, "wbr": true,
275}
276
277// mtoken is one piece of a sample's markup: text, or a tag.
278type mtoken struct {
279 raw string
280 tag string // the element's name; empty for text
281 close bool // </tag>
282 void bool // a void element, a self-closing SVG child, or a comment
283}
284
285// tokenise splits markup into text and tags. It is not a general HTML
286// parser and does not need to be: the samples are this repository's
287// own, with every attribute quoted. A comment (the layouts' slot marks)
288// is a tag that opens nothing.
289func tokenise(src string) []mtoken {
290 var out []mtoken
291 for len(src) > 0 {
292 i := strings.IndexByte(src, '<')
293 if i != 0 {
294 if i < 0 {
295 i = len(src)
296 }
297 out = append(out, mtoken{raw: src[:i]})
298 src = src[i:]
299 continue
300 }
301 j := markupTagEnd(src)
302 raw := src[:j]
303 src = src[j:]
304 t := mtoken{raw: raw}
305 name := strings.TrimPrefix(raw[1:], "/")
306 t.close = strings.HasPrefix(raw, "</")
307 if k := strings.IndexAny(name, " \t\n/>"); k >= 0 {
308 name = name[:k]
309 }
310 t.tag = strings.ToLower(name)
311 t.void = voidElements[t.tag] || strings.HasSuffix(raw, "/>") || strings.HasPrefix(raw, "<!")
312 out = append(out, t)
313 }
314 return out
315}
316
317// markupTagEnd is the length of the tag at the start of src, quoted
318// attribute values included: a > inside one does not end the tag.
319// Rastrillo's tagEnd, renamed: frame.go has a tagEnd of its own.
320func markupTagEnd(src string) int {
321 var quote byte
322 for i := 1; i < len(src); i++ {
323 switch c := src[i]; {
324 case quote != 0:
325 if c == quote {
326 quote = 0
327 }
328 case c == '"' || c == '\'':
329 quote = c
330 case c == '>':
331 return i + 1
332 }
333 }
334 return len(src)
335}
336
337// closing is the index of the token that closes the element opened at
338// toks[i], or len(toks) when nothing does.
339func closing(toks []mtoken, i int) int {
340 depth := 0
341 for j := i + 1; j < len(toks); j++ {
342 t := toks[j]
343 if t.tag != toks[i].tag || t.void {
344 continue
345 }
346 if !t.close {
347 depth++
348 continue
349 }
350 if depth == 0 {
351 return j
352 }
353 depth--
354 }
355 return len(toks)
356}
357
358// localiser is one walk over a sample's markup: the locale the data-rst
359// attributes are written in, the example's senses, and look, which
360// turns a key into the text that replaces it. The renderer looks each
361// key up in the locale; EnglishStrings collects them. Both walk with
362// the same code, so they cannot ask for different strings.
363type localiser struct {
364 locale string
365 senses map[string]string
366 look func(key string) string
367}
368
369// key is the key a run of sample text is looked up by: its sense where
370// the example names one, its English otherwise.
371func (l localiser) key(s string) string {
372 if k, ok := l.senses[s]; ok {
373 return k
374 }
375 return s
376}
377
378// localMarkup is one hand-written sample of the named example in one
379// locale. In English it returns src byte for byte; anywhere else the
380// only bytes that change are the text, the spoken attribute values and
381// the enhanced controls' attributes it translates.
382func localMarkup(locale, name, src string) string {
383 return walkMarkup(localiser{locale: locale, senses: sampleSenses[name], look: func(key string) string {
384 return sampleText(locale, key)
385 }}, src)
386}
387
388// sampleKeys is every key localMarkup looks up in one sample, in order.
389func sampleKeys(name, src string) []string {
390 var out []string
391 walkMarkup(localiser{locale: "en", senses: sampleSenses[name], look: func(key string) string {
392 out = append(out, key)
393 return withoutSense(key)
394 }}, src)
395 return out
396}
397
398// walkMarkup is Rastrillo's localMarkup over a localiser.
399func walkMarkup(l localiser, src string) string {
400 toks := tokenise(src)
401 var b strings.Builder
402 for i := 0; i < len(toks); i++ {
403 t := toks[i]
404 switch {
405 case t.tag == "":
406 b.WriteString(localText(l, t.raw))
407 case t.close || t.void:
408 b.WriteString(localAttrs(l, t))
409 default:
410 end := closing(toks, i)
411 b.WriteString(localAttrs(l, t))
412 if keptElement(t) {
413 for _, k := range toks[i+1 : min(end+1, len(toks))] {
414 b.WriteString(k.raw)
415 }
416 i = end
417 continue
418 }
419 if isPhrase(toks[i+1 : end]) {
420 var inner strings.Builder
421 for _, k := range toks[i+1 : end] {
422 inner.WriteString(k.raw)
423 }
424 b.WriteString(localText(l, inner.String()))
425 if end < len(toks) {
426 b.WriteString(toks[end].raw)
427 }
428 i = end
429 }
430 }
431 }
432 return b.String()
433}
434
435// keptElement says whether an element's content stays as written.
436func keptElement(t mtoken) bool {
437 if keptElements[t.tag] {
438 return true
439 }
440 for _, a := range keptAttrs {
441 if strings.Contains(t.raw, " "+a+" ") || strings.Contains(t.raw, " "+a+">") || strings.Contains(t.raw, " "+a+"=") {
442 return true
443 }
444 }
445 return false
446}
447
448// isPhrase says whether an element's content is one sentence: words of
449// its own beside at least one element, and every element inside it one
450// that sits in running text.
451func isPhrase(inner []mtoken) bool {
452 words, elements := false, false
453 depth := 0
454 for _, t := range inner {
455 switch {
456 case t.tag == "":
457 if depth == 0 && hasWords(html.UnescapeString(t.raw)) {
458 words = true
459 }
460 case !phraseElements[t.tag]:
461 return false
462 case t.close:
463 depth--
464 default:
465 if depth == 0 {
466 elements = true
467 }
468 if !t.void {
469 depth++
470 }
471 }
472 }
473 return words && elements
474}
475
476// localText translates one run, keeping the whitespace around it where
477// it was: the samples are indented, and the indent is not the sentence.
478func localText(l localiser, raw string) string {
479 core := strings.TrimSpace(raw)
480 if core == "" || keepsAsWritten(stripTags(core)) {
481 return raw
482 }
483 lead := raw[:strings.Index(raw, core)]
484 return lead + l.look(l.key(core)) + raw[len(lead)+len(core):]
485}
486
487// stripTags is a phrase's text with its markup taken out, for deciding
488// whether there is anything in it to translate.
489func stripTags(s string) string {
490 var b strings.Builder
491 for _, t := range tokenise(s) {
492 if t.tag == "" {
493 b.WriteString(t.raw)
494 }
495 }
496 return b.String()
497}
498
499// spokenAttr matches one double-quoted attribute and its value.
500var spokenAttr = regexp.MustCompile(`([a-z-]+)="([^"]*)"`)
501
502// controlAttr matches one of the enhanced controls' attributes and its
503// value: data-rst- and the key the framework's catalog files it under.
504var controlAttr = regexp.MustCompile(`(\sdata-rst-)([a-z0-9-]+)="([^"]*)"`)
505
506// localAttrs translates the spoken attributes of one tag. A text input's
507// value is spoken too: it is what the field shows. Every data-rst-<key>
508// whose key the framework's catalog has takes that catalog's value in
509// the walk's locale, which is what the enhanced controls would have
510// read from a script table.
511func localAttrs(l localiser, t mtoken) string {
512 if t.close || !strings.Contains(t.raw, `="`) {
513 return t.raw
514 }
515 textInput := t.tag == "input" && (strings.Contains(t.raw, ` type="text"`) || !strings.Contains(t.raw, ` type="`))
516 raw := spokenAttr.ReplaceAllStringFunc(t.raw, func(m string) string {
517 sub := spokenAttr.FindStringSubmatch(m)
518 if !(spokenAttrs[sub[1]] || textInput && sub[1] == "value") || keepsAsWritten(sub[2]) {
519 return m
520 }
521 return sub[1] + `="` + l.look(l.key(sub[2])) + `"`
522 })
523 if !strings.Contains(raw, " data-rst-") {
524 return raw
525 }
526 tables, err := controlStrings()
527 if err != nil {
528 return raw
529 }
530 table := tables[l.locale]
531 return controlAttr.ReplaceAllStringFunc(raw, func(m string) string {
532 sub := controlAttr.FindStringSubmatch(m)
533 v, ok := table[sub[2]]
534 if !ok {
535 return m
536 }
537 return sub[1] + sub[2] + `="` + template.HTMLEscapeString(v) + `"`
538 })
539}
540
541// ── Documents ────────────────────────────────────────────────────────
542
543var titleText = regexp.MustCompile(`<title>[^<]*</title>`)
544
545// localBody is a document with its body's markup through localMarkup;
546// the head is left as it is. English is the document unchanged.
547func localBody(locale, name, doc string) (string, error) {
548 if locale == "en" {
549 return doc, nil
550 }
551 body := bodyTag.FindStringIndex(doc)
552 end := strings.LastIndex(doc, "</body>")
553 if body == nil || end < body[1] {
554 return "", fmt.Errorf("no <body> to localise")
555 }
556 return doc[:body[1]] + localMarkup(locale, name, doc[body[1]:end]) + doc[end:], nil
557}
558
559// localDocument is one state's document in locale: its body localised
560// and its title the frame's (localTitle). English is the document as
561// written.
562func (s *site) localDocument(locale string, e example.Entry, st example.State, doc string) (string, error) {
563 doc, err := localBody(locale, e.Name, doc)
564 if err != nil {
565 return "", err
566 }
567 return s.localTitle(locale, e, st, doc)
568}
569
570// localTitle is a state's document with its <title> the frame's title
571// in the page's language, the example's name and the state's label, in
572// place of the English the example wrote there. English is the
573// document as written.
574func (s *site) localTitle(locale string, e example.Entry, st example.State, doc string) (string, error) {
575 if locale == "en" {
576 return doc, nil
577 }
578 if n := len(titleText.FindAllStringIndex(doc, -1)); n != 1 {
579 return "", fmt.Errorf("the document has %d <title> elements, want one", n)
580 }
581 title := "<title>" + template.HTMLEscapeString(s.frameTitle(locale, e, st)) + "</title>"
582 return titleText.ReplaceAllLiteralString(doc, title), nil
583}
584
585// localHTML is a document's <html> in the page's language and its
586// direction. A document that does not open as English left to right
587// is an error, so a seed written otherwise is not silently left as
588// English.
589func localHTML(doc, locale string) (string, error) {
590 if locale == "en" {
591 return doc, nil
592 }
593 if !strings.Contains(doc, ltrHTML) {
594 return "", fmt.Errorf("no %s to localise", ltrHTML)
595 }
596 return strings.Replace(doc, ltrHTML, `<html lang="`+locale+`" dir="`+Dir(locale)+`"`, 1), nil
597}
598
599// localSample is one state's frame document and the code its HTML tab
600// shows, in locale. The example's document is put in the page's
601// language (localDocument) and then framed, so the walker reads the
602// sample's own links and the frame's rewrites come after it; the code
603// is the component's body as the frame shows it, or for a layout the
604// whole document in the page's language. The money sample in its
605// default state is the money table's markup for the locale, frame and
606// code alike, bypassing the walker as Rastrillo's formatMarkup does:
607// its amounts are already written for the locale, and its currency
608// codes read as words.
609func (s *site) localSample(theme, locale string, e example.Entry, st example.State) (frame, code string, err error) {
610 doc, err := example.Document(s.src, e.Name, st.ID)
611 if err != nil {
612 return "", "", err
613 }
614 // A layout's code is its whole document, so its body is not read
615 // again on its own.
616 var en string
617 if !e.Document {
618 if en, err = example.Markup(s.src, e.Name, st.ID); err != nil {
619 return "", "", err
620 }
621 }
622 if e.Name == moneyExample && st.ID == e.Default && !e.Document {
623 money, ok := s.moneyBody(locale)
624 if !ok {
625 return "", "", fmt.Errorf("i18n/money.json has no %s sample", locale)
626 }
627 if strings.Count(doc, en) != 1 {
628 return "", "", errors.New("the body is not where the money table can replace it")
629 }
630 if doc, err = s.localTitle(locale, e, st, strings.Replace(doc, en, money, 1)); err != nil {
631 return "", "", err
632 }
633 code = money
634 } else {
635 if doc, err = s.localDocument(locale, e, st, doc); err != nil {
636 return "", "", err
637 }
638 if !e.Document {
639 code = localMarkup(locale, e.Name, en)
640 }
641 }
642 if frame, err = s.frame(doc, theme, locale, e, st.ID); err != nil {
643 return "", "", err
644 }
645 if e.Document {
646 code, err = localHTML(doc, locale)
647 }
648 return frame, code, err
649}
650
651// sampleSources is every sample the renderer puts through localMarkup,
652// by the example (or document) whose senses apply: each example state's
653// body, the money sample's default excepted; the demo application's
654// content, with the gallery's own sentences it carries held out; and
655// the demo's two rail labels. EnglishStrings collects the sample keys
656// from these, with the walker the renderer uses.
657func sampleSources(src fs.FS, m *example.Manifest) ([][2]string, error) {
658 var out [][2]string
659 for _, e := range m.Examples {
660 for _, st := range e.States {
661 if e.Name == moneyExample && st.ID == e.Default {
662 continue
663 }
664 markup, err := example.Markup(src, e.Name, st.ID)
665 if err != nil {
666 return nil, err
667 }
668 out = append(out, [2]string{e.Name, markup})
669 }
670 }
671 for _, view := range demoViews[1:] {
672 content, err := demoContent(view, demoData{Back: demoBackHeld, Callout: demoCalloutHeld})
673 if err != nil {
674 return nil, err
675 }
676 out = append(out, [2]string{"demo", content})
677 }
678 for _, label := range demoRail {
679 out = append(out, [2]string{"demo", label.label})
680 }
681 return out, nil
682}
683