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:
@@ -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) {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user