Skip to content

Commit 5e84085

Browse files
authored
feat: i18n infrastructure + en/zh-CN translations + per-locale demos (#9)
* feat(i18n): add @open-codesign/i18n package with en + zh-CN Ships an i18next-backed translation layer with two locales (English and Simplified Chinese), a normalize/detect helper that coalesces zh-Hans* variants, and a missing-key warner that surfaces gaps as visible markers in dev. No silent fallbacks. Signed-off-by: hqhq1025 <1506751656@qq.com> * feat(templates): per-locale demo prompts via getDemos(locale) Splits the four built-in demos into ./locales/en.ts and ./locales/zh-CN.ts and exposes locale-aware getDemos() / getDemo(). BUILTIN_DEMOS is kept as an English alias so renderer code that has not migrated yet keeps working. Signed-off-by: hqhq1025 <1506751656@qq.com> * feat(desktop): locale IPC handlers (main process) Adds registerLocaleIpc() exposing locale:get-system, locale:get-current, and locale:set. Persists user choice to ~/.config/open-codesign/locale.json with a schemaVersion field — separate from config.toml so i18n can boot before the encrypted config loader finishes. Renderer wiring is deferred to the maintainer because apps/desktop/src/main/index.ts and the preload bridge are owned by the parallel preview-ux-v2 branch. Signed-off-by: hqhq1025 <1506751656@qq.com> * docs(i18n): add I18N.md covering architecture, conventions, and how-to Signed-off-by: hqhq1025 <1506751656@qq.com> --------- Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 0caca07 commit 5e84085

13 files changed

Lines changed: 803 additions & 32 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Locale IPC handlers (main process).
3+
*
4+
* Renderer wiring is intentionally NOT included here — `apps/desktop/src/main/index.ts`
5+
* and `preload/index.ts` are owned by a parallel branch (preview-ux-v2). After that
6+
* lands, the maintainer registers these handlers from `index.ts` and exposes them
7+
* via the preload bridge under `window.electronAPI.locale.{getSystem,getCurrent,set}`.
8+
*
9+
* Persistence is in its own file (`~/.config/open-codesign/locale.json`) so user
10+
* language can be read before the TOML config loader has finished — i18n needs to
11+
* boot synchronously enough to render the first frame.
12+
*/
13+
14+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
15+
import { homedir } from 'node:os';
16+
import { dirname, join } from 'node:path';
17+
import { app, ipcMain } from 'electron';
18+
19+
const CONFIG_DIR = join(homedir(), '.config', 'open-codesign');
20+
const LOCALE_FILE = join(CONFIG_DIR, 'locale.json');
21+
const SCHEMA_VERSION = 1;
22+
23+
interface LocaleFile {
24+
schemaVersion: number;
25+
locale: string;
26+
}
27+
28+
async function readPersisted(): Promise<string | null> {
29+
try {
30+
const raw = await readFile(LOCALE_FILE, 'utf8');
31+
const parsed = JSON.parse(raw) as Partial<LocaleFile>;
32+
if (typeof parsed.locale === 'string' && parsed.locale.length > 0) {
33+
return parsed.locale;
34+
}
35+
return null;
36+
} catch (err) {
37+
const code = (err as NodeJS.ErrnoException).code;
38+
if (code === 'ENOENT') return null;
39+
console.warn(`[locale-ipc] failed to read ${LOCALE_FILE}:`, err);
40+
return null;
41+
}
42+
}
43+
44+
async function writePersisted(locale: string): Promise<void> {
45+
await mkdir(dirname(LOCALE_FILE), { recursive: true });
46+
const payload: LocaleFile = { schemaVersion: SCHEMA_VERSION, locale };
47+
await writeFile(LOCALE_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
48+
}
49+
50+
export function registerLocaleIpc(): void {
51+
ipcMain.handle('locale:get-system', () => app.getLocale());
52+
53+
ipcMain.handle('locale:get-current', async () => {
54+
const persisted = await readPersisted();
55+
return persisted ?? app.getLocale();
56+
});
57+
58+
ipcMain.handle('locale:set', async (_e, raw: unknown) => {
59+
if (typeof raw !== 'string' || raw.length === 0) {
60+
throw new Error('locale:set expects a non-empty string');
61+
}
62+
await writePersisted(raw);
63+
return raw;
64+
});
65+
}

docs/I18N.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Internationalization (i18n)
2+
3+
open-codesign ships with two locales today: **English (`en`)** and **Simplified Chinese (`zh-CN`)**. This document covers how to add a string, add a locale, and what conventions the i18n layer follows.
4+
5+
## Architecture
6+
7+
```
8+
packages/i18n/
9+
src/
10+
index.ts # initI18n / setLocale / useT / normalizeLocale / availableLocales
11+
locales/
12+
en.json # canonical key tree
13+
zh-CN.json # parallel tree, same shape
14+
i18n.test.ts # vitest covering normalization, switching, missing keys
15+
packages/templates/
16+
src/
17+
locales/
18+
en.ts # per-locale demo prompts (DemoTemplate[])
19+
zh-CN.ts
20+
index.ts # getDemos(locale) / getDemo(id, locale)
21+
apps/desktop/src/main/
22+
locale-ipc.ts # ipcMain handlers: locale:get-system, locale:get-current, locale:set
23+
# persists to ~/.config/open-codesign/locale.json (separate from config.toml)
24+
```
25+
26+
The renderer integration (`App.tsx`, `store.ts`, preload bridge) is wired up by the maintainer after the parallel `wt/preview-ux-v2` branch lands.
27+
28+
## Adding a string
29+
30+
1. Pick a namespace from the existing tree (`common`, `preview`, `chat`, `settings`, `onboarding`, `commands`, `errors`, `demos`). Avoid creating new top-level namespaces unless the strings genuinely don't fit anywhere.
31+
2. Add the key + English copy to `packages/i18n/src/locales/en.json`.
32+
3. Add the same key + Chinese copy to `packages/i18n/src/locales/zh-CN.json`. **Both files must have identical key shapes.** No silent fallbacks — a missing key surfaces as `⟦key⟧` in dev and a `console.warn` in any environment.
33+
4. Use it in the renderer:
34+
35+
```ts
36+
import { useT } from '@open-codesign/i18n';
37+
38+
function MyButton() {
39+
const t = useT();
40+
return <button>{t('common.send')}</button>;
41+
}
42+
```
43+
44+
5. For interpolation use double-brace syntax: `t('onboarding.paste.recognized', { provider: 'Anthropic' })`.
45+
6. For pluralization use the `_one` / `_other` suffix convention (i18next v23 default): `t('onboarding.paste.connected', { count: n })`.
46+
47+
## Adding a locale
48+
49+
1. Add the locale code to `availableLocales` in `packages/i18n/src/index.ts`.
50+
2. Create `packages/i18n/src/locales/<code>.json` with the full key tree (copy from `en.json` and translate). Tests will fail fast if any key is missing, which is the point.
51+
3. Register the file in the `resources` map in `index.ts` and add an entry to `REGISTRY` in `packages/templates/src/index.ts` plus a per-locale demo file under `packages/templates/src/locales/`.
52+
4. Extend `normalizeLocale` if your locale has common aliases (e.g. `zh-Hans-CN``zh-CN`).
53+
5. Add the locale option to the Settings → Language dropdown.
54+
55+
## Naming conventions
56+
57+
- Keys are `camelCase`. Nest by feature, not by element type. `preview.empty.title`, not `titles.previewEmpty`.
58+
- Keep namespaces shallow (≤ 3 levels). If you need more, the feature probably wants its own namespace.
59+
- One string per piece of UI copy. Do not concatenate translated fragments — that breaks word order in non-English languages. If you have a sentence with a variable, use interpolation.
60+
- Demo prompts live under `demos.<demoId>.{title,description,prompt}`. The `id` field is the canonical identifier; titles/descriptions/prompts are translated.
61+
62+
## Locale precedence
63+
64+
At boot the renderer asks for the current locale through this chain:
65+
66+
1. User-set value (persisted at `~/.config/open-codesign/locale.json`)
67+
2. System locale (Electron `app.getLocale()`)
68+
3. `en` (default)
69+
70+
The user can change locale at runtime via Settings → Language; the change is persisted and applied via `setLocale()` without restarting the app.
71+
72+
## Why a separate `locale.json` instead of `config.toml`?
73+
74+
i18n needs to be initialized before the first render so users never see an English flash before the switch. The TOML config loader is async and waits on `safeStorage` decryption; reading a tiny JSON file is faster and cannot fail for missing keychain reasons. The locale file carries `schemaVersion: 1` for forward migration.
75+
76+
## What is NOT in scope
77+
78+
- Right-to-left layouts. Add when we ship Arabic / Hebrew.
79+
- Locale-specific number / date / currency formatting. Use `Intl.*` directly when needed.
80+
- Translating model output. The LLM responds in the language of the user's prompt; we don't post-process.

packages/i18n/package.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "@open-codesign/i18n",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "./src/index.ts",
7+
"types": "./src/index.ts",
8+
"exports": {
9+
".": "./src/index.ts",
10+
"./locales/en": "./src/locales/en.json",
11+
"./locales/zh-CN": "./src/locales/zh-CN.json"
12+
},
13+
"scripts": {
14+
"typecheck": "tsc --noEmit",
15+
"test": "vitest run --passWithNoTests"
16+
},
17+
"dependencies": {
18+
"i18next": "^23.16.5",
19+
"react-i18next": "^15.1.3"
20+
},
21+
"peerDependencies": {
22+
"react": "^19.0.0"
23+
},
24+
"devDependencies": {
25+
"@types/react": "^19.0.0",
26+
"react": "^19.0.0",
27+
"typescript": "^5.7.2",
28+
"vitest": "^2.1.8"
29+
}
30+
}

packages/i18n/src/i18n.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { availableLocales, initI18n, isSupportedLocale, normalizeLocale, setLocale } from './index';
3+
4+
describe('normalizeLocale', () => {
5+
it('returns the value unchanged when it is supported', () => {
6+
expect(normalizeLocale('en')).toBe('en');
7+
expect(normalizeLocale('zh-CN')).toBe('zh-CN');
8+
});
9+
10+
it('coalesces common Chinese variants to zh-CN', () => {
11+
expect(normalizeLocale('zh')).toBe('zh-CN');
12+
expect(normalizeLocale('zh-Hans')).toBe('zh-CN');
13+
expect(normalizeLocale('zh-Hans-CN')).toBe('zh-CN');
14+
expect(normalizeLocale('zh_CN')).toBe('zh-CN');
15+
});
16+
17+
it('maps en-US / en-GB to en', () => {
18+
expect(normalizeLocale('en-US')).toBe('en');
19+
expect(normalizeLocale('en-GB')).toBe('en');
20+
});
21+
22+
it('falls back to en for unsupported locales and warns', () => {
23+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
24+
expect(normalizeLocale('fr-FR')).toBe('en');
25+
expect(warn).toHaveBeenCalled();
26+
warn.mockRestore();
27+
});
28+
29+
it('falls back to en for nullish input without warning', () => {
30+
expect(normalizeLocale(undefined)).toBe('en');
31+
expect(normalizeLocale(null)).toBe('en');
32+
});
33+
});
34+
35+
describe('isSupportedLocale', () => {
36+
it('matches exactly the available locales', () => {
37+
for (const code of availableLocales) {
38+
expect(isSupportedLocale(code)).toBe(true);
39+
}
40+
expect(isSupportedLocale('fr')).toBe(false);
41+
expect(isSupportedLocale(undefined)).toBe(false);
42+
expect(isSupportedLocale(null)).toBe(false);
43+
expect(isSupportedLocale('')).toBe(false);
44+
});
45+
});
46+
47+
describe('initI18n + setLocale (live switching)', () => {
48+
it('boots and serves translated strings for both locales', async () => {
49+
const { i18n } = await import('./index');
50+
await initI18n('en');
51+
expect(i18n.t('chat.placeholder')).toBe('Describe what to design…');
52+
expect(i18n.t('common.send')).toBe('Send');
53+
54+
await setLocale('zh-CN');
55+
expect(i18n.t('chat.placeholder')).toBe('想设计什么?');
56+
expect(i18n.t('common.preAlpha')).toBe('预览版');
57+
58+
await setLocale('en');
59+
expect(i18n.t('common.send')).toBe('Send');
60+
});
61+
62+
it('warns and surfaces a visible marker when a key is missing', async () => {
63+
const { i18n } = await import('./index');
64+
await initI18n('en');
65+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
66+
const value = i18n.t('common.thisKeyDoesNotExist');
67+
// parseMissingKeyHandler in dev wraps with ⟦…⟧ brackets.
68+
expect(value).toContain('thisKeyDoesNotExist');
69+
expect(warn).toHaveBeenCalled();
70+
warn.mockRestore();
71+
});
72+
});

packages/i18n/src/index.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* i18n entry point for open-codesign.
3+
*
4+
* Design notes:
5+
* - Two locales out of the gate: `en` and `zh-CN`. Adding a third means adding
6+
* a JSON file under `./locales/` and registering it in `resources` + `availableLocales`.
7+
* - We do NOT silently swallow missing keys. In dev they render as `⟦key⟧` so
8+
* they're visible in the UI; in any environment a `console.warn` records the
9+
* namespace + locale + key path. (Principle §10: no silent fallbacks.)
10+
* - `normalizeLocale` is intentionally narrow — we only widen aliases that we
11+
* are confident about (zh-Hans*, en-*). Anything else logs a warning and
12+
* falls back to `DEFAULT_LOCALE`.
13+
*/
14+
15+
import i18next from 'i18next';
16+
import { initReactI18next, useTranslation } from 'react-i18next';
17+
import en from './locales/en.json';
18+
import zhCN from './locales/zh-CN.json';
19+
20+
export const availableLocales = ['en', 'zh-CN'] as const;
21+
export type Locale = (typeof availableLocales)[number];
22+
23+
const DEFAULT_LOCALE: Locale = 'en';
24+
25+
const resources = {
26+
en: { translation: en },
27+
'zh-CN': { translation: zhCN },
28+
} as const;
29+
30+
export function isSupportedLocale(value: string | undefined | null): value is Locale {
31+
if (!value) return false;
32+
return (availableLocales as readonly string[]).includes(value);
33+
}
34+
35+
export function normalizeLocale(value: string | undefined | null): Locale {
36+
if (!value) return DEFAULT_LOCALE;
37+
if (isSupportedLocale(value)) return value;
38+
const lower = value.toLowerCase();
39+
if (lower === 'zh' || lower.startsWith('zh-hans') || lower === 'zh-cn' || lower === 'zh_cn') {
40+
return 'zh-CN';
41+
}
42+
if (lower.startsWith('en')) return 'en';
43+
console.warn(
44+
`[i18n] unsupported locale "${value}", falling back to "${DEFAULT_LOCALE}". ` +
45+
`Supported: ${availableLocales.join(', ')}`,
46+
);
47+
return DEFAULT_LOCALE;
48+
}
49+
50+
let initialized = false;
51+
52+
function detectIsDev(): boolean {
53+
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
54+
return proc?.env?.['NODE_ENV'] !== 'production';
55+
}
56+
57+
export async function initI18n(locale: string | undefined): Promise<Locale> {
58+
const target = normalizeLocale(locale);
59+
if (initialized) {
60+
if (i18next.language !== target) {
61+
await i18next.changeLanguage(target);
62+
}
63+
return target;
64+
}
65+
66+
const isDev = detectIsDev();
67+
68+
await i18next.use(initReactI18next).init({
69+
resources,
70+
lng: target,
71+
fallbackLng: DEFAULT_LOCALE,
72+
supportedLngs: [...availableLocales],
73+
interpolation: { escapeValue: false },
74+
returnNull: false,
75+
saveMissing: true,
76+
missingKeyHandler: (lngs, ns, key) => {
77+
const lang = Array.isArray(lngs) ? lngs.join(',') : String(lngs);
78+
console.warn(
79+
`[i18n] missing translation key "${key}" in namespace "${ns}" for locale "${lang}"`,
80+
);
81+
},
82+
parseMissingKeyHandler: (key) => {
83+
if (isDev) return `\u27E6${key}\u27E7`;
84+
return key;
85+
},
86+
react: { useSuspense: false },
87+
});
88+
89+
initialized = true;
90+
return target;
91+
}
92+
93+
export async function setLocale(locale: string): Promise<Locale> {
94+
const target = normalizeLocale(locale);
95+
if (!initialized) {
96+
return initI18n(target);
97+
}
98+
await i18next.changeLanguage(target);
99+
return target;
100+
}
101+
102+
export function getCurrentLocale(): Locale {
103+
return normalizeLocale(i18next.language);
104+
}
105+
106+
export function useT(): (key: string, options?: Record<string, unknown>) => string {
107+
const { t } = useTranslation();
108+
return (key, options) => t(key, options ?? {}) as string;
109+
}
110+
111+
export { i18next as i18n };
112+
export { useTranslation } from 'react-i18next';

0 commit comments

Comments
 (0)