//go:build fiestaseed // The Fiesta translation exporter for internal/designsystem. // hack/seed/run-translations.sh copies this file into a scratch // rastrillo worktree at the given commit and runs TestFiestaTranslations // there, with FIESTA_ENGLISH naming the file Fiesta's hack/seed/english // wrote: every English string the Fiesta gallery says, by id. Each one // is matched byte for byte against the gallery's own tables, prose and // sampleProse (merged into prose at init), and then against the // framework's base catalogs by their English value, which is where the // shell's words and each language's own name live. Under // FIESTA_SEED_OUT it writes: // // - internal/gallery/i18n/.json for the eleven locales that // are not English, every id sorted, the hits carried; // - internal/gallery/i18n/samples..json for the same eleven, // the sample strings (every id samples.), keyed as Rastrillo's // sampleProse is, by the key: the English, or the English and its // sense ("Archive (verb)"), which is what the lookup matches; // - internal/gallery/i18n/money.json, moneySampleMarkup's output for // each of the twelve locales, English included: the Money sample is // formatted per locale from data and is not translated by key; // - internal/gallery/i18n/drafts.json, the entries that are machine // drafts nobody has reviewed, each with the English it was drafted // from and the locales it was drafted in; // - hack/seed/translations-todo.json, the misses with no draft yet, // each with its id, its English and the locales it lacks; the file // is removed when there are none. // // A draft already in a catalog is kept on a later run while drafts.json // says it was drafted from the English the id has now. A hit replaces // it; English that has changed since puts the id back on the todo list. package designsystem import ( "bytes" "encoding/json" "errors" "html" "html/template" "io/fs" "os" "path/filepath" "slices" "sort" "strings" "testing" "amadan.net/rastrillo/rastrillo" ) // translationDraft is one entry of drafts.json. type translationDraft struct { English string `json:"english"` Locales []string `json:"locales"` } // translationMiss is one entry of translations-todo.json. type translationMiss struct { ID string `json:"id"` English string `json:"english"` Locales []string `json:"locales"` } func TestFiestaTranslations(t *testing.T) { out := os.Getenv("FIESTA_SEED_OUT") englishFile := os.Getenv("FIESTA_ENGLISH") if out == "" || englishFile == "" { t.Skip("FIESTA_SEED_OUT and FIESTA_ENGLISH are set by hack/seed/run-translations.sh") } var english map[string]string readJSON(t, englishFile, &english) if len(english) == 0 { t.Fatalf("%s holds no strings", englishFile) } dir := filepath.Join(out, "internal", "gallery", "i18n") todoFile := filepath.Join(out, "hack", "seed", "translations-todo.json") base := rastrillo.BaseCatalogs() // The base catalog's keys by their English value, sorted, so a // string two keys share is looked up in a fixed order. byEnglish := map[string][]string{} for key, en := range base["en"] { byEnglish[en] = append(byEnglish[en], key) } for _, keys := range byEnglish { sort.Strings(keys) } // lookup is Rastrillo's translation of en into locale, and which // table carried it. lookup := func(en, locale string) (string, string) { if row, ok := prose[en]; ok && row[locale] != "" { if _, sample := sampleProse[en]; sample { return row[locale], "sampleProse" } return row[locale], "prose" } for _, key := range byEnglish[en] { if tr := base[locale][key]; tr != "" { return tr, "locales" } } return "", "" } var locales []string for _, l := range rastrillo.BaseLocales() { if l != "en" { locales = append(locales, l) } } sort.Strings(locales) old := map[string]map[string]string{} for _, l := range locales { c := map[string]string{} readJSONIfThere(t, filepath.Join(dir, l+".json"), &c) old[l] = c } oldDrafts := map[string]translationDraft{} readJSONIfThere(t, filepath.Join(dir, "drafts.json"), &oldDrafts) // A sample id is its key with samplePrefix in front; the key is what // Rastrillo's tables are matched by and what its catalog files the // translation under. const samplePrefix = "samples." keyOf := func(id string) (string, bool) { if k, ok := strings.CutPrefix(id, samplePrefix); ok { return k, true } return english[id], false } oldSamples := map[string]map[string]string{} for _, l := range locales { c := map[string]string{} readJSONIfThere(t, filepath.Join(dir, "samples."+l+".json"), &c) oldSamples[l] = c } oldValue := func(l, id string) string { if k, sample := keyOf(id); sample { return oldSamples[l][k] } return old[l][id] } ids := make([]string, 0, len(english)) for id := range english { ids = append(ids, id) } sort.Strings(ids) catalogs := map[string]map[string]string{} samples := map[string]map[string]string{} for _, l := range locales { catalogs[l] = map[string]string{} samples[l] = map[string]string{} } put := func(l, id, tr string) { if k, sample := keyOf(id); sample { samples[l][k] = tr return } catalogs[l][id] = tr } drafts := map[string]translationDraft{} var todo []translationMiss carried := map[string]map[string]int{} drafted := map[string]int{} for _, l := range locales { carried[l] = map[string]int{} } sampleCarried := map[string]int{} sampleDrafted := map[string]int{} for _, id := range ids { en := english[id] key, sample := keyOf(id) var missing, kept []string for _, l := range locales { tr, table := lookup(key, l) if table == "" && sample && !strings.Contains(key, "<") && html.UnescapeString(key) != key { // A sample's text as html/template wrote it into the // seed (It's, +18s) is the same words as // Rastrillo's table writes them plain, and its // translation goes in written the same way. if tr, table = lookup(html.UnescapeString(key), l); table != "" { tr = template.HTMLEscapeString(tr) } } if table != "" { put(l, id, tr) carried[l][table]++ if sample { sampleCarried[l]++ } continue } d, ok := oldDrafts[id] if ok && d.English == en && slices.Contains(d.Locales, l) && oldValue(l, id) != "" { put(l, id, oldValue(l, id)) kept = append(kept, l) drafted[l]++ if sample { sampleDrafted[l]++ } continue } missing = append(missing, l) } if len(kept) > 0 { drafts[id] = translationDraft{English: en, Locales: kept} } if len(missing) > 0 { todo = append(todo, translationMiss{ID: id, English: en, Locales: missing}) } } if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } for _, l := range locales { writeJSON(t, filepath.Join(dir, l+".json"), catalogs[l]) writeJSON(t, filepath.Join(dir, "samples."+l+".json"), samples[l]) } money := map[string]string{} for _, l := range rastrillo.BaseLocales() { money[l] = moneySampleMarkup(l) } writeJSON(t, filepath.Join(dir, "money.json"), money) writeJSON(t, filepath.Join(dir, "drafts.json"), drafts) if len(todo) == 0 { if err := os.Remove(todoFile); err != nil && !errors.Is(err, fs.ErrNotExist) { t.Fatal(err) } } else { writeJSON(t, todoFile, todo) } t.Logf("%d English strings", len(ids)) for _, l := range locales { t.Logf("%-7s carried %3d (prose %d, sampleProse %d, locales %d), drafted %3d, missing %3d", l, carried[l]["prose"]+carried[l]["sampleProse"]+carried[l]["locales"], carried[l]["prose"], carried[l]["sampleProse"], carried[l]["locales"], drafted[l], len(ids)-carried[l]["prose"]-carried[l]["sampleProse"]-carried[l]["locales"]-drafted[l]) } for _, l := range locales { t.Logf("%-7s of them samples: carried %3d, drafted %3d", l, sampleCarried[l], sampleDrafted[l]) } t.Logf("%d ids on the todo list", len(todo)) } // readJSON decodes the file at path into v. func readJSON(t *testing.T, path string, v any) { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if err := json.Unmarshal(b, v); err != nil { t.Fatalf("%s: %v", path, err) } } // readJSONIfThere is readJSON for a file an earlier run may not have // written: a missing file leaves v as it is. func readJSONIfThere(t *testing.T, path string, v any) { t.Helper() if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return } readJSON(t, path, v) } // writeJSON writes v to path indented, with no HTML escaping, so a // translation reads in the file as it reads on the page; maps come out // sorted by key. func writeJSON(t *testing.T, path string, v any) { t.Helper() var b bytes.Buffer enc := json.NewEncoder(&b) enc.SetEscapeHTML(false) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { t.Fatal(err) } if err := os.WriteFile(path, b.Bytes(), 0o644); err != nil { t.Fatal(err) } }