feat(i18n): add Japanese locale catalogs and negotiation (#3841)

Co-authored-by: Y.anbe <yoshitaka.anbe@aot.it.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Y.anbe
2026-09-22 07:18:37 +09:00
committed by GitHub
parent ecbe2c6b03
commit cd08c50ca6
63 changed files with 11978 additions and 88 deletions
+39 -8
View File
@@ -50,6 +50,7 @@ var supportedLocales = map[string]bool{
"zh-cn": true,
"zh-tw": true,
"zh-hk": true,
"ja": true,
"pseudo": true,
}
@@ -82,23 +83,45 @@ func load() {
})
}
// Supported reports whether locale has a committed catalog.
func Supported(locale string) bool { return supportedLocales[canonicalLocale(locale)] }
// Normalize returns locale when it is supported, otherwise DefaultLocale. Used
// for both `<html lang>` and message lookup so they can never disagree.
func Normalize(locale string) string {
canonical := canonicalLocale(locale)
if supportedLocales[canonical] {
return canonical
if Supported(locale) {
return canonicalLocale(locale)
}
// A region-suffixed variant of a supported base collapses onto it when the
// base is unambiguous ("ja-JP" → "ja"). Multi-variant bases stay exact.
if collapsed := normalizeRegion(locale); supportedLocales[collapsed] {
return collapsed
}
return DefaultLocale
}
// Supported reports whether locale has a committed catalog.
func Supported(locale string) bool {
return supportedLocales[canonicalLocale(locale)]
}
func canonicalLocale(locale string) string {
return strings.ToLower(strings.TrimSpace(locale))
}
// normalizeRegion collapses a region-suffixed locale onto its base when the base
// is unambiguous: "ja-JP" → "ja", "pt-BR" → "pt" (unsupported here, falls
// through to en via Normalize). Multi-variant bases like "zh" stay exact —
// "zh-CN"/"zh-TW"/"zh-HK" must not collapse to a shared "zh".
func normalizeRegion(locale string) string {
canonical := canonicalLocale(locale)
base, _, hasRegion := strings.Cut(canonical, "-")
if !hasRegion {
return canonical
}
if supportedLocales[base] && base != "zh" && base != "pt" {
return base
}
return canonical
}
// FromRequest resolves the active locale: the kandev_locale cookie first (the
// SPA's explicit user choice), then the browser's Accept-Language, then the
// default. Unknown values coerce to DefaultLocale rather than erroring.
@@ -106,8 +129,16 @@ func FromRequest(r *http.Request) string {
if r == nil {
return DefaultLocale
}
if cookie, err := r.Cookie(LocaleCookie); err == nil && Supported(cookie.Value) {
return Normalize(cookie.Value)
if cookie, err := r.Cookie(LocaleCookie); err == nil {
if Supported(cookie.Value) {
return Normalize(cookie.Value)
}
// Unambiguous region suffixes such as ja-JP collapse onto a shipped
// base. Supported() is exact, so Normalize() is not used here: it
// would turn an unknown cookie into en and skip Accept-Language.
if collapsed := normalizeRegion(cookie.Value); supportedLocales[collapsed] {
return collapsed
}
}
for _, tag := range parseAcceptLanguage(r.Header.Get("Accept-Language")) {
if Supported(tag) {
+54 -1
View File
@@ -21,6 +21,8 @@ func TestNormalize(t *testing.T) {
{"zh-TW", "zh-tw"},
{"zh-hk", "zh-hk"},
{"zh-HK", "zh-hk"},
{"ja", "ja"},
{"ja-JP", "ja"},
{localePtPT, localePtPT},
{"pt-PT", localePtPT},
{"pseudo", "pseudo"},
@@ -54,6 +56,9 @@ func TestTranslatesAndFallsBack(t *testing.T) {
if hongKong := T("zh-hk", "webapp.shellUnavailable"); hongKong == en {
t.Fatalf("zh-hk message should differ from en, both %q", en)
}
if japanese := T("ja", "webapp.shellUnavailable"); japanese == en {
t.Fatalf("ja message should differ from en, both %q", en)
}
if portuguese := T(localePtPT, "webapp.shellUnavailable"); portuguese == en {
t.Fatalf("pt-pt message should differ from en, both %q", en)
}
@@ -137,7 +142,7 @@ func TestCatalogsHaveMatchingKeys(t *testing.T) {
t.Parallel()
load()
source := catalogs[DefaultLocale]
for _, locale := range []string{"pseudo", "zh-cn", "zh-tw", "zh-hk", localePtPT} {
for _, locale := range []string{"pseudo", "zh-cn", "zh-tw", "zh-hk", "ja", localePtPT} {
translated := catalogs[locale]
if len(translated) != len(source) {
t.Fatalf("%s catalog has %d keys, want %d", locale, len(translated), len(source))
@@ -187,6 +192,16 @@ func TestFromRequest(t *testing.T) {
}
})
t.Run("invalid cookie does not skip accept-language", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: LocaleCookie, Value: "klingon"})
r.Header.Set("Accept-Language", "ja;q=1.0, en;q=0.5")
if got := FromRequest(r); got != "ja" {
t.Fatalf("got %q, want ja", got)
}
})
t.Run("accept-language honored by q-value", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -223,6 +238,44 @@ func TestFromRequest(t *testing.T) {
}
})
t.Run("ja cookie wins over accept-language", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: LocaleCookie, Value: "ja"})
r.Header.Set("Accept-Language", "en")
if got := FromRequest(r); got != "ja" {
t.Fatalf("got %q, want ja", got)
}
})
t.Run("ja-JP cookie collapses onto ja", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: LocaleCookie, Value: "ja-JP"})
r.Header.Set("Accept-Language", "en")
if got := FromRequest(r); got != "ja" {
t.Fatalf("got %q, want ja", got)
}
})
t.Run("accept-language selects ja", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Accept-Language", "ja;q=1.0, en;q=0.5")
if got := FromRequest(r); got != "ja" {
t.Fatalf("got %q, want ja", got)
}
})
t.Run("accept-language ja-JP collapses onto ja", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Accept-Language", "ja-JP,en;q=0.8")
if got := FromRequest(r); got != "ja" {
t.Fatalf("got %q, want ja", got)
}
})
t.Run("zh-tw cookie wins over accept-language", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -0,0 +1,74 @@
{
"share.brandTag": "共有タスク",
"share.builtWith": "構築",
"share.ctaTryKandev": "GitHub で kandev を試す",
"share.emptyMessage": "空",
"share.gistDescription": "kandev 共有:{{title}}",
"share.gistDescriptionUntitled": "kandev タスク共有",
"share.messageCount_one": "{{count}} 件のメッセージ",
"share.messageCount_other": "{{count}} 件のメッセージ",
"share.metaAgent": "エージェント",
"share.metaCompleted": "完了",
"share.metaExecutor": "実行環境",
"share.metaModel": "モデル",
"share.metaStarted": "開始",
"share.metaWorkflowStep": "ワークフローステップ",
"share.noMessages": "メッセージはありません。",
"share.noMetadata": "メタデータなし",
"share.openRenderedView": "**[レンダリングビューを開く →]({{url}})** で、整形された会話を確認できます。",
"share.openShareHTML": "**{{file}} を開く** で、レンダリングされた会話を確認できます。",
"share.pageTitle": "{{title}};kandev 共有",
"share.pitch": "**[kandev]({{url}})** から共有;オープンソースのエージェンティック開発環境。",
"share.rawExport": "生エクスポート:",
"share.redacted": "編集済み:",
"share.redactedBeforePublish": "公開前に編集済み:",
"share.roleAssistant": "アシスタント",
"share.roleSystem": "システム",
"share.roleUser": "ユーザー",
"share.roleYou": "あなた",
"share.sessionDetails": "セッション詳細",
"share.toolOutput": "ツール出力",
"share.toolOutputTruncated": "ツール出力(切り詰め済み)",
"share.untitledTask": "無題のタスク",
"startup.duration.minutes_one": "{{count}} 分",
"startup.duration.minutes_other": "{{count}} 分",
"startup.duration.seconds_one": "{{count}} 秒",
"startup.duration.seconds_other": "{{count}} 秒",
"startup.page.countedProgress": "{{total}} 中 {{done}} {{unit}}",
"startup.page.countingProgress": "{{done}} {{unit}}",
"startup.page.elapsed": "経過時間 {{duration}}",
"startup.page.eta": "残り約 {{duration}}",
"startup.page.lastKnown": "最終確認",
"startup.page.progressUnavailable": "このステップの進捗は測定できません。",
"startup.page.stalled": "{{duration}} 停滞中",
"startup.page.title": "Kandev を起動しています",
"startup.page.waiting": "Kandev を起動中で、まだ到達できません。",
"startup.phase.applying_migrations": "マイグレーションを適用中",
"startup.phase.backing_up_database": "データベースをバックアップ中",
"startup.phase.initializing_services": "サービスを初期化中",
"startup.phase.opening_database": "データベースを開いています",
"startup.phase.ready": "準備完了",
"startup.phase.recovering_sessions": "セッションを復元中",
"startup.step.database_backup": "データベースのバックアップ",
"startup.step.message_timestamps": "メッセージのタイムスタンプ",
"startup.step.prompt_seq": "プロンプトのシーケンス番号",
"startup.step.session_recovery": "セッションの復元",
"startup.step.stores_repositories": "リポジトリストア",
"startup.step.stores_services": "サービスストア",
"startup.step.subagent_context": "サブエージェントのコンテキスト",
"startup.unit.bytes_one": "バイト",
"startup.unit.bytes_other": "バイト",
"startup.unit.messages_one": "件",
"startup.unit.messages_other": "件",
"startup.unit.rows_one": "行",
"startup.unit.rows_other": "行",
"startup.unit.sessions_one": "セッション",
"startup.unit.sessions_other": "セッション",
"startup.unit.stores_one": "ストア",
"startup.unit.stores_other": "ストア",
"startup.unit.turns_one": "ターン",
"startup.unit.turns_other": "ターン",
"webapp.assetReadFailed": "Web アセットを読み込めませんでした。",
"webapp.devServerUnavailable": "Vite 開発サーバーを利用できません。",
"webapp.shellUnavailable": "Kandev Web アプリを利用できません。"
}
@@ -113,6 +113,7 @@ func TestRenderShellSetsHTMLLangFromLocale(t *testing.T) {
{name: "supported simplified chinese", locale: "zh-cn", wantLang: `lang="zh-cn"`},
{name: "supported traditional chinese taiwan", locale: "zh-tw", wantLang: `lang="zh-tw"`},
{name: "supported traditional chinese hong kong", locale: "zh-hk", wantLang: `lang="zh-hk"`},
{name: "supported japanese", locale: "ja", wantLang: `lang="ja"`},
{name: "supported european portuguese", locale: "pt-pt", wantLang: `lang="pt-pt"`},
{name: "supported pseudo", locale: "pseudo", wantLang: `lang="pseudo"`},
{name: "unknown coerces to en", locale: "klingon", wantLang: `lang="en"`},
@@ -240,6 +241,8 @@ func TestNormalizeLocale(t *testing.T) {
{"zh-TW", "zh-tw"},
{"zh-hk", "zh-hk"},
{"zh-HK", "zh-hk"},
{"ja", "ja"},
{"ja-JP", "ja"},
{"pt-pt", "pt-pt"},
{"pt-PT", "pt-pt"},
{"pseudo", "pseudo"},