package gallery import ( "encoding/json" "html/template" "io/fs" "regexp" "slices" "sort" "strings" "sync" "testing" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta" ) // The translation gates: every id a render asks for is translated in // all eleven catalogs, every chrome string is one a render asks for, // no English the gallery says in its own voice reaches a translated // page, and the page templates write no English of their own. // The two classes of document in a theme and locale directory. A page // is a page kind's file or an example's page: the gallery's chrome and // prose, in the page's language through the catalogs. A sample document // is a frame or one of the extra documents (the demo application's // four and the modal route), whose words are the examples' own, // translated through the sample catalogs. The leak sweep reads both. const ( docPage = "page" docSample = "sample document" ) // documentKind classes one path of the tree: docPage, docSample, or "" // for anything else (the assets, the tree root). func (s *site) documentKind(name string) string { parts := strings.SplitN(name, "/", 3) if len(parts) != 3 || !slices.Contains(fiesta.ThemeNames(), parts[0]) || !known(parts[1]) { return "" } rest := parts[2] if strings.HasPrefix(rest, "frames/") || slices.Contains(s.extraDocumentNames(), rest) { return docSample } for _, pk := range s.pageKinds() { if pk.File == rest { return docPage } } if stem, ok := strings.CutSuffix(rest, ".html"); ok { if _, ok := s.m.Entry(stem); ok { return docPage } } return "" } // tracedIDs is every id one full render asks for. One render serves // every gate that reads it. func tracedIDs(t *testing.T) map[string]bool { t.Helper() tracedOnce.Do(func() { seen := map[string]bool{} var mu sync.Mutex stop := setTrace(func(id string) { mu.Lock() seen[id] = true mu.Unlock() }) _, tracedErr = Render(fiesta.FS(), Options{}) stop() tracedSet = seen }) if tracedErr != nil { t.Fatalf("Render: %v", tracedErr) } return tracedSet } var ( tracedOnce sync.Once tracedSet map[string]bool tracedErr error ) // englishStrings is EnglishStrings over the source tree, failed loudly. func englishStrings(t *testing.T) map[string]string { t.Helper() m, err := EnglishStrings(fiesta.FS()) if err != nil { t.Fatal(err) } return m } // loadedCatalogs is the eleven catalogs, failed loudly. func loadedCatalogs(t *testing.T) map[string]catalog { t.Helper() cs, err := catalogs() if err != nil { t.Fatal(err) } return cs } // nonEnglish is every locale a catalog answers for. func nonEnglish() []string { var out []string for _, l := range Locales { if l != "en" { out = append(out, l) } } return out } // placeholderName is one {name} in a string. var placeholderName = regexp.MustCompile(`\{[a-z]+\}`) // TestEveryProseKeyIsTranslated: every id a full render asks for, the // chrome's, the prose's and the labels', has a non-empty translation in // all eleven catalogs, with every {placeholder} of its English kept and // none added; no catalog carries an id no render asks for; the catalog // directory holds the eleven and drafts.json, with no en catalog and // no locale the gallery does not render; and drafts.json names only // entries the catalogs carry, each drafted from the English the id has // now. func TestEveryProseKeyIsTranslated(t *testing.T) { ids := tracedIDs(t) english := englishStrings(t) cs := loadedCatalogs(t) sorted := make([]string, 0, len(ids)) for id := range ids { sorted = append(sorted, id) } sort.Strings(sorted) for _, id := range sorted { if strings.HasPrefix(id, "samples.") { // A sample string's translation is in the sample catalogs, // which TestEverySampleStringIsTranslated holds. continue } en, ok := english[id] if !ok { t.Errorf("a render asked for %s, which EnglishStrings does not list, so the exporter never sees it", id) continue } want := placeholderName.FindAllString(en, -1) sort.Strings(want) for _, l := range nonEnglish() { tr := cs[l][id] if strings.TrimSpace(tr) == "" { t.Errorf("i18n/%s.json has no translation of %s (%q)", l, id, en) continue } got := placeholderName.FindAllString(tr, -1) sort.Strings(got) if !slices.Equal(got, want) { t.Errorf("i18n/%s.json: %s carries the placeholders %v, its English %v", l, id, got, want) } } } for _, l := range nonEnglish() { for id := range cs[l] { if !ids[id] { t.Errorf("i18n/%s.json carries %s, which no render asks for", l, id) } } } entries, err := fs.ReadDir(catalogFiles, "i18n") if err != nil { t.Fatal(err) } for _, e := range entries { name := e.Name() locale, _ := strings.CutSuffix(name, ".json") switch { case name == "drafts.json" || name == "money.json": case strings.HasPrefix(locale, "samples."): if l := strings.TrimPrefix(locale, "samples."); l == "en" || !known(l) { t.Errorf("i18n/ carries %s, which is no translated locale's sample catalog", name) } case locale == "en": t.Errorf("i18n/ carries %s; English is copy.go, the prose and the manifest", name) case !known(locale): t.Errorf("i18n/ carries %s, which is no locale the gallery renders", name) } } b, err := fs.ReadFile(catalogFiles, "i18n/drafts.json") if err != nil { t.Fatal(err) } var drafts map[string]struct { English string `json:"english"` Locales []string `json:"locales"` } if err := json.Unmarshal(b, &drafts); err != nil { t.Fatalf("i18n/drafts.json: %v", err) } for id, d := range drafts { if strings.HasPrefix(id, "samples.") { continue } if d.English != english[id] { t.Errorf("i18n/drafts.json: %s was drafted from %q, and its English is now %q", id, d.English, english[id]) } for _, l := range d.Locales { if l == "en" || !known(l) || cs[l][id] == "" { t.Errorf("i18n/drafts.json names a %s draft of %s that no catalog carries", l, id) } } } t.Logf("%d ids in %d catalogs, %d of them drafted somewhere", len(sorted), len(cs), len(drafts)) } // TestEveryCopyKeyIsRendered: every id copy.go files is one a full // render asks for, so a key a rewrite left behind is pruned rather // than translated eleven times over. func TestEveryCopyKeyIsRendered(t *testing.T) { ids := tracedIDs(t) keys := make([]string, 0, len(text)) for id := range text { keys = append(keys, id) } sort.Strings(keys) for _, id := range keys { if !ids[id] { t.Errorf("copy.go files %s (%q), which no render asks for; prune it", id, text[id]) } } } // leakFloor is the length, in runes, from which a string is a sentence // and is looked for anywhere inside a run of text, as Rastrillo's // sweep has it: a sentence does not occur inside other words by // chance. Every traced string, sentence or word, is also looked for as // a whole run of text or a whole spoken attribute value, which is how // a short word of the chrome ("Copy", "Overview") is caught without // matching inside the words around it. A string a locale rightly keeps // as it is, is in leakUnchanged. const leakFloor = 12 // leakSentences is the English the sweep looks for inside runs: each // traced string's runs between placeholders, of leakFloor runes or // more, whitespace collapsed, bar leakExempt. func leakSentences(ids map[string]bool, english map[string]string) []string { seen := map[string]bool{} var out []string for id := range ids { en := english[id] if leakExempt(en) { continue } for _, run := range placeholderName.Split(en, -1) { run = squash(run) if len([]rune(run)) >= leakFloor && !leakExempt(run) && !seen[run] { seen[run] = true out = append(out, run) } } } sort.Strings(out) return out } // leakSweep is everything the sweep looks for: the sentences, inside // any run; every traced string without placeholders, as a whole run; // and every traced string with them, as a whole run with the // placeholders filled by anything. type leakSweep struct { sentences []string whole map[string]bool patterns []leakPattern } // leakPattern is one string with placeholders: its English, the longest // run of it between placeholders (a cheap test before the expression), // and the expression a whole run matches when it is that string filled. type leakPattern struct { en, key string re *regexp.Regexp } // newLeakSweep builds the sweep from the traced ids. A string whose // only text between placeholders is punctuation ("{example}: {state}") // says no English of its own and is not looked for. func newLeakSweep(ids map[string]bool, english map[string]string) leakSweep { w := leakSweep{sentences: leakSentences(ids, english), whole: map[string]bool{}} for id := range ids { en := squash(english[id]) if en == "" || leakExempt(en) { continue } if !placeholderName.MatchString(en) { w.whole[en] = true continue } parts := placeholderName.Split(en, -1) if !templateLetter.MatchString(strings.Join(parts, "")) { continue } quoted := make([]string, len(parts)) key := "" for i, part := range parts { quoted[i] = regexp.QuoteMeta(part) if len(strings.TrimSpace(part)) > len(key) { key = strings.TrimSpace(part) } } w.patterns = append(w.patterns, leakPattern{en: en, key: key, re: regexp.MustCompile("^" + strings.Join(quoted, ".+") + "$")}) } sort.Slice(w.patterns, func(i, j int) bool { return w.patterns[i].en < w.patterns[j].en }) return w } // leaks is the English a page in locale carries, given its runs, bar // what leakUnchanged says the locale keeps. func (w leakSweep) leaks(locale string, runs []string) []string { var out []string seen := map[string]bool{} add := func(en string) { if !seen[en] && !unchangedIn(locale, en) { seen[en] = true out = append(out, en) } } words := strings.Join(runs, "\x00") for _, s := range w.sentences { if strings.Contains(words, s) { add(s) } } for _, r := range runs { if w.whole[r] && !collidesIn(locale, r) { add(r) } for _, p := range w.patterns { if strings.Contains(r, p.key) && p.re.MatchString(r) { add(p.en) } } } return out } // leakSpoken are the attributes whose values a person reads or hears: // Rastrillo's spoken attributes, alt, the other ARIA attributes that // carry words, and the Copy live region's three words, which the // script speaks. The page sweep and the template gate read the same // list. var leakSpoken = map[string]bool{ "aria-label": true, "title": true, "placeholder": true, "label": true, "data-tip": true, "alt": true, "data-copy": true, "data-copied": true, "data-failed": true, "aria-description": true, "aria-roledescription": true, "aria-valuetext": true, "aria-placeholder": true, } // leakSkipped says whether the sweep leaves an element and everything // in it alone: script and style, which say nothing. The scheme menu // and the HTML tab's source are swept like the rest, since the samples // are in the page's language. func leakSkipped(n *xhtml.Node) bool { return n.Data == "script" || n.Data == "style" } // leakKept says whether the sweep reads an element's spoken attributes // and leaves its content alone: whatever the sample walker keeps as // written (keptElements and keptAttrs: code, a drawing, a record's own // words in , a shell's brand and person, and anything that says // its own language), which may well be words a sample elsewhere // translates. The walker still translates the element's own spoken // attributes, as it does every opening tag's, so the sweep reads them. func leakKept(n *xhtml.Node) bool { if keptElements[n.Data] { return true } for _, a := range n.Attr { // The walker reads bodies; a document's own says // the page's language, not that its words are kept. if slices.Contains(keptAttrs, a.Key) && n.Data != "html" { return true } } return false } // sourceOf says whether n is an HTML tab's source block, whose text is // markup to be read as a sample is. func sourceOf(n *xhtml.Node) bool { if n.Data != "pre" { return false } for _, a := range n.Attr { if a.Key == "class" && slices.Contains(strings.Fields(a.Val), "ds-src") { return true } } return false } // textContent is everything n's text nodes say, in order. func textContent(n *xhtml.Node) string { var b strings.Builder var walk func(*xhtml.Node) walk = func(n *xhtml.Node) { if n.Type == xhtml.TextNode { b.WriteString(n.Data) } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(n) return b.String() } // pageRuns is every run of text on a page and every spoken attribute // value, as a reader gets them (entities decoded, whitespace // collapsed), outside what leakSkipped leaves alone and the content // of what leakKept keeps, empty runs left out; an HTML tab's source is // read as the markup it shows. func pageRuns(t *testing.T, doc []byte) []string { t.Helper() root, err := xhtml.Parse(strings.NewReader(string(doc))) if err != nil { t.Fatal(err) } var out []string keep := func(s string) { if s = squash(s); s != "" { out = append(out, s) } } var walk func(*xhtml.Node) walk = func(n *xhtml.Node) { switch n.Type { case xhtml.ElementNode: if leakSkipped(n) { return } if sourceOf(n) { // The HTML tab shows markup as text; it is read as the // markup it is, so its words and spoken attributes are // swept as a frame's are, and what the walker keeps is // left as the walker left it. out = append(out, pageRuns(t, []byte(textContent(n)))...) return } for _, a := range n.Attr { if leakSpoken[a.Key] { keep(a.Val) } } if leakKept(n) { return } case xhtml.TextNode: keep(n.Data) } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(root) return out } // pageWords is pageRuns joined with NULs, so a sentence found in it // cannot span two runs. func pageWords(t *testing.T, doc []byte) string { t.Helper() return strings.Join(pageRuns(t, doc), "\x00") } // englishLeaks is which of sentences the page's words carry. func englishLeaks(words string, sentences []string) []string { var out []string for _, s := range sentences { if strings.Contains(words, s) { out = append(out, s) } } return out } // translatedPages is every page, as documentKind classes them, of the // non-English locales in the tree. func translatedPages(s *site, files map[string][]byte) []string { return translatedOf(s, files, docPage) } // translatedDocuments is every document documentKind classes, page or // sample document, of the non-English locales in the tree. func translatedDocuments(s *site, files map[string][]byte) []string { return translatedOf(s, files, docPage, docSample) } // translatedOf is every document of the non-English locales whose // documentKind is one of kinds. func translatedOf(s *site, files map[string][]byte, kinds ...string) []string { var out []string for name := range files { if slices.Contains(kinds, s.documentKind(name)) && !strings.Contains(name, "/en/") { out = append(out, name) } } sort.Strings(out) return out } // translationOf is locale's translation of a traced id: the sample // catalog's for a sample, the catalog's for anything else. func translationOf(t *testing.T, locale, id string) string { t.Helper() if k, ok := strings.CutPrefix(id, "samples."); ok { return loadedSamples(t)[locale][k] } return loadedCatalogs(t)[locale][id] } // TestNoEnglishProseReachesATranslatedPage: no English a render looks // up, chrome, prose, label or sample, is in the text or the spoken // attributes of any document in a language that is not English: every // page, the lifted scheme menu and the HTML tab's source included, and // every sample document, the frames, the demo application and the // modal route. A sentence anywhere in a run, any string as a whole run, // bar what leakUnchanged says a locale keeps. The controls are catalog // values put back to their English, a sentence and two short words of // the chrome and a sample's word, which the sweep must report on the // document that says them. func TestNoEnglishProseReachesATranslatedPage(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") english := englishStrings(t) sweep := leakSweepFor(t, english) if len(sweep.sentences) < 100 || len(sweep.whole) < 200 || len(sweep.patterns) < 5 { t.Fatalf("sweeping for %d sentences, %d whole strings and %d patterns; the sweep has stopped reading the traced strings", len(sweep.sentences), len(sweep.whole), len(sweep.patterns)) } // Every file under a theme and locale is one class or the other, // so the sweep cannot quietly read fewer pages than there are. for name := range files { if strings.HasSuffix(name, ".html") && strings.Count(name, "/") >= 2 && s.documentKind(name) == "" { t.Errorf("%s is neither a page nor a sample document", name) } } // Each string leakUnchanged lets through is one a render says, and // each locale it names does keep it, so the list cannot outlive the // reason for an entry. for en, locales := range leakUnchanged { if locales == nil { locales = nonEnglish() } kept := false for id := range tracedIDs(t) { if english[id] != en { continue } for _, l := range locales { if tr := translationOf(t, l, id); tr != en { t.Errorf("leakUnchanged keeps %q in %s, whose catalog says %q for %s", en, l, tr, id) } } kept = true } if !kept { t.Errorf("leakUnchanged keeps %q, which no render says", en) } } // Each word leakCollisions excuses is the English of a string a // render says, and in each locale it names, the translation of // another: the language's own word, spelt as English spells it. for word, locales := range leakCollisions { said := false for id := range tracedIDs(t) { said = said || squash(english[id]) == word } if !said { t.Errorf("leakCollisions excuses %q, which is the English of nothing a render says", word) } for _, l := range locales { own := false for id := range tracedIDs(t) { if squash(translationOf(t, l, id)) == word && squash(english[id]) != word { own = true } } if !own { t.Errorf("leakCollisions excuses %q in %s, where it translates nothing; it is English there", word, l) } } } docs := translatedDocuments(s, files) perDir := len(s.pageKinds()) + len(s.m.Examples) + len(s.extraDocumentNames()) for _, e := range s.m.Examples { perDir += len(s.frameFiles(e)) } if want := len(fiesta.ThemeNames()) * len(nonEnglish()) * perDir; len(docs) != want { t.Errorf("sweeping %d translated documents, want %d", len(docs), want) } for _, name := range docs { for _, en := range sweep.leaks(strings.Split(name, "/")[1], pageRuns(t, files[name])) { t.Errorf("%s carries the English %q", name, en) } } // The controls: three strings put back to their English in the ja // catalog, a sentence and two short words of the chrome (the Copy // live region's word and the Overview's name), each of which the // sweep must report on the Japanese Overview; and sample strings put // back in their sample catalogs, each of which it must report on the // document that shows it: a word of the sidebar shell, a short // drafted link of the demo, and a carried sentence of a field's // error. The sweep is built again with the catalogs changed, so no // exemption it draws can excuse what a control put back. cs := loadedCatalogs(t) samples := loadedSamples(t) controls := []string{"gallery.overview.intro", "gallery.copy", "gallery.kind.overview"} sampleControls := []struct{ locale, key, doc string }{ {"ja", "Sign out", "day/ja/frames/layout-sidebar/" + mustEntry(t, "layout-sidebar").Default + ".html"}, {"ja", "← Requests", "day/ja/demo-request.html"}, {"es", "A summary needs at least twenty characters.", "day/es/frames/field-textarea/with-an-error.html"}, } orig := map[string]string{} for _, id := range controls { orig[id] = cs["ja"][id] cs["ja"][id] = text[id] } origSamples := make([]string, len(sampleControls)) for i, c := range sampleControls { origSamples[i] = samples[c.locale][c.key] samples[c.locale][c.key] = c.key } scratch, err := Render(fiesta.FS(), Options{}) controlSweep := leakSweepFor(t, english) for _, id := range controls { cs["ja"][id] = orig[id] } for i, c := range sampleControls { samples[c.locale][c.key] = origSamples[i] } if err != nil { t.Fatal(err) } found := controlSweep.leaks("ja", pageRuns(t, scratch["day/ja/index.html"])) for _, id := range controls { if !slices.Contains(found, squash(text[id])) { t.Errorf("with %s put back to its English in the ja catalog, the sweep does not report %q on day/ja/index.html; it reports %q", id, text[id], found) } } for i, c := range sampleControls { if origSamples[i] == "" || origSamples[i] == c.key { t.Errorf("the %s sample catalog says %q for %q; the control proves nothing", c.locale, origSamples[i], c.key) } if !strings.Contains(string(scratch[c.doc]), c.key) { t.Errorf("%s does not show %q once it is put back; the control is on the wrong document", c.doc, c.key) } if found := controlSweep.leaks(c.locale, pageRuns(t, scratch[c.doc])); !slices.Contains(found, c.key) { t.Errorf("with %q put back to its English in the %s sample catalog, the sweep does not report it on %s; it reports %q", c.key, c.locale, c.doc, found) } } // A kept element's own spoken attributes are the walker's to // translate, as it translates every opening tag's, so the sweep // reads them while it leaves the element's content alone. runs := pageRuns(t, []byte(`

Grace Hopper Harbour

`)) for _, want := range []string{"Help: orders", "Back to the start"} { if !slices.Contains(runs, want) { t.Errorf("the sweep does not read the spoken attribute %q of a kept element; it reads %q", want, runs) } } for _, kept := range []string{"Grace Hopper", "Harbour"} { if slices.Contains(runs, kept) { t.Errorf("the sweep reads %q, the content of a kept element", kept) } } } // leakSweepFor is the sweep over a full render's traced strings. Its // exemptions are leakExempt, leakUnchanged and leakCollisions, lists // written down, so nothing a catalog says can widen them; the controls // build it again after changing the catalogs all the same, so that // stays true if an exemption is ever drawn from them. func leakSweepFor(t *testing.T, english map[string]string) leakSweep { t.Helper() return newLeakSweep(tracedIDs(t), english) } func asRendered(s string) string { var b strings.Builder if err := renderedText.Execute(&b, s); err != nil { panic(err) } return b.String() } var renderedText = template.Must(template.New("").Parse("{{.}}")) // escapedSentences are traced strings html/template writes escaped: one // with an apostrophe, one with a quote and one with a plus sign, where // the gallery says any, each a whole sentence the sweep looks for. func escapedSentences(t *testing.T) map[string]string { t.Helper() english := englishStrings(t) ids := tracedIDs(t) sorted := make([]string, 0, len(ids)) for id := range ids { sorted = append(sorted, id) } sort.Strings(sorted) out := map[string]string{} for _, mark := range []string{"'", `"`, "+"} { for _, id := range sorted { en := english[id] if strings.Contains(en, mark) && !placeholderName.MatchString(en) && len([]rune(en)) >= leakFloor && en == squash(en) && !strings.HasPrefix(id, "labels.") { out[id] = en break } } } if len(out) < 2 { t.Fatalf("found %d escaped sentences to try, want at least the apostrophe and the quote", len(out)) } return out } // TestTheLeakSweepSeesEscapedPunctuation: a sentence html/template // writes with ', " or + is still reported. Each one, with // its Irish translation put back to the English, is on the Irish pages // only in its escaped form, and the sweep reports it there. func TestTheLeakSweepSeesEscapedPunctuation(t *testing.T) { s := newTestSite(t, fiesta.FS(), "") cs := loadedCatalogs(t) for id, en := range escapedSentences(t) { if asRendered(en) == en { t.Errorf("%s (%q) is written as it is, so it is not the case this covers", id, en) } if got := englishLeaks(pageWords(t, []byte("

"+asRendered(en)+"

")), []string{en}); len(got) != 1 { t.Errorf("the rendered form of %q is not reported", en) } orig := cs["ga"][id] cs["ga"][id] = en files, err := Render(fiesta.FS(), Options{}) cs["ga"][id] = orig if err != nil { t.Fatal(err) } var raw strings.Builder reported := false for _, name := range translatedPages(s, files) { if !strings.Contains(name, "/ga/") { continue } raw.Write(files[name]) if len(englishLeaks(pageWords(t, files[name]), []string{en})) == 1 { reported = true } } if strings.Contains(raw.String(), en) { t.Errorf("the raw English of %s is on the Irish pages, so this case does not reach the escaped form", id) } if !reported { t.Errorf("with its Irish translation put back to the English, %s (%q) is not reported on any Irish page", id, en) } } } // literalText is every run of letters a template writes outside its // actions: the text between tags, an SVG's title and desc included, // and the values of the spoken attributes, however they are quoted. // The template is read by the HTML tokenizer with each action replaced // by actionHole, a private-use character no template writes, and a run // is cut at each hole: what an action prints is a lookup or a field. Script and style say nothing; an SVG's drawing data (d, // points, viewBox) is in attributes that are not spoken, so it is // never read. func literalText(src string) []string { z := xhtml.NewTokenizer(strings.NewReader(templateAction.ReplaceAllString(src, actionHole))) var out []string keep := func(chunk string) { for _, piece := range strings.Split(chunk, actionHole) { if c := squash(piece); c != "" && templateLetter.MatchString(c) { out = append(out, c) } } } silent := false for { switch tt := z.Next(); tt { case xhtml.ErrorToken: return out case xhtml.StartTagToken, xhtml.SelfClosingTagToken: name, more := z.TagName() for more { var k, v []byte k, v, more = z.TagAttr() if leakSpoken[string(k)] { keep(string(v)) } } if tt == xhtml.StartTagToken && (string(name) == "script" || string(name) == "style") { silent = true } case xhtml.EndTagToken: if name, _ := z.TagName(); string(name) == "script" || string(name) == "style" { silent = false } case xhtml.TextToken: if !silent { keep(string(z.Text())) } } } } var ( // An action, non-greedy: a say call's arguments carry no braces. templateAction = regexp.MustCompile(`(?s)\{\{.*?\}\}`) templateLetter = regexp.MustCompile(`\p{L}`) ) // actionHole stands where an action was. const actionHole = "\ue000" // templateFixtures are the runs of letters a template may write as // they are, because they are the same in every language: the type // specimen beside each font-size token, as Rastrillo's list has it, // and the two halves of the icon file path, icons/.svg, which // is code a reader copies. Adding to it is a claim that a string is // not a word. var templateFixtures = map[string]bool{ "Ag": true, "icons/": true, ".svg": true, } // TestNoUnregisteredEnglishInThePageTemplates: every word the page, // body and view templates put on a page comes out of an action, a // {{say …}} or a field, so no English is written straight into a // template where the catalogs cannot reach it; templateFixtures names // the few runs that are not words. The demo's documents are not among // these templates: their words are sample content. The controls are a // set of scratch templates: literal words in text, in an SVG's title // and desc, and in spoken attributes quoted either way or not at all, // which the extractor finds, and words in a script or a style and SVG // drawing data, which it does not. func TestNoUnregisteredEnglishInThePageTemplates(t *testing.T) { if len(templateSources) < 8 { t.Fatalf("%d registered templates; the page, the view and the six bodies are eight", len(templateSources)) } fixtures := 0 for i, src := range templateSources { name := regexp.MustCompile(`\{\{define "([^"]+)"\}\}`).FindStringSubmatch(src) for _, s := range literalText(src) { if templateFixtures[s] { fixtures++ continue } t.Errorf("template %d (%v) writes %q literally; say it through {{say …}} and a copy.go key", i, name, s) } } if fixtures != len(templateFixtures) { t.Errorf("the templates write %d of the %d fixtures; a fixture no template writes any more is to be dropped", fixtures, len(templateFixtures)) } for _, c := range []struct { src string want []string }{ {`{{define "x"}}

{{say "gallery.brand"}} there

{{end}}`, []string{"Hello", "there"}}, {``, []string{"Settings", "A cog"}}, {`Logo`, []string{"Close", "Logo"}}, {`

{{.Name}}

`, nil}, } { if got := literalText(c.src); !slices.Equal(got, c.want) { t.Errorf("the extractor reads %q from %s, want %q", got, c.src, c.want) } } }