From d99c5d1dc5084e750c47adb4863016f3bdc727dc Mon Sep 17 00:00:00 2001 From: KenanZhu Date: Wed, 14 Oct 2026 00:01:40 +0800 Subject: [PATCH] tools: align locale diagnostics and generate native language names --- CONTRIBUTING.md | 3 +++ docs/CONTRIBUTING.zh-CN.md | 3 +++ preview/index.html | 28 +-------------------- tools/cli/upstream.go | 6 +---- tools/go.mod | 1 + tools/go.sum | 2 ++ tools/logging/locale.go | 43 +++++++++++++++++++++++++++++++++ tools/logging/locale_test.go | 38 +++++++++++++++++++++++++++++ tools/preview/engine.go | 27 +++++++++++---------- tools/preview/languages.go | 21 ++++++++++++++++ tools/preview/languages_test.go | 33 +++++++++++++++++++++++++ tools/preview/parity_test.go | 4 +++ tools/upstream/keys.go | 7 +++++- 13 files changed, 170 insertions(+), 46 deletions(-) create mode 100644 tools/logging/locale.go create mode 100644 tools/logging/locale_test.go create mode 100644 tools/preview/languages.go create mode 100644 tools/preview/languages_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6c3f31..93f0b84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -198,6 +198,9 @@ go run . preview all Default output includes `preview/rendered.js` and `preview/rendered/.js`. Open [preview/index.html](preview/index.html) in a browser; no server is required. See the [README](README.md#controls-and-keyboard-shortcuts) for controls and shortcuts. +> [!NOTE] +> Native language names are generated for the actual preview locales from CLDR data in `golang.org/x/text/language/display` and written to the manifest as `language_names`. The preview interface reads this data directly, without maintaining a language-name table. + > [!IMPORTANT] > Each run overwrites the preview manifest at the output location instead of merging with previously selected themes. For example, after `preview aurora`, the manifest contains only Aurora. Run `preview all` to restore every theme. diff --git a/docs/CONTRIBUTING.zh-CN.md b/docs/CONTRIBUTING.zh-CN.md index 0fbd104..e438adb 100644 --- a/docs/CONTRIBUTING.zh-CN.md +++ b/docs/CONTRIBUTING.zh-CN.md @@ -198,6 +198,9 @@ go run . preview all 默认输出包括 `preview/rendered.js` 和 `preview/rendered/<语言>.js`。在浏览器中打开 [preview/index.html](../preview/index.html) 即可查看,无需启动服务器。操作和快捷键见 [README](README.zh-CN.md#控制和快捷键)。 +> [!NOTE] +> 语言名称由 `golang.org/x/text/language/display` 的 CLDR 数据按实际预览语言生成,并写入预览清单的 `language_names`。预览界面直接读取清单,无需维护语言名称表。 + > [!IMPORTANT] > 每次生成会覆盖输出位置的预览清单,不与先前选择的主题合并。例如,执行 `preview aurora` 后,该清单只包含 Aurora;需要恢复全部主题时执行 `preview all`。 diff --git a/preview/index.html b/preview/index.html index f593798..e4ea593 100644 --- a/preview/index.html +++ b/preview/index.html @@ -482,34 +482,8 @@ function fillSelect(id, entries, selected) { select.value=selected; } -// Stable native names also cover browsers with incomplete ICU locale data. -const LOCALE_NAMES = { - 'cs-CZ':'čeština (Česko)', 'de-DE':'Deutsch (Deutschland)', - 'el-GR':'Ελληνικά (Ελλάδα)', 'en-US':'English (United States)', - 'es-ES':'español (España)', 'fa-IR':'فارسی (ایران)', - 'fi-FI':'suomi (Suomi)', 'fr-FR':'français (France)', - 'ga-IE':'Gaeilge (Éire)', 'hu-HU':'magyar (Magyarország)', - 'id-ID':'Indonesia (Indonesia)', 'is-IS':'íslenska (Ísland)', - 'it-IT':'italiano (Italia)', 'ja-JP':'日本語 (日本)', - 'ko-KR':'한국어 (대한민국)', 'lv-LV':'latviešu (Latvija)', - 'nl-NL':'Nederlands (Nederland)', 'pl-PL':'polski (Polska)', - 'pt-BR':'português (Brasil)', 'pt-PT':'português (Portugal)', - 'ru-RU':'русский (Россия)', 'si-LK':'සිංහල (ශ්‍රී ලංකාව)', - 'sk-SK':'slovenčina (Slovensko)', - 'sv-SE':'svenska (Sverige)', 'tr-TR':'Türkçe (Türkiye)', - 'uk-UA':'українська (Україна)', 'zh-CN':'中文 (简体,中国)', - 'zh-TW':'中文 (繁體,台灣)' -}; - function localeLabel(locale) { - let name = LOCALE_NAMES[locale]; - if (!name) { - try { - if (Intl.DisplayNames.supportedLocalesOf([locale]).length) { - name = new Intl.DisplayNames([locale], {type:'language', fallback:'none'}).of(locale); - } - } catch (_) { /* Unknown languages remain selectable by their locale code. */ } - } + const name = manifest?.language_names?.[locale]; return name && name !== locale ? locale + ' — ' + name : locale; } diff --git a/tools/cli/upstream.go b/tools/cli/upstream.go index 9d13f28..48d602d 100644 --- a/tools/cli/upstream.go +++ b/tools/cli/upstream.go @@ -20,11 +20,7 @@ func UpstreamCommand() *cli.Command { return err } logging.Info("Upstream", "%s: %d mail files, %d languages", s.Lock.Tag, len(s.Templates), len(s.Locales)) - for _, lang := range s.Languages() { - if keys := missing[lang]; len(keys) > 0 { - logging.Warn("Locale", "%s -> en-US: %v", lang, keys) - } - } + logging.WarnMissingTranslations(missing) return nil }}, }} diff --git a/tools/go.mod b/tools/go.mod index 71566eb..555357c 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/urfave/cli/v2 v2.27.7 golang.org/x/net v0.46.0 + golang.org/x/text v0.31.0 ) require ( diff --git a/tools/go.sum b/tools/go.sum index a83ab75..5f0f02b 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -8,3 +8,5 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= diff --git a/tools/logging/locale.go b/tools/logging/locale.go new file mode 100644 index 0000000..08d90c1 --- /dev/null +++ b/tools/logging/locale.go @@ -0,0 +1,43 @@ +package logging + +import ( + "fmt" + "sort" + "strings" +) + +// WarnMissingTranslations groups missing keys without wrapping long key lists. +func WarnMissingTranslations(missing map[string][]string) { + if message := missingTranslations(missing); message != "" { + Warn("Locale", "%s", message) + } +} + +func missingTranslations(missing map[string][]string) string { + languages := make([]string, 0, len(missing)) + width := len("Language") + for lang, keys := range missing { + if len(keys) > 0 { + languages = append(languages, lang) + width = max(width, len(lang)) + } + } + if len(languages) == 0 { + return "" + } + sort.Strings(languages) + var message strings.Builder + fmt.Fprintf(&message, "Missing translation keys:\n%-*s %-8s %s\n", width, "Language", "Fallback", "Key") + for _, lang := range languages { + keys := append([]string(nil), missing[lang]...) + sort.Strings(keys) + for i, key := range keys { + label, fallback := "", "" + if i == 0 { + label, fallback = lang, "en-US" + } + fmt.Fprintf(&message, "%-*s %-8s %s\n", width, label, fallback, key) + } + } + return strings.TrimSuffix(message.String(), "\n") +} diff --git a/tools/logging/locale_test.go b/tools/logging/locale_test.go new file mode 100644 index 0000000..c0635cc --- /dev/null +++ b/tools/logging/locale_test.go @@ -0,0 +1,38 @@ +package logging + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +func TestMissingTranslations(t *testing.T) { + missing := map[string][]string{ + "fr-FR": {"mail.second", "mail.first"}, + "en-US": nil, + "de-DE": {"mail.first"}, + } + want := "Missing translation keys:\n" + + "Language Fallback Key\n" + + "de-DE en-US mail.first\n" + + "fr-FR en-US mail.first\n" + + " mail.second" + message := missingTranslations(missing) + if message != want { + t.Fatalf("got %q, want %q", message, want) + } + if !reflect.DeepEqual(missing["fr-FR"], []string{"mail.second", "mail.first"}) { + t.Fatal("report mutated input keys") + } + var output bytes.Buffer + write(&output, true, "W", "Locale", message) + for _, line := range strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") { + if !strings.HasPrefix(line, "\033[33m[W]\033[0m [Locale] ") { + t.Fatalf("missing warning prefix: %q", line) + } + } + if missingTranslations(nil) != "" || missingTranslations(map[string][]string{"en-US": nil}) != "" { + t.Fatal("empty report produced a warning") + } +} diff --git a/tools/preview/engine.go b/tools/preview/engine.go index 63d3c68..fcf1b8d 100644 --- a/tools/preview/engine.go +++ b/tools/preview/engine.go @@ -20,16 +20,17 @@ import ( ) type PreviewResult struct { - Rendered map[string]map[string]map[string]string - Registry map[string]map[string]string - Params map[string]map[string]string - Languages []string - Themes []string - Upstream string - Commit string - Logo []byte - Fallback map[string][]string - Warnings map[string]map[string][]string + Rendered map[string]map[string]map[string]string + Registry map[string]map[string]string + Params map[string]map[string]string + Languages []string + LanguageNames map[string]string + Themes []string + Upstream string + Commit string + Logo []byte + Fallback map[string][]string + Warnings map[string]map[string][]string } func DiscoverThemes(dir string) ([]string, error) { return builder.Discover(dir) } @@ -92,6 +93,7 @@ func RenderAll(themesDir string, cfg *config.TemplatesConfig, filter map[string] return nil, err } result := &PreviewResult{Rendered: map[string]map[string]map[string]string{}, Registry: config.Registry(cfg), Params: map[string]map[string]string{}, Languages: s.Languages(), Upstream: s.Lock.Tag, Fallback: missing, Warnings: map[string]map[string][]string{}} + result.LanguageNames = languageNames(result.Languages) for id, t := range cfg.Templates { result.Params[id] = config.FlattenParams(t.Params) } @@ -236,6 +238,7 @@ func WriteRenderedJS(result *PreviewResult, output string) error { return err } manifest := map[string]any{"schema": 1, "upstream": result.Upstream, "languages": result.Languages, "themes": result.Themes, "registry": result.Registry, "params": result.Params, "fallback": result.Fallback, "warnings": result.Warnings} + manifest["language_names"] = result.LanguageNames manifest["logo"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(result.Logo) manifest["upstream_commit"] = result.Commit data, err := json.Marshal(manifest) @@ -267,10 +270,8 @@ func PrintDetailedSummary(result *PreviewResult, _ string, _ *config.TemplatesCo logging.Warn("Upstream", "%s", strings.TrimPrefix(warning, "[UPSTREAM-WARN] ")) } } - if keys := result.Fallback[lang]; len(keys) > 0 { - logging.Warn("Locale", "%s -> en-US: %s", lang, strings.Join(keys, ", ")) - } } + logging.WarnMissingTranslations(result.Fallback) } func markSafeHTML(data map[string]any) { diff --git a/tools/preview/languages.go b/tools/preview/languages.go new file mode 100644 index 0000000..11b35a2 --- /dev/null +++ b/tools/preview/languages.go @@ -0,0 +1,21 @@ +package preview + +import ( + "golang.org/x/text/language" + "golang.org/x/text/language/display" +) + +// languageNames derives native names from CLDR for the generated preview locales. +func languageNames(locales []string) map[string]string { + names := make(map[string]string, len(locales)) + for _, locale := range locales { + name := locale + if tag, err := language.Parse(locale); err == nil { + if native := display.Self.Name(tag); native != "" { + name = native + } + } + names[locale] = name + } + return names +} diff --git a/tools/preview/languages_test.go b/tools/preview/languages_test.go new file mode 100644 index 0000000..bd0053a --- /dev/null +++ b/tools/preview/languages_test.go @@ -0,0 +1,33 @@ +package preview + +import ( + "path/filepath" + "testing" + + "gitea-mail-templates/tools/upstream" +) + +func TestNativeLanguageNames(t *testing.T) { + snapshot, err := upstream.LoadPinned(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + locales := snapshot.Languages() + names := languageNames(locales) + if len(names) != len(locales) { + t.Fatal("language names do not match preview locales") + } + for _, locale := range locales { + if names[locale] == "" || names[locale] == locale { + t.Errorf("missing native name for %s", locale) + } + } + for locale, want := range map[string]string{"ga-IE": "Gaeilge", "is-IS": "íslenska"} { + if names[locale] != want { + t.Errorf("%s: got %q, want %q", locale, names[locale], want) + } + } + if got := languageNames([]string{"!invalid"})["!invalid"]; got != "!invalid" { + t.Fatalf("invalid locale fallback: %q", got) + } +} diff --git a/tools/preview/parity_test.go b/tools/preview/parity_test.go index 5889b06..7d79018 100644 --- a/tools/preview/parity_test.go +++ b/tools/preview/parity_test.go @@ -294,6 +294,7 @@ func TestSubjectSplitAndMissingFixture(t *testing.T) { func TestPreviewBundles(t *testing.T) { result := &PreviewResult{Rendered: map[string]map[string]map[string]string{"en-US": {"test": {"activate": "hello"}}, "zh-CN": {"test": {"activate": "你好"}}}, Languages: []string{"en-US", "zh-CN"}, Themes: []string{"test"}, Registry: map[string]map[string]string{}, Upstream: "v28.0.0"} + result.LanguageNames = languageNames(result.Languages) output := filepath.Join(t.TempDir(), "rendered.js") if err := WriteRenderedJS(result, output); err != nil { t.Fatal(err) @@ -305,6 +306,9 @@ func TestPreviewBundles(t *testing.T) { if bytes.Contains(manifest, []byte("")) { t.Fatal("manifest embeds mail payload") } + if !bytes.Contains(manifest, []byte(`"language_names":`)) || !bytes.Contains(manifest, []byte(fmt.Sprintf("%q:%q", "en-US", result.LanguageNames["en-US"]))) { + t.Fatal("manifest does not include generated native language names") + } for _, lang := range result.Languages { data, err := os.ReadFile(filepath.Join(filepath.Dir(output), "rendered", lang+".js")) if err != nil { diff --git a/tools/upstream/keys.go b/tools/upstream/keys.go index 402d04f..c726f8f 100644 --- a/tools/upstream/keys.go +++ b/tools/upstream/keys.go @@ -11,7 +11,12 @@ import ( // TrN keys. Dynamic translation keys require an explicit adapter review. func ReferencedKeys(s *Snapshot) ([]string, error) { fns := template.FuncMap{} - for _, name := range []string{"AppUrl", "AppName", "AppDomain", "DotEscape", "QueryEscape", "ShortSha", "HTMLFormat", "PathEscapeSegments", "FormatByteSize", "Dict", "dict", "StringUtils", "SliceUtils", "JsonUtils", "QueryBuild", "PathEscape", "SanitizeHTML"} { + for _, name := range []string{ + "AppUrl", "AppName", "AppDomain", "DotEscape", "QueryEscape", + "ShortSha", "HTMLFormat", "PathEscapeSegments", "FormatByteSize", + "Dict", "dict", "StringUtils", "SliceUtils", "JsonUtils", "QueryBuild", + "PathEscape", "SanitizeHTML"} { + fns[name] = func(...any) string { return "" } } keys := map[string]bool{}