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 "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
32var catalogFiles embed.FS
33
34// catalog is one locale's translations, by id.
35type catalog map[string]string
36
37// catalogs is every non-English locale's catalog, read once.
38var 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.
43func 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.
64func 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.
82func 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.
89func (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.
101func (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.
112func (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.
123func (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.
142func 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.
182var 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.
186var 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.
192func 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.
216var 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.
256func 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.
271var 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.
277func 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.
287var 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.
292func 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.
296func (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.
321var (
322 traceMu sync.Mutex
323 traceFn func(string)
324)
325
326func 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.
336func 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