package gallery import ( "embed" "encoding/json" "fmt" "html/template" "io/fs" "regexp" "slices" "sync" "amadan.net/rastrillo/fiesta/example" ) // The words a page says, in the page's language. English is where it // was written: copy.go's text for the chrome, examples/prose.json for // the explanations, the manifest for the state labels. Every other // locale reads a catalog, i18n/.json, which maps an id to its // translation: a text id, a prose slot id as prose.each names it, or // labels... hack/seed/run-translations.sh writes the // catalogs, carrying Rastrillo's translations where Rastrillo says the // same English and listing the rest for drafting; i18n/drafts.json // names the entries that are machine drafts nobody has reviewed. // // A catalog with no entry for an id falls back to the English, so a // page never shows an id; TestEveryProseKeyIsTranslated fails the // build while any traced id lacks one. Every lookup is traced, which // is how that gate knows what the pages asked for. //go:embed i18n/*.json var catalogFiles embed.FS // catalog is one locale's translations, by id. type catalog map[string]string // catalogs is every non-English locale's catalog, read once. var catalogs = sync.OnceValues(load) // load reads the eleven catalogs. A locale with no file, or a file // that is not a flat object of strings, is an error: the render // refuses it rather than showing a whole language in English. func load() (map[string]catalog, error) { out := map[string]catalog{} for _, locale := range Locales { if locale == "en" { continue } b, err := fs.ReadFile(catalogFiles, "i18n/"+locale+".json") if err != nil { return nil, fmt.Errorf("the %s catalog: %w", locale, err) } var c catalog if err := json.Unmarshal(b, &c); err != nil { return nil, fmt.Errorf("i18n/%s.json: %w", locale, err) } out[locale] = c } return out, nil } // translated is the catalog's string for id in locale, or en, the // English, for en and for an id the catalog does not answer. func translated(locale, id, en string) string { if locale == "en" { return en } cs, err := catalogs() if err != nil { return en } if t := cs[locale][id]; t != "" { return t } return en } // endonym is a language's name in itself, as the language menu writes // it on every page: the catalog's gallery.locale.name, which is the // framework catalog's own locale name carried, and not a lookup in the // page's language, so it is not traced. func endonym(locale string) string { return translated(locale, "gallery.locale.name", text["gallery.locale.name"]) } // say is the text filed under id, in locale, with each {name} replaced // by the value following it in args. An id nothing is filed under // panics, as the English say does. func (s *site) say(locale, id string, args ...string) string { trace(id) en, ok := text[id] if !ok { panic("gallery: no text for " + id) } return interpolate(translated(locale, id, en), args) } // proseIn is one prose string, identified by its slot id, in locale. // en is the English the prose holds for that slot. The empty string in // is the empty string out: a state with no note stays without one. func (s *site) proseIn(locale, id, en string) string { if en == "" { return "" } trace(id) return translated(locale, id, en) } // label is one state's label on the page, in locale, filed as // labels... A state the manifest leaves unlabelled // stays unlabelled in every language. func (s *site) label(locale string, e example.Entry, st example.State) string { if st.Label == "" { return "" } id := "labels." + e.Name + "." + st.ID trace(id) return translated(locale, id, st.Label) } // textOf resolves one page kind's title or blurb id: a gallery.* id // through say, any other through the prose. func (s *site) textOf(locale, id string, args ...string) string { if len(id) > len("gallery.") && id[:len("gallery.")] == "gallery." { return s.say(locale, id, args...) } return s.proseIn(locale, id, s.prose.english(id)) } // EnglishStrings is every string the design system's pages can say, // by the id a catalog files its translation under: every text id, // every prose slot (a warning's title and body each a slot of their // own), every manifest state label as labels.., and // every sample string as samples., each with its English. A // sample's key is its English, or its English and its sense where // sampleSenses names one ("samples.Archive (verb)" says "Archive"); the // keys are collected by the walker the renderer translates with, over // every sample it walks (sampleSources), so the two ask for the same // phrases. A slot or label that is empty says nothing and is not // listed. hack/seed/english writes it out for the translation // exporter. func EnglishStrings(src fs.FS) (map[string]string, error) { m, err := example.Load(src) if err != nil { return nil, fmt.Errorf("gallery: %w", err) } p, err := loadProse(src) if err != nil { return nil, fmt.Errorf("gallery: %w", err) } out := map[string]string{} for id, en := range text { out[id] = en } p.each(func(id, en string) { if en != "" { out[id] = en } }) for _, e := range m.Examples { for _, st := range e.States { if st.Label != "" { out["labels."+e.Name+"."+st.ID] = st.Label } } } samples, err := sampleSources(src, m) if err != nil { return nil, fmt.Errorf("gallery: %w", err) } for _, sm := range samples { for _, key := range sampleKeys(sm[0], sm[1]) { out[sampleID(key)] = withoutSense(key) } } return out, nil } // leakCodeWords are the tone names: code wearing the shape of a word, // the same four (and info) the class names carry and a caller passes, // so they stay as written in every language. var leakCodeWords = map[string]bool{"neutral": true, "positive": true, "warning": true, "negative": true, "info": true} // leakIdentifier is a value that is a name in code: a slug, a class or // a file name, the same on every page. var leakIdentifier = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z]+)?$`) // leakExempt says whether an English value may appear on a translated // page without being a leak, the one place those exemptions are // written: a language's own name (the language menu names every // language in itself on every page), the code words, and identifiers. func leakExempt(en string) bool { if leakCodeWords[en] || leakIdentifier.MatchString(en) { return true } for _, locale := range Locales { if en == endonym(locale) { return true } } return false } // leakUnchanged are the English strings a translation keeps exactly as // they are, each with the locales that keep it (nil is every locale): // the brand, the name of the HTML tab, and the title pattern, whose // only words are the brand's; "Tokens", which Spanish and Portuguese // write as English does; and the sample words a language writes as // English does, carried from Rastrillo's tables: the cities of the // select samples where the language keeps the English name, and the // product words (Beta, Pro, Plan, Editor, Email, Menu, Slug, Token, // No) where it borrows or shares them; and General, the settings // section Spanish names with the same word. Anything else a translated page // says in English is a leak, however short. Adding to this list is a // claim that the translation is right as it stands. var leakUnchanged = map[string][]string{ "Fiesta": nil, "HTML": nil, "{page} · Fiesta · {theme}": nil, "Tokens": {"es", "pt-PT"}, "Accra": {"ga", "vi"}, "Asia": {"es"}, "Athens": {"vi"}, "Austin": {"es", "ga", "pt-PT", "vi"}, "Berlin": {"vi"}, "Dhaka": {"vi"}, "Dublin": {"pt-PT", "vi"}, "Hanoi": {"ga"}, "Helsinki": {"es", "vi"}, "Lima": {"es", "pt-PT", "vi"}, "Lisbon": {"vi"}, "Madrid": {"es", "pt-PT", "vi"}, "Montréal": {"ga", "vi"}, "Nairobi": {"es", "ga", "vi"}, "Osaka": {"es", "pt-PT", "vi"}, "Reykjavík": {"vi"}, "Tallinn": {"pt-PT", "vi"}, "Tunis": {"vi"}, "Valletta": {"vi"}, "Zagreb": {"es", "pt-PT", "vi"}, "Beta": {"es", "pt-PT"}, "Editor": {"es", "pt-PT"}, "Email": {"vi"}, "General": {"es"}, "Menu": {"pt-PT"}, "No": {"es"}, "Plan": {"es"}, "Pro": {"es", "ga", "pt-PT"}, "Slug": {"es", "pt-PT"}, "Token": {"es", "pt-PT"}, } // unchangedIn says whether locale keeps en as it is, by leakUnchanged. func unchangedIn(locale, en string) bool { locales, ok := leakUnchanged[en] if !ok { return false } return locales == nil || slices.Contains(locales, locale) } // leakCollisions are the words a language uses for one string that // English spells like another string the gallery says, each with the // locales where that happens: Vietnamese names the Tokens page "Token", // which is also the English of a sample's column heading, translated // there. On a page in that locale the word is the language's own, not // English. Adding to this list is a claim about a word of that // language, and the leak gate checks the word is a translation there. var leakCollisions = map[string][]string{ "Token": {"vi"}, } // collidesIn says whether run, on a page in locale, is that locale's // own word by leakCollisions. func collidesIn(locale, run string) bool { return slices.Contains(leakCollisions[run], locale) } // templateSources is every template the pages are drawn from, each // registered by the file that owns it at init, so a page kind in a // file of its own brings its body template with it and nothing here // lists it. Go runs a package's init functions in file-name order, so // the order is fixed; it does not matter to the parse, since each // source is a {{define}} block of its own. var templateSources []string // registerTemplate adds one template source to the tree every locale's // templates are parsed from. Call it from an init function beside the // constant it registers. func registerTemplate(src string) { templateSources = append(templateSources, src) } // templates is every registered template parsed into one tree with say // bound to locale, built once per locale and kept for the render. func (s *site) templates(locale string) (*template.Template, error) { if t, ok := s.tmpl[locale]; ok { return t, nil } funcs := template.FuncMap{ "say": func(id string, args ...string) string { return s.say(locale, id, args...) }, "icon": s.iconMarkup, "anchor": anchorID, } t := template.New("gallery").Funcs(funcs) for _, src := range templateSources { if _, err := t.Parse(src); err != nil { return nil, err } } if s.tmpl == nil { s.tmpl = map[string]*template.Template{} } s.tmpl[locale] = t return t, nil } // The tracer. Every lookup the renderer makes is announced to traceFn // when one is installed, which is how a gate learns what the pages // asked for rather than what a list says they should. var ( traceMu sync.Mutex traceFn func(string) ) func trace(id string) { traceMu.Lock() fn := traceFn traceMu.Unlock() if fn != nil { fn(id) } } // setTrace installs a tracer and returns the function that removes it. func setTrace(fn func(string)) func() { traceMu.Lock() traceFn = fn traceMu.Unlock() return func() { traceMu.Lock() traceFn = nil traceMu.Unlock() } }