| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 4 | "embed" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "html/template" |
| 8 | "io/fs" |
| 9 | "regexp" |
| 10 | "slices" |
| 11 | "sync" |
| 12 | |
| 13 | "amadan.net/rastrillo/fiesta/example" |
| 14 | ) |
| 15 | |
| 16 | // The words a page says, in the page's language. English is where it |
| 17 | // was written: copy.go's text for the chrome, examples/prose.json for |
| 18 | // the explanations, the manifest for the state labels. Every other |
| 19 | // locale reads a catalog, i18n/<locale>.json, which maps an id to its |
| 20 | // translation: a text id, a prose slot id as prose.each names it, or |
| 21 | // labels.<example>.<state>. hack/seed/run-translations.sh writes the |
| 22 | // catalogs, carrying Rastrillo's translations where Rastrillo says the |
| 23 | // same English and listing the rest for drafting; i18n/drafts.json |
| 24 | // names the entries that are machine drafts nobody has reviewed. |
| 25 | // |
| 26 | // A catalog with no entry for an id falls back to the English, so a |
| 27 | // page never shows an id; TestEveryProseKeyIsTranslated fails the |
| 28 | // build while any traced id lacks one. Every lookup is traced, which |
| 29 | // is how that gate knows what the pages asked for. |
| 30 | |
| 31 | //go:embed i18n/*.json |
| 32 | var catalogFiles embed.FS |
| 33 | |
| 34 | // catalog is one locale's translations, by id. |
| 35 | type catalog map[string]string |
| 36 | |
| 37 | // catalogs is every non-English locale's catalog, read once. |
| 38 | var catalogs = sync.OnceValues(load) |
| 39 | |
| 40 | // load reads the eleven catalogs. A locale with no file, or a file |
| 41 | // that is not a flat object of strings, is an error: the render |
| 42 | // refuses it rather than showing a whole language in English. |
| 43 | func load() (map[string]catalog, error) { |
| 44 | out := map[string]catalog{} |
| 45 | for _, locale := range Locales { |
| 46 | if locale == "en" { |
| 47 | continue |
| 48 | } |
| 49 | b, err := fs.ReadFile(catalogFiles, "i18n/"+locale+".json") |
| 50 | if err != nil { |
| 51 | return nil, fmt.Errorf("the %s catalog: %w", locale, err) |
| 52 | } |
| 53 | var c catalog |
| 54 | if err := json.Unmarshal(b, &c); err != nil { |
| 55 | return nil, fmt.Errorf("i18n/%s.json: %w", locale, err) |
| 56 | } |
| 57 | out[locale] = c |
| 58 | } |
| 59 | return out, nil |
| 60 | } |
| 61 | |
| 62 | // translated is the catalog's string for id in locale, or en, the |
| 63 | // English, for en and for an id the catalog does not answer. |
| 64 | func translated(locale, id, en string) string { |
| 65 | if locale == "en" { |
| 66 | return en |
| 67 | } |
| 68 | cs, err := catalogs() |
| 69 | if err != nil { |
| 70 | return en |
| 71 | } |
| 72 | if t := cs[locale][id]; t != "" { |
| 73 | return t |
| 74 | } |
| 75 | return en |
| 76 | } |
| 77 | |
| 78 | // endonym is a language's name in itself, as the language menu writes |
| 79 | // it on every page: the catalog's gallery.locale.name, which is the |
| 80 | // framework catalog's own locale name carried, and not a lookup in the |
| 81 | // page's language, so it is not traced. |
| 82 | func endonym(locale string) string { |
| 83 | return translated(locale, "gallery.locale.name", text["gallery.locale.name"]) |
| 84 | } |
| 85 | |
| 86 | // say is the text filed under id, in locale, with each {name} replaced |
| 87 | // by the value following it in args. An id nothing is filed under |
| 88 | // panics, as the English say does. |
| 89 | func (s *site) say(locale, id string, args ...string) string { |
| 90 | trace(id) |
| 91 | en, ok := text[id] |
| 92 | if !ok { |
| 93 | panic("gallery: no text for " + id) |
| 94 | } |
| 95 | return interpolate(translated(locale, id, en), args) |
| 96 | } |
| 97 | |
| 98 | // proseIn is one prose string, identified by its slot id, in locale. |
| 99 | // en is the English the prose holds for that slot. The empty string in |
| 100 | // is the empty string out: a state with no note stays without one. |
| 101 | func (s *site) proseIn(locale, id, en string) string { |
| 102 | if en == "" { |
| 103 | return "" |
| 104 | } |
| 105 | trace(id) |
| 106 | return translated(locale, id, en) |
| 107 | } |
| 108 | |
| 109 | // label is one state's label on the page, in locale, filed as |
| 110 | // labels.<example>.<state>. A state the manifest leaves unlabelled |
| 111 | // stays unlabelled in every language. |
| 112 | func (s *site) label(locale string, e example.Entry, st example.State) string { |
| 113 | if st.Label == "" { |
| 114 | return "" |
| 115 | } |
| 116 | id := "labels." + e.Name + "." + st.ID |
| 117 | trace(id) |
| 118 | return translated(locale, id, st.Label) |
| 119 | } |
| 120 | |
| 121 | // textOf resolves one page kind's title or blurb id: a gallery.* id |
| 122 | // through say, any other through the prose. |
| 123 | func (s *site) textOf(locale, id string, args ...string) string { |
| 124 | if len(id) > len("gallery.") && id[:len("gallery.")] == "gallery." { |
| 125 | return s.say(locale, id, args...) |
| 126 | } |
| 127 | return s.proseIn(locale, id, s.prose.english(id)) |
| 128 | } |
| 129 | |
| 130 | // EnglishStrings is every string the design system's pages can say, |
| 131 | // by the id a catalog files its translation under: every text id, |
| 132 | // every prose slot (a warning's title and body each a slot of their |
| 133 | // own), every manifest state label as labels.<example>.<state>, and |
| 134 | // every sample string as samples.<key>, each with its English. A |
| 135 | // sample's key is its English, or its English and its sense where |
| 136 | // sampleSenses names one ("samples.Archive (verb)" says "Archive"); the |
| 137 | // keys are collected by the walker the renderer translates with, over |
| 138 | // every sample it walks (sampleSources), so the two ask for the same |
| 139 | // phrases. A slot or label that is empty says nothing and is not |
| 140 | // listed. hack/seed/english writes it out for the translation |
| 141 | // exporter. |
| 142 | func EnglishStrings(src fs.FS) (map[string]string, error) { |
| 143 | m, err := example.Load(src) |
| 144 | if err != nil { |
| 145 | return nil, fmt.Errorf("gallery: %w", err) |
| 146 | } |
| 147 | p, err := loadProse(src) |
| 148 | if err != nil { |
| 149 | return nil, fmt.Errorf("gallery: %w", err) |
| 150 | } |
| 151 | out := map[string]string{} |
| 152 | for id, en := range text { |
| 153 | out[id] = en |
| 154 | } |
| 155 | p.each(func(id, en string) { |
| 156 | if en != "" { |
| 157 | out[id] = en |
| 158 | } |
| 159 | }) |
| 160 | for _, e := range m.Examples { |
| 161 | for _, st := range e.States { |
| 162 | if st.Label != "" { |
| 163 | out["labels."+e.Name+"."+st.ID] = st.Label |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | samples, err := sampleSources(src, m) |
| 168 | if err != nil { |
| 169 | return nil, fmt.Errorf("gallery: %w", err) |
| 170 | } |
| 171 | for _, sm := range samples { |
| 172 | for _, key := range sampleKeys(sm[0], sm[1]) { |
| 173 | out[sampleID(key)] = withoutSense(key) |
| 174 | } |
| 175 | } |
| 176 | return out, nil |
| 177 | } |
| 178 | |
| 179 | // leakCodeWords are the tone names: code wearing the shape of a word, |
| 180 | // the same four (and info) the class names carry and a caller passes, |
| 181 | // so they stay as written in every language. |
| 182 | var leakCodeWords = map[string]bool{"neutral": true, "positive": true, "warning": true, "negative": true, "info": true} |
| 183 | |
| 184 | // leakIdentifier is a value that is a name in code: a slug, a class or |
| 185 | // a file name, the same on every page. |
| 186 | var leakIdentifier = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z]+)?$`) |
| 187 | |
| 188 | // leakExempt says whether an English value may appear on a translated |
| 189 | // page without being a leak, the one place those exemptions are |
| 190 | // written: a language's own name (the language menu names every |
| 191 | // language in itself on every page), the code words, and identifiers. |
| 192 | func leakExempt(en string) bool { |
| 193 | if leakCodeWords[en] || leakIdentifier.MatchString(en) { |
| 194 | return true |
| 195 | } |
| 196 | for _, locale := range Locales { |
| 197 | if en == endonym(locale) { |
| 198 | return true |
| 199 | } |
| 200 | } |
| 201 | return false |
| 202 | } |
| 203 | |
| 204 | // leakUnchanged are the English strings a translation keeps exactly as |
| 205 | // they are, each with the locales that keep it (nil is every locale): |
| 206 | // the brand, the name of the HTML tab, and the title pattern, whose |
| 207 | // only words are the brand's; "Tokens", which Spanish and Portuguese |
| 208 | // write as English does; and the sample words a language writes as |
| 209 | // English does, carried from Rastrillo's tables: the cities of the |
| 210 | // select samples where the language keeps the English name, and the |
| 211 | // product words (Beta, Pro, Plan, Editor, Email, Menu, Slug, Token, |
| 212 | // No) where it borrows or shares them; and General, the settings |
| 213 | // section Spanish names with the same word. Anything else a translated page |
| 214 | // says in English is a leak, however short. Adding to this list is a |
| 215 | // claim that the translation is right as it stands. |
| 216 | var leakUnchanged = map[string][]string{ |
| 217 | "Fiesta": nil, |
| 218 | "HTML": nil, |
| 219 | "{page} · Fiesta · {theme}": nil, |
| 220 | "Tokens": {"es", "pt-PT"}, |
| 221 | |
| 222 | "Accra": {"ga", "vi"}, |
| 223 | "Asia": {"es"}, |
| 224 | "Athens": {"vi"}, |
| 225 | "Austin": {"es", "ga", "pt-PT", "vi"}, |
| 226 | "Berlin": {"vi"}, |
| 227 | "Dhaka": {"vi"}, |
| 228 | "Dublin": {"pt-PT", "vi"}, |
| 229 | "Hanoi": {"ga"}, |
| 230 | "Helsinki": {"es", "vi"}, |
| 231 | "Lima": {"es", "pt-PT", "vi"}, |
| 232 | "Lisbon": {"vi"}, |
| 233 | "Madrid": {"es", "pt-PT", "vi"}, |
| 234 | "Montréal": {"ga", "vi"}, |
| 235 | "Nairobi": {"es", "ga", "vi"}, |
| 236 | "Osaka": {"es", "pt-PT", "vi"}, |
| 237 | "Reykjavík": {"vi"}, |
| 238 | "Tallinn": {"pt-PT", "vi"}, |
| 239 | "Tunis": {"vi"}, |
| 240 | "Valletta": {"vi"}, |
| 241 | "Zagreb": {"es", "pt-PT", "vi"}, |
| 242 | |
| 243 | "Beta": {"es", "pt-PT"}, |
| 244 | "Editor": {"es", "pt-PT"}, |
| 245 | "Email": {"vi"}, |
| 246 | "General": {"es"}, |
| 247 | "Menu": {"pt-PT"}, |
| 248 | "No": {"es"}, |
| 249 | "Plan": {"es"}, |
| 250 | "Pro": {"es", "ga", "pt-PT"}, |
| 251 | "Slug": {"es", "pt-PT"}, |
| 252 | "Token": {"es", "pt-PT"}, |
| 253 | } |
| 254 | |
| 255 | // unchangedIn says whether locale keeps en as it is, by leakUnchanged. |
| 256 | func unchangedIn(locale, en string) bool { |
| 257 | locales, ok := leakUnchanged[en] |
| 258 | if !ok { |
| 259 | return false |
| 260 | } |
| 261 | return locales == nil || slices.Contains(locales, locale) |
| 262 | } |
| 263 | |
| 264 | // leakCollisions are the words a language uses for one string that |
| 265 | // English spells like another string the gallery says, each with the |
| 266 | // locales where that happens: Vietnamese names the Tokens page "Token", |
| 267 | // which is also the English of a sample's column heading, translated |
| 268 | // there. On a page in that locale the word is the language's own, not |
| 269 | // English. Adding to this list is a claim about a word of that |
| 270 | // language, and the leak gate checks the word is a translation there. |
| 271 | var leakCollisions = map[string][]string{ |
| 272 | "Token": {"vi"}, |
| 273 | } |
| 274 | |
| 275 | // collidesIn says whether run, on a page in locale, is that locale's |
| 276 | // own word by leakCollisions. |
| 277 | func collidesIn(locale, run string) bool { |
| 278 | return slices.Contains(leakCollisions[run], locale) |
| 279 | } |
| 280 | |
| 281 | // templateSources is every template the pages are drawn from, each |
| 282 | // registered by the file that owns it at init, so a page kind in a |
| 283 | // file of its own brings its body template with it and nothing here |
| 284 | // lists it. Go runs a package's init functions in file-name order, so |
| 285 | // the order is fixed; it does not matter to the parse, since each |
| 286 | // source is a {{define}} block of its own. |
| 287 | var templateSources []string |
| 288 | |
| 289 | // registerTemplate adds one template source to the tree every locale's |
| 290 | // templates are parsed from. Call it from an init function beside the |
| 291 | // constant it registers. |
| 292 | func registerTemplate(src string) { templateSources = append(templateSources, src) } |
| 293 | |
| 294 | // templates is every registered template parsed into one tree with say |
| 295 | // bound to locale, built once per locale and kept for the render. |
| 296 | func (s *site) templates(locale string) (*template.Template, error) { |
| 297 | if t, ok := s.tmpl[locale]; ok { |
| 298 | return t, nil |
| 299 | } |
| 300 | funcs := template.FuncMap{ |
| 301 | "say": func(id string, args ...string) string { return s.say(locale, id, args...) }, |
| 302 | "icon": s.iconMarkup, |
| 303 | "anchor": anchorID, |
| 304 | } |
| 305 | t := template.New("gallery").Funcs(funcs) |
| 306 | for _, src := range templateSources { |
| 307 | if _, err := t.Parse(src); err != nil { |
| 308 | return nil, err |
| 309 | } |
| 310 | } |
| 311 | if s.tmpl == nil { |
| 312 | s.tmpl = map[string]*template.Template{} |
| 313 | } |
| 314 | s.tmpl[locale] = t |
| 315 | return t, nil |
| 316 | } |
| 317 | |
| 318 | // The tracer. Every lookup the renderer makes is announced to traceFn |
| 319 | // when one is installed, which is how a gate learns what the pages |
| 320 | // asked for rather than what a list says they should. |
| 321 | var ( |
| 322 | traceMu sync.Mutex |
| 323 | traceFn func(string) |
| 324 | ) |
| 325 | |
| 326 | func trace(id string) { |
| 327 | traceMu.Lock() |
| 328 | fn := traceFn |
| 329 | traceMu.Unlock() |
| 330 | if fn != nil { |
| 331 | fn(id) |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // setTrace installs a tracer and returns the function that removes it. |
| 336 | func setTrace(fn func(string)) func() { |
| 337 | traceMu.Lock() |
| 338 | traceFn = fn |
| 339 | traceMu.Unlock() |
| 340 | return func() { |
| 341 | traceMu.Lock() |
| 342 | traceFn = nil |
| 343 | traceMu.Unlock() |
| 344 | } |
| 345 | } |
| 346 | |