Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/desktop/src/main/locale-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Locale IPC handlers (main process).
*
* Renderer wiring is intentionally NOT included here — `apps/desktop/src/main/index.ts`
* and `preload/index.ts` are owned by a parallel branch (preview-ux-v2). After that
* lands, the maintainer registers these handlers from `index.ts` and exposes them
* via the preload bridge under `window.electronAPI.locale.{getSystem,getCurrent,set}`.
*
* Persistence is in its own file (`~/.config/open-codesign/locale.json`) so user
* language can be read before the TOML config loader has finished — i18n needs to
* boot synchronously enough to render the first frame.
*/

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { app, ipcMain } from 'electron';

const CONFIG_DIR = join(homedir(), '.config', 'open-codesign');
const LOCALE_FILE = join(CONFIG_DIR, 'locale.json');
const SCHEMA_VERSION = 1;

interface LocaleFile {
schemaVersion: number;
locale: string;
}

async function readPersisted(): Promise<string | null> {
try {
const raw = await readFile(LOCALE_FILE, 'utf8');
const parsed = JSON.parse(raw) as Partial<LocaleFile>;
if (typeof parsed.locale === 'string' && parsed.locale.length > 0) {
return parsed.locale;
}
return null;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return null;
console.warn(`[locale-ipc] failed to read ${LOCALE_FILE}:`, err);
return null;
}
}

async function writePersisted(locale: string): Promise<void> {
await mkdir(dirname(LOCALE_FILE), { recursive: true });
const payload: LocaleFile = { schemaVersion: SCHEMA_VERSION, locale };
await writeFile(LOCALE_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}

export function registerLocaleIpc(): void {
ipcMain.handle('locale:get-system', () => app.getLocale());

ipcMain.handle('locale:get-current', async () => {
const persisted = await readPersisted();
return persisted ?? app.getLocale();
});

ipcMain.handle('locale:set', async (_e, raw: unknown) => {
if (typeof raw !== 'string' || raw.length === 0) {
throw new Error('locale:set expects a non-empty string');
}
await writePersisted(raw);
return raw;
});
}
80 changes: 80 additions & 0 deletions docs/I18N.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Internationalization (i18n)

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.

## Architecture

```
packages/i18n/
src/
index.ts # initI18n / setLocale / useT / normalizeLocale / availableLocales
locales/
en.json # canonical key tree
zh-CN.json # parallel tree, same shape
i18n.test.ts # vitest covering normalization, switching, missing keys
packages/templates/
src/
locales/
en.ts # per-locale demo prompts (DemoTemplate[])
zh-CN.ts
index.ts # getDemos(locale) / getDemo(id, locale)
apps/desktop/src/main/
locale-ipc.ts # ipcMain handlers: locale:get-system, locale:get-current, locale:set
# persists to ~/.config/open-codesign/locale.json (separate from config.toml)
```

The renderer integration (`App.tsx`, `store.ts`, preload bridge) is wired up by the maintainer after the parallel `wt/preview-ux-v2` branch lands.

## Adding a string

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.
2. Add the key + English copy to `packages/i18n/src/locales/en.json`.
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.
4. Use it in the renderer:

```ts
import { useT } from '@open-codesign/i18n';

function MyButton() {
const t = useT();
return <button>{t('common.send')}</button>;
}
```

5. For interpolation use double-brace syntax: `t('onboarding.paste.recognized', { provider: 'Anthropic' })`.
6. For pluralization use the `_one` / `_other` suffix convention (i18next v23 default): `t('onboarding.paste.connected', { count: n })`.

## Adding a locale

1. Add the locale code to `availableLocales` in `packages/i18n/src/index.ts`.
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.
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/`.
4. Extend `normalizeLocale` if your locale has common aliases (e.g. `zh-Hans-CN` → `zh-CN`).
5. Add the locale option to the Settings → Language dropdown.

## Naming conventions

- Keys are `camelCase`. Nest by feature, not by element type. `preview.empty.title`, not `titles.previewEmpty`.
- Keep namespaces shallow (≤ 3 levels). If you need more, the feature probably wants its own namespace.
- 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.
- Demo prompts live under `demos.<demoId>.{title,description,prompt}`. The `id` field is the canonical identifier; titles/descriptions/prompts are translated.

## Locale precedence

At boot the renderer asks for the current locale through this chain:

1. User-set value (persisted at `~/.config/open-codesign/locale.json`)
2. System locale (Electron `app.getLocale()`)
3. `en` (default)

The user can change locale at runtime via Settings → Language; the change is persisted and applied via `setLocale()` without restarting the app.

## Why a separate `locale.json` instead of `config.toml`?

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.

## What is NOT in scope

- Right-to-left layouts. Add when we ship Arabic / Hebrew.
- Locale-specific number / date / currency formatting. Use `Intl.*` directly when needed.
- Translating model output. The LLM responds in the language of the user's prompt; we don't post-process.
30 changes: 30 additions & 0 deletions packages/i18n/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@open-codesign/i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./locales/en": "./src/locales/en.json",
"./locales/zh-CN": "./src/locales/zh-CN.json"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"i18next": "^23.16.5",
"react-i18next": "^15.1.3"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
72 changes: 72 additions & 0 deletions packages/i18n/src/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest';
import { availableLocales, initI18n, isSupportedLocale, normalizeLocale, setLocale } from './index';

describe('normalizeLocale', () => {
it('returns the value unchanged when it is supported', () => {
expect(normalizeLocale('en')).toBe('en');
expect(normalizeLocale('zh-CN')).toBe('zh-CN');
});

it('coalesces common Chinese variants to zh-CN', () => {
expect(normalizeLocale('zh')).toBe('zh-CN');
expect(normalizeLocale('zh-Hans')).toBe('zh-CN');
expect(normalizeLocale('zh-Hans-CN')).toBe('zh-CN');
expect(normalizeLocale('zh_CN')).toBe('zh-CN');
});

it('maps en-US / en-GB to en', () => {
expect(normalizeLocale('en-US')).toBe('en');
expect(normalizeLocale('en-GB')).toBe('en');
});

it('falls back to en for unsupported locales and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(normalizeLocale('fr-FR')).toBe('en');
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});

it('falls back to en for nullish input without warning', () => {
expect(normalizeLocale(undefined)).toBe('en');
expect(normalizeLocale(null)).toBe('en');
});
});

describe('isSupportedLocale', () => {
it('matches exactly the available locales', () => {
for (const code of availableLocales) {
expect(isSupportedLocale(code)).toBe(true);
}
expect(isSupportedLocale('fr')).toBe(false);
expect(isSupportedLocale(undefined)).toBe(false);
expect(isSupportedLocale(null)).toBe(false);
expect(isSupportedLocale('')).toBe(false);
});
});

describe('initI18n + setLocale (live switching)', () => {
it('boots and serves translated strings for both locales', async () => {
const { i18n } = await import('./index');
await initI18n('en');
expect(i18n.t('chat.placeholder')).toBe('Describe what to design…');
expect(i18n.t('common.send')).toBe('Send');

await setLocale('zh-CN');
expect(i18n.t('chat.placeholder')).toBe('想设计什么?');
expect(i18n.t('common.preAlpha')).toBe('预览版');

await setLocale('en');
expect(i18n.t('common.send')).toBe('Send');
});

it('warns and surfaces a visible marker when a key is missing', async () => {
const { i18n } = await import('./index');
await initI18n('en');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const value = i18n.t('common.thisKeyDoesNotExist');
// parseMissingKeyHandler in dev wraps with ⟦…⟧ brackets.
expect(value).toContain('thisKeyDoesNotExist');
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
112 changes: 112 additions & 0 deletions packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* i18n entry point for open-codesign.
*
* Design notes:
* - Two locales out of the gate: `en` and `zh-CN`. Adding a third means adding
* a JSON file under `./locales/` and registering it in `resources` + `availableLocales`.
* - We do NOT silently swallow missing keys. In dev they render as `⟦key⟧` so
* they're visible in the UI; in any environment a `console.warn` records the
* namespace + locale + key path. (Principle §10: no silent fallbacks.)
* - `normalizeLocale` is intentionally narrow — we only widen aliases that we
* are confident about (zh-Hans*, en-*). Anything else logs a warning and
* falls back to `DEFAULT_LOCALE`.
*/

import i18next from 'i18next';
import { initReactI18next, useTranslation } from 'react-i18next';
import en from './locales/en.json';
import zhCN from './locales/zh-CN.json';

export const availableLocales = ['en', 'zh-CN'] as const;
export type Locale = (typeof availableLocales)[number];

const DEFAULT_LOCALE: Locale = 'en';

const resources = {
en: { translation: en },
'zh-CN': { translation: zhCN },
} as const;

export function isSupportedLocale(value: string | undefined | null): value is Locale {
if (!value) return false;
return (availableLocales as readonly string[]).includes(value);
}

export function normalizeLocale(value: string | undefined | null): Locale {
if (!value) return DEFAULT_LOCALE;
if (isSupportedLocale(value)) return value;
const lower = value.toLowerCase();
if (lower === 'zh' || lower.startsWith('zh-hans') || lower === 'zh-cn' || lower === 'zh_cn') {
return 'zh-CN';
}
if (lower.startsWith('en')) return 'en';
console.warn(
`[i18n] unsupported locale "${value}", falling back to "${DEFAULT_LOCALE}". ` +
`Supported: ${availableLocales.join(', ')}`,
);
return DEFAULT_LOCALE;
}

let initialized = false;

function detectIsDev(): boolean {
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
return proc?.env?.['NODE_ENV'] !== 'production';
}

export async function initI18n(locale: string | undefined): Promise<Locale> {
const target = normalizeLocale(locale);
if (initialized) {
if (i18next.language !== target) {
await i18next.changeLanguage(target);
}
return target;
}

const isDev = detectIsDev();

await i18next.use(initReactI18next).init({
resources,
lng: target,
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...availableLocales],
interpolation: { escapeValue: false },
returnNull: false,
saveMissing: true,
missingKeyHandler: (lngs, ns, key) => {
const lang = Array.isArray(lngs) ? lngs.join(',') : String(lngs);
console.warn(
`[i18n] missing translation key "${key}" in namespace "${ns}" for locale "${lang}"`,
);
},
parseMissingKeyHandler: (key) => {
if (isDev) return `\u27E6${key}\u27E7`;
return key;
},
react: { useSuspense: false },
});

initialized = true;
return target;
}

export async function setLocale(locale: string): Promise<Locale> {
const target = normalizeLocale(locale);
if (!initialized) {
return initI18n(target);
}
await i18next.changeLanguage(target);
return target;
}

export function getCurrentLocale(): Locale {
return normalizeLocale(i18next.language);
}

export function useT(): (key: string, options?: Record<string, unknown>) => string {
const { t } = useTranslation();
return (key, options) => t(key, options ?? {}) as string;
}

export { i18next as i18n };
export { useTranslation } from 'react-i18next';
Loading
Loading