package www import ( "bytes" "encoding/json" "fmt" "io" "io/fs" "path" "regexp" "slices" "strings" ) // Languages is every language the landing page is built in, English // first and the rest in code order. Arabic is written right to left. // yue is in Fiesta's own string tables but is not a site language. var Languages = []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "zh-Hans"} // Dir is the direction lang is written in, as the html element's dir // attribute wants it: rtl for Arabic, ltr for everything else. func Dir(lang string) string { if lang == "ar" { return "rtl" } return "ltr" } // RequiredKeys is every key a landing page catalog holds, sorted. A // catalog has exactly these: a key the page would render empty, or one // the page never reads, is a mistake Strings reports. var RequiredKeys = []string{ "copied", "copy", "copy.failed", "cta.see", "cta.themes", "description", "foot.carlos.framework", "foot.carlos.platform", "foot.carlos.start", "foot.docs", "foot.eleven", "foot.letters", "foot.licence", "foot.principles", "foot.source", "foot.tagline", "foot.trust", "foot.version", "headline", "install", "lang.label", "lede", "nav.design", "nav.label", "nav.start", "nav.themes", "sample.cancel", "sample.coming", "sample.courses", "sample.desserts", "sample.dish.glutenfree", "sample.dish.nuts", "sample.dish.vegetarian", "sample.dishes", "sample.email", "sample.export", "sample.fade", "sample.guest.noreply", "sample.guest.plusone", "sample.guest.vegetarian", "sample.guests", "sample.invite", "sample.invited", "sample.label", "sample.mains", "sample.menu", "sample.music", "sample.nav.label", "sample.next", "sample.next.body", "sample.overview", "sample.partyname", "sample.playing", "sample.public", "sample.reminders", "sample.save", "sample.saved", "sample.search", "sample.searched", "sample.settings", "sample.shuffle", "sample.songs", "sample.starters", "sample.timezone", "scheme.dark", "scheme.label", "scheme.light", "scheme.system", "skip", "theme.day", "theme.label", "theme.plain", "theme.signal", "title", "why.a11y.body", "why.a11y.title", "why.attrs.body", "why.attrs.title", "why.dark.body", "why.dark.title", "why.depth.body", "why.depth.title", "why.link.body", "why.link.title", "why.rtl.body", "why.rtl.title", } // stringsDir is the directory under the site source holding the // landing page's catalogs, one .json each: a JSON object from // key to string, nothing else. const stringsDir = "landing/strings" // placeholder is a {name} a template fills in. The English string // decides which placeholders a key has; a translation holds the same // ones, or the page would say something different in that language. var placeholder = regexp.MustCompile(`\{[^{}]*\}`) // Strings reads the landing page's catalog for lang from the site // source, landing/strings/.json, and returns it key by key. It // refuses a catalog that is missing, that lacks a required key or // holds one the page never reads, that leaves a value empty, or that // drops or adds a placeholder the English string has; each error names // the key. English is read alongside every other language for the // placeholder check. func Strings(siteDir fs.FS, lang string) (map[string]string, error) { out, err := catalog(siteDir, lang) if err != nil { return nil, err } if lang == "en" { return out, nil } en, err := catalog(siteDir, "en") if err != nil { return nil, err } for _, key := range RequiredKeys { want, got := placeholder.FindAllString(en[key], -1), placeholder.FindAllString(out[key], -1) slices.Sort(want) slices.Sort(got) if !slices.Equal(want, got) { return nil, fmt.Errorf("www: %s: %s has placeholders %v, but the English has %v", name(lang), key, got, want) } } return out, nil } // name is the catalog's path under the site source. func name(lang string) string { return path.Join(stringsDir, lang+".json") } // catalog reads one language's catalog and checks it against // RequiredKeys. It decodes token by token rather than into a map, so // a key written twice is refused instead of the second quietly // winning, and so a value that is not a string is named. func catalog(siteDir fs.FS, lang string) (map[string]string, error) { data, err := fs.ReadFile(siteDir, name(lang)) if err != nil { return nil, fmt.Errorf("www: reading the %s catalog: %w", lang, err) } dec := json.NewDecoder(bytes.NewReader(data)) if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { return nil, fmt.Errorf("www: %s is not a JSON object", name(lang)) } out := make(map[string]string, len(RequiredKeys)) for dec.More() { tok, err := dec.Token() if err != nil { return nil, fmt.Errorf("www: %s: %w", name(lang), err) } key := tok.(string) if _, dup := out[key]; dup { return nil, fmt.Errorf("www: %s: %s is written twice", name(lang), key) } var value string if err := dec.Decode(&value); err != nil { return nil, fmt.Errorf("www: %s: %s: %w", name(lang), key, err) } if strings.TrimSpace(value) == "" { return nil, fmt.Errorf("www: %s: %s is empty", name(lang), key) } out[key] = value } if _, err := dec.Token(); err != nil { return nil, fmt.Errorf("www: %s: %w", name(lang), err) } if _, err := dec.Token(); err != io.EOF { return nil, fmt.Errorf("www: %s has content after the object", name(lang)) } for _, key := range RequiredKeys { if _, ok := out[key]; !ok { return nil, fmt.Errorf("www: %s lacks %s", name(lang), key) } } if len(out) != len(RequiredKeys) { for key := range out { if _, ok := slices.BinarySearch(RequiredKeys, key); !ok { return nil, fmt.Errorf("www: %s has %s, which the page never reads", name(lang), key) } } } return out, nil }