tools: align locale diagnostics and generate native language names
Validate / Validate (push) Successful in 2m46s
Validate / Validate (push) Successful in 2m46s
This commit is contained in:
1 parent
b8089e8a7e
commit
d99c5d1dc5
13 files changed
+170
-46
No files matched your search
@@ -198,6 +198,9 @@ go run . preview all
|
|||||||
|
|
||||||
Default output includes `preview/rendered.js` and `preview/rendered/<locale>.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.
|
Default output includes `preview/rendered.js` and `preview/rendered/<locale>.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]
|
> [!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.
|
> 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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#控制和快捷键)。
|
默认输出包括 `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]
|
> [!IMPORTANT]
|
||||||
> 每次生成会覆盖输出位置的预览清单,不与先前选择的主题合并。例如,执行 `preview aurora` 后,该清单只包含 Aurora;需要恢复全部主题时执行 `preview all`。
|
> 每次生成会覆盖输出位置的预览清单,不与先前选择的主题合并。例如,执行 `preview aurora` 后,该清单只包含 Aurora;需要恢复全部主题时执行 `preview all`。
|
||||||
|
|
||||||
|
|||||||
+1
-27
@@ -482,34 +482,8 @@ function fillSelect(id, entries, selected) {
|
|||||||
select.value=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) {
|
function localeLabel(locale) {
|
||||||
let name = LOCALE_NAMES[locale];
|
const name = manifest?.language_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. */ }
|
|
||||||
}
|
|
||||||
return name && name !== locale ? locale + ' — ' + name : locale;
|
return name && name !== locale ? locale + ' — ' + name : locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ func UpstreamCommand() *cli.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logging.Info("Upstream", "%s: %d mail files, %d languages", s.Lock.Tag, len(s.Templates), len(s.Locales))
|
logging.Info("Upstream", "%s: %d mail files, %d languages", s.Lock.Tag, len(s.Templates), len(s.Locales))
|
||||||
for _, lang := range s.Languages() {
|
logging.WarnMissingTranslations(missing)
|
||||||
if keys := missing[lang]; len(keys) > 0 {
|
|
||||||
logging.Warn("Locale", "%s -> en-US: %v", lang, keys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.24.0
|
|||||||
require (
|
require (
|
||||||
github.com/urfave/cli/v2 v2.27.7
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
golang.org/x/net v0.46.0
|
golang.org/x/net v0.46.0
|
||||||
|
golang.org/x/text v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -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=
|
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 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
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=
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-13
@@ -20,16 +20,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PreviewResult struct {
|
type PreviewResult struct {
|
||||||
Rendered map[string]map[string]map[string]string
|
Rendered map[string]map[string]map[string]string
|
||||||
Registry map[string]map[string]string
|
Registry map[string]map[string]string
|
||||||
Params map[string]map[string]string
|
Params map[string]map[string]string
|
||||||
Languages []string
|
Languages []string
|
||||||
Themes []string
|
LanguageNames map[string]string
|
||||||
Upstream string
|
Themes []string
|
||||||
Commit string
|
Upstream string
|
||||||
Logo []byte
|
Commit string
|
||||||
Fallback map[string][]string
|
Logo []byte
|
||||||
Warnings map[string]map[string][]string
|
Fallback map[string][]string
|
||||||
|
Warnings map[string]map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func DiscoverThemes(dir string) ([]string, error) { return builder.Discover(dir) }
|
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
|
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 := &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 {
|
for id, t := range cfg.Templates {
|
||||||
result.Params[id] = config.FlattenParams(t.Params)
|
result.Params[id] = config.FlattenParams(t.Params)
|
||||||
}
|
}
|
||||||
@@ -236,6 +238,7 @@ func WriteRenderedJS(result *PreviewResult, output string) error {
|
|||||||
return err
|
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 := 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["logo"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(result.Logo)
|
||||||
manifest["upstream_commit"] = result.Commit
|
manifest["upstream_commit"] = result.Commit
|
||||||
data, err := json.Marshal(manifest)
|
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] "))
|
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) {
|
func markSafeHTML(data map[string]any) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -294,6 +294,7 @@ func TestSubjectSplitAndMissingFixture(t *testing.T) {
|
|||||||
|
|
||||||
func TestPreviewBundles(t *testing.T) {
|
func TestPreviewBundles(t *testing.T) {
|
||||||
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{"en-US": {"test": {"activate": "<body>hello</body>"}}, "zh-CN": {"test": {"activate": "<body>你好</body>"}}}, Languages: []string{"en-US", "zh-CN"}, Themes: []string{"test"}, Registry: map[string]map[string]string{}, Upstream: "v28.0.0"}
|
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{"en-US": {"test": {"activate": "<body>hello</body>"}}, "zh-CN": {"test": {"activate": "<body>你好</body>"}}}, 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")
|
output := filepath.Join(t.TempDir(), "rendered.js")
|
||||||
if err := WriteRenderedJS(result, output); err != nil {
|
if err := WriteRenderedJS(result, output); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -305,6 +306,9 @@ func TestPreviewBundles(t *testing.T) {
|
|||||||
if bytes.Contains(manifest, []byte("<body>")) {
|
if bytes.Contains(manifest, []byte("<body>")) {
|
||||||
t.Fatal("manifest embeds mail payload")
|
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 {
|
for _, lang := range result.Languages {
|
||||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(output), "rendered", lang+".js"))
|
data, err := os.ReadFile(filepath.Join(filepath.Dir(output), "rendered", lang+".js"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import (
|
|||||||
// TrN keys. Dynamic translation keys require an explicit adapter review.
|
// TrN keys. Dynamic translation keys require an explicit adapter review.
|
||||||
func ReferencedKeys(s *Snapshot) ([]string, error) {
|
func ReferencedKeys(s *Snapshot) ([]string, error) {
|
||||||
fns := template.FuncMap{}
|
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 "" }
|
fns[name] = func(...any) string { return "" }
|
||||||
}
|
}
|
||||||
keys := map[string]bool{}
|
keys := map[string]bool{}
|
||||||
|
|||||||
Reference in new issue
Block a user