Skip to content

Commit 411ed9d

Browse files
authored
Merge pull request #1 from takecchi/feat/i18n-language-support
feat: 日本語/英語のi18n対応と設定による言語切替
2 parents 2b6cfb8 + 59960f5 commit 411ed9d

21 files changed

Lines changed: 664 additions & 69 deletions

.claude/rules/i18n.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# 多言語対応(i18n)規約
2+
3+
UI に見えるすべての文字列は言語カタログに集約し、設定で日本語/英語を切り替えられる状態を保つ。
4+
新しい画面・メッセージを足すときは、必ず両言語ぶんをカタログに追加する。
5+
6+
## 大原則
7+
8+
- **UI コンポーネント(`.tsx`)に表示文字列を直書きしない。** 文字列は `src/core/i18n.ts`
9+
`messages` カタログ(`Messages` 型)にのみ置く。UI からは `useMessages()` で引く。
10+
- 例外は「表示テキストではない」もの: 色名(`'cyan'`)、記号(`'❯'`, `'» '`, `'y'`/`'n'` などのキー)、
11+
数値・ID・ブランチ名等のデータ。これらは翻訳対象ではない。
12+
- **ja と en は必ず対で追加する。** 片方だけ足さない。`Messages` 型が欠落を型エラーで捕え、
13+
`i18n.spec.ts` が ja/en のキー集合一致を実行時にも検証する(新グループを足してもこのテストが番人になる)。
14+
- **カタログは純粋に保つ。** `core/i18n.ts` は Ink/React/fs/execa を import しない([architecture.md](./architecture.md))。
15+
16+
## レイヤ配置
17+
18+
| 対象 | 置き場所 |
19+
|------|---------|
20+
| 文字列カタログ・`Lang``resolveLang``detectLocaleLang``normalizeLang` | `src/core/i18n.ts`(純粋) |
21+
| 設定ドメイン型・JSON 検証(`CodivaConfig` / `toConfig`| `src/core/config.ts`(純粋) |
22+
| 設定ファイルの読み書き(`~/.codiva/config.json`| `src/utils/config.ts`(I/O ラッパ) |
23+
| React コンテキスト(`MessagesProvider` / `useMessages`| `src/ui/i18n-context.tsx` |
24+
25+
## 文字列を追加・変更する手順
26+
27+
1. `Messages` interface に適切なグループ(`list` / `detail` / `badge` / `permission` / `app`)でキーを足す。
28+
2. `ja``en` **両カタログ**に実装を書く。
29+
3. 動的な差し込み・複数形は**文字列テンプレート関数**にする(例: `(n: number) => string`)。
30+
これで型安全(`any` 不要)・言語ごとの語順や複数形差を吸収できる。UI 側で `${}` 連結して組み立てない。
31+
4. UI から参照する:
32+
- コンポーネントは `const m = useMessages()``m.detail.notFound` のように使う。
33+
`m` はメッセージカタログ用に予約(`s`=session, `t`=todo などと衝突させない)。
34+
- 純関数(`badgeFor` 等)は `Messages`**引数で受ける**(フックを呼ばない)。
35+
5. 色付きキー(`y`/`n`/`m`/`d`)を色付き `<Text>` で挟む行は、**キー記号は JSX のまま、語だけカタログ化**する。
36+
6. 文字列を変えたら、対応する spec / `tests/*.test.tsx` のアサートも更新する。
37+
38+
## 言語の決定(優先順位)
39+
40+
`resolveLang()` が次の順で決める(上が優先):
41+
42+
1. 環境変数 `CODIVA_LANG``ja` / `en`。明示上書き)
43+
2. 設定ファイル `~/.codiva/config.json``language``'ja'` / `'en'``'auto'` は次へ委譲)
44+
3. OS ロケール(`LC_ALL``LC_MESSAGES``LANG`): `ja*` なら日本語、それ以外は英語
45+
46+
配線は `src/index.tsx`(合成ルート)で行い、解決済みカタログを `App``messages` prop に注入する。
47+
48+
## 言語を増やすとき
49+
50+
`core/i18n.ts``Lang` 型・`LANGS``messages` に言語を追加し、`normalizeLang` /
51+
`detectLocaleLang` の判定を拡張する。UI 側は `useMessages()` 経由なので変更不要。
52+
53+
## 落とし穴
54+
55+
- ブロックコメント内に `*/` を含む文字列(例: `ja*``/en` が隣接)を書くとコメントが早期終了する。
56+
oxc(vitest の変換器)が `Invalid Character` で落ちる。語の間にスペースを入れて回避する。

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ npm run spike # scripts/spike.ts(Phase 1 の SDK 挙動検証)
2929
@.claude/rules/architecture.md
3030
@.claude/rules/coding-rules.md
3131
@.claude/rules/ink-components.md
32+
@.claude/rules/i18n.md
3233

3334
## ビルド/モジュール構成(重要)
3435

docs/ARCHITECTURE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,27 @@ interface SessionState {
140140
- `SessionDetail`: メッセージログ + 追加指示入力 + `PermissionDialog`。ログは Ink の `<Static>` で追記描画し再描画コストを抑える。
141141
- 再描画スロットリング: SessionManager の通知を UI 側で ~100ms にスロットルする。
142142

143+
## 多言語対応(i18n)
144+
145+
UI 文字列は日本語/英語を設定で切り替えられる。規約は [.claude/rules/i18n.md](../.claude/rules/i18n.md)
146+
147+
- **カタログ**: 全 UI 文字列は `core/i18n.ts``messages``Record<Lang, Messages>`)に集約する(純粋)。
148+
UI にリテラルを直書きせず、`useMessages()``ui/i18n-context.tsx` の React コンテキスト)で引く。
149+
純関数(`badgeFor` 等)は `Messages` を引数で受ける。動的差し込み・複数形は型安全な文字列テンプレート関数で持つ。
150+
- **設定**: 表示言語は `~/.codiva/config.json``{ "language": "ja" | "en" | "auto" }`)に永続化する
151+
(Claude Code の `~/.claude/` と同じユーザーグローバルの流儀)。検証変換は `core/config.ts`
152+
`toConfig()`、ファイル I/O は `utils/config.ts``loadConfig` / `saveConfig`)。
153+
- **言語解決**`core/i18n.ts``resolveLang`、優先順): `CODIVA_LANG` 環境変数 → 設定ファイルの
154+
`language``auto` 以外)→ OS ロケール(`LC_ALL`/`LC_MESSAGES`/`LANG``ja*` なら日本語、他は英語)。
155+
配線は合成ルート `index.tsx` で行い、解決済みカタログを `App``messages` prop に注入する。
156+
- **番人**: `Messages` 型がキー欠落を型で捕え、`i18n.spec.ts` が ja/en のキー集合一致を実行時にも検証する。
157+
143158
## 設計上の決定と理由
144159

145160
| 決定 | 理由 |
146161
|------|------|
147162
| 分離手段は git worktree | 同一リポジトリの並列作業では最軽量。ブランチがそのまま成果物になる。Docker 等はMVPではオーバーキル |
163+
| UI 文字列はカタログ集約 + 設定で言語切替 | 日本語/英語の利用者が混在する。ハードコードを排し、追加言語も `Lang`/`messages` 拡張だけで済む |
148164
| セッション = SDK `query()` 1本(サブプロセス1本) | SDK の設計単位に素直。プロセス分離により1セッションのクラッシュが他に波及しない |
149165
| streaming input を常用(単発 prompt を使わない) | 追加指示(F-6)と質問への回答(F-7)を同一機構で実現でき、セッションを開いたまま維持できる |
150166
| コアと UI の分離 + queryFn の DI | SDK もネットワークも不要なユニットテストを可能にする(N-3 の 80% カバレッジはこれが前提) |

src/app.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { useApp, useInput } from 'ink';
22
import { type FC, useState } from 'react';
3-
import type { SessionManager } from '@/core';
4-
import { SessionDetail, SessionList } from '@/ui';
3+
import { messages as catalogs, type Messages, type SessionManager } from '@/core';
4+
import { MessagesProvider, SessionDetail, SessionList } from '@/ui';
55

66
type View = { mode: 'list' } | { mode: 'detail'; id: string };
77

8-
export const App: FC<{ manager: SessionManager }> = ({ manager }) => {
8+
export const App: FC<{ manager: SessionManager; messages?: Messages }> = ({
9+
manager,
10+
// 既定は ja。index.tsx が解決済みカタログを注入する。
11+
messages = catalogs.ja,
12+
}) => {
913
const { exit } = useApp();
1014
const [view, setView] = useState<View>({ mode: 'list' });
1115

@@ -21,12 +25,17 @@ export const App: FC<{ manager: SessionManager }> = ({ manager }) => {
2125
}
2226
});
2327

24-
if (view.mode === 'detail') {
25-
return (
26-
<SessionDetail manager={manager} id={view.id} onBack={() => setView({ mode: 'list' })} />
27-
);
28-
}
2928
return (
30-
<SessionList manager={manager} onOpen={(id) => setView({ mode: 'detail', id })} onQuit={quit} />
29+
<MessagesProvider value={messages}>
30+
{view.mode === 'detail' ? (
31+
<SessionDetail manager={manager} id={view.id} onBack={() => setView({ mode: 'list' })} />
32+
) : (
33+
<SessionList
34+
manager={manager}
35+
onOpen={(id) => setView({ mode: 'detail', id })}
36+
onQuit={quit}
37+
/>
38+
)}
39+
</MessagesProvider>
3140
);
3241
};

src/core/config.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { type CodivaConfig, toConfig } from '@/core/config';
3+
4+
describe('toConfig', () => {
5+
it.each([
6+
[{ language: 'ja' }, { language: 'ja' }],
7+
[{ language: 'en' }, { language: 'en' }],
8+
[{ language: 'auto' }, { language: 'auto' }],
9+
] as [unknown, CodivaConfig][])('keeps valid language %o', (input, expected) => {
10+
expect(toConfig(input)).toEqual(expected);
11+
});
12+
13+
it.each([
14+
[{ language: 'fr' }],
15+
[{ language: 42 }],
16+
[{ language: null }],
17+
[{}],
18+
[null],
19+
[undefined],
20+
['not an object'],
21+
[123],
22+
])('drops invalid/absent language: %o', (input) => {
23+
expect(toConfig(input)).toEqual({});
24+
});
25+
26+
it('ignores unknown extra keys', () => {
27+
expect(toConfig({ language: 'en', theme: 'dark' })).toEqual({ language: 'en' });
28+
});
29+
});

src/core/config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Lang } from './i18n';
2+
3+
/**
4+
* 永続設定のドメイン型。今は表示言語のみ。将来キーが増えても、外部 JSON からの
5+
* 変換は必ず `toConfig()` に閉じ込める(規約: coding-rules.md)。
6+
*/
7+
export interface CodivaConfig {
8+
/** 表示言語。'auto' は OS ロケールに従う。未設定も 'auto' 相当。 */
9+
language?: Lang | 'auto';
10+
}
11+
12+
/** 設定ファイルの生 JSON 形(各フィールドは unknown として受ける)。 */
13+
interface CodivaConfigJson {
14+
language?: unknown;
15+
}
16+
17+
function toLangSetting(value: unknown): Lang | 'auto' | undefined {
18+
return value === 'ja' || value === 'en' || value === 'auto' ? value : undefined;
19+
}
20+
21+
/**
22+
* 外部 JSON(設定ファイル内容)を CodivaConfig へ検証変換する。未知・不正な値は
23+
* 落として無視する(TUI を設定ミスでクラッシュさせないため、寛容に既定へフォールバック)。
24+
*/
25+
export function toConfig(json: unknown): CodivaConfig {
26+
if (typeof json !== 'object' || json === null) {
27+
return {};
28+
}
29+
const language = toLangSetting((json as CodivaConfigJson).language);
30+
return language ? { language } : {};
31+
}

src/core/i18n.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
detectLocaleLang,
4+
LANGS,
5+
type Lang,
6+
type Messages,
7+
messages,
8+
normalizeLang,
9+
resolveLang,
10+
} from '@/core/i18n';
11+
12+
describe('detectLocaleLang', () => {
13+
it.each([
14+
['ja_JP.UTF-8', 'ja'],
15+
['ja', 'ja'],
16+
['JA_JP', 'ja'],
17+
['en_US.UTF-8', 'en'],
18+
['fr_FR', 'en'],
19+
[undefined, 'en'],
20+
['', 'en'],
21+
] as const)('%s → %s', (locale, expected) => {
22+
expect(detectLocaleLang(locale)).toBe(expected);
23+
});
24+
});
25+
26+
describe('normalizeLang', () => {
27+
it.each([
28+
['ja', 'ja'],
29+
['ja_JP', 'ja'],
30+
['en', 'en'],
31+
['en_US', 'en'],
32+
['EN', 'en'],
33+
['fr', undefined],
34+
['', undefined],
35+
[undefined, undefined],
36+
] as const)('%s → %s', (value, expected) => {
37+
expect(normalizeLang(value)).toBe(expected);
38+
});
39+
});
40+
41+
describe('resolveLang', () => {
42+
it('prefers CODIVA_LANG over config and locale', () => {
43+
expect(resolveLang({ env: 'en', config: 'ja', locale: 'ja_JP' })).toBe('en');
44+
});
45+
it('falls back to config when env is unset/invalid', () => {
46+
expect(resolveLang({ env: undefined, config: 'ja', locale: 'en_US' })).toBe('ja');
47+
expect(resolveLang({ env: 'xx', config: 'en', locale: 'ja_JP' })).toBe('en');
48+
});
49+
it('uses the OS locale when config is "auto"', () => {
50+
expect(resolveLang({ config: 'auto', locale: 'ja_JP' })).toBe('ja');
51+
expect(resolveLang({ config: 'auto', locale: 'en_US' })).toBe('en');
52+
});
53+
it('uses the OS locale when nothing is set', () => {
54+
expect(resolveLang({ locale: 'ja_JP' })).toBe('ja');
55+
expect(resolveLang({})).toBe('en');
56+
});
57+
});
58+
59+
describe('message catalogs', () => {
60+
it('exposes ja and en', () => {
61+
expect(LANGS).toEqual(['ja', 'en']);
62+
expect(messages.ja).toBeDefined();
63+
expect(messages.en).toBeDefined();
64+
});
65+
66+
// Collect every leaf key path so we can assert no translation is missing in
67+
// either language — the whole point of enforcing a shared Messages shape.
68+
const keyPaths = (obj: Record<string, unknown>, prefix = ''): string[] =>
69+
Object.entries(obj)
70+
.flatMap(([k, v]) =>
71+
typeof v === 'object' && v !== null
72+
? keyPaths(v as Record<string, unknown>, `${prefix}${k}.`)
73+
: [`${prefix}${k}`],
74+
)
75+
.sort();
76+
77+
it('ja and en have identical key sets', () => {
78+
const jaKeys = keyPaths(messages.ja as unknown as Record<string, unknown>);
79+
const enKeys = keyPaths(messages.en as unknown as Record<string, unknown>);
80+
expect(jaKeys).toEqual(enKeys);
81+
});
82+
83+
it.each(LANGS)('%s renders dynamic strings without leftover placeholders', (lang: Lang) => {
84+
const m: Messages = messages[lang];
85+
expect(m.list.sessionCount(1)).toContain('1');
86+
expect(m.badge.step(2, 5)).toBe('Step 2/5');
87+
expect(m.detail.progress(1, 3, 'writing')).toContain('writing');
88+
expect(m.detail.progress(1, 3, undefined)).not.toContain('—');
89+
expect(m.permission.toolTitle('Bash')).toContain('Bash');
90+
expect(m.permission.questionHelp(true)).toContain('Space');
91+
expect(m.permission.questionHelp(false)).not.toContain('Space');
92+
expect(m.app.remainingWorktrees(2)).toContain('2');
93+
});
94+
});

0 commit comments

Comments
 (0)