From afba35ad1592934955a2520a7055029fa866b044 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <1506751656@qq.com> Date: Sat, 18 Apr 2026 12:16:49 +0800 Subject: [PATCH] feat(i18n): en + zh-CN with namespaced keys, system locale auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @open-codesign/i18n built on i18next + react-i18next, plus per-locale demo prompts in @open-codesign/templates and the renderer wiring needed to swap the entire UI live. - New package packages/i18n (deps: i18next ~50KB, react-i18next ~25KB) with namespaced JSON resources for common, preview, chat, settings, onboarding, commands, errors, demos. - Locale auto-detect via Electron app.getLocale() on first run, persisted to ~/.config/open-codesign/locale.json (locale-ipc) until wt/onboarding folds it into config.toml under ui.locale. - Renderer boots i18n before createRoot; useCodesignStore exposes a locale slice + setLocale that updates persistence and UI atomically. - BUILTIN_DEMOS becomes getDemos(locale) returning the per-locale variant. - Missing keys are loud: console.warn + dev-mode bracket markers, never silent (PRINCIPLES §10). - Vitest covers normalization, supported-locale detection, and live locale switching. Tier 1 scope: only en and zh-CN. Translation keys stay shallow so they remain re-keyable. Signed-off-by: Haoqing Wang <1506751656@qq.com> --- apps/desktop/package.json | 1 + apps/desktop/src/main/index.ts | 2 + apps/desktop/src/main/locale-ipc.ts | 50 +++++++++ apps/desktop/src/preload/index.ts | 5 + apps/desktop/src/renderer/src/App.tsx | 40 +++++--- apps/desktop/src/renderer/src/main.tsx | 26 ++++- apps/desktop/src/renderer/src/store.ts | 21 ++++ docs/I18N.md | 92 +++++++++++++++++ packages/i18n/package.json | 30 ++++++ packages/i18n/src/index.test.ts | 49 +++++++++ packages/i18n/src/index.ts | 96 +++++++++++++++++ packages/i18n/src/locales/en.json | 130 ++++++++++++++++++++++++ packages/i18n/src/locales/zh-CN.json | 130 ++++++++++++++++++++++++ packages/i18n/tsconfig.json | 8 ++ packages/templates/package.json | 1 + packages/templates/src/index.ts | 57 +++++------ packages/templates/src/locales/en.ts | 32 ++++++ packages/templates/src/locales/zh-CN.ts | 32 ++++++ pnpm-lock.yaml | 73 +++++++++++++ 19 files changed, 824 insertions(+), 51 deletions(-) create mode 100644 apps/desktop/src/main/locale-ipc.ts create mode 100644 docs/I18N.md create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/src/index.test.ts create mode 100644 packages/i18n/src/index.ts create mode 100644 packages/i18n/src/locales/en.json create mode 100644 packages/i18n/src/locales/zh-CN.json create mode 100644 packages/i18n/tsconfig.json create mode 100644 packages/templates/src/locales/en.ts create mode 100644 packages/templates/src/locales/zh-CN.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1c5c12ff..b18486dc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,6 +14,7 @@ "dependencies": { "@open-codesign/artifacts": "workspace:*", "@open-codesign/core": "workspace:*", + "@open-codesign/i18n": "workspace:*", "@open-codesign/providers": "workspace:*", "@open-codesign/runtime": "workspace:*", "@open-codesign/shared": "workspace:*", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f23cae02..e1a322f8 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,6 +5,7 @@ import { detectProviderFromKey } from '@open-codesign/providers'; import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared'; import { BrowserWindow, app, ipcMain, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { registerLocaleIpc } from './locale-ipc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -78,6 +79,7 @@ function setupAutoUpdater(): void { void app.whenReady().then(() => { registerIpcHandlers(); + registerLocaleIpc(); setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/main/locale-ipc.ts b/apps/desktop/src/main/locale-ipc.ts new file mode 100644 index 00000000..848ae0cf --- /dev/null +++ b/apps/desktop/src/main/locale-ipc.ts @@ -0,0 +1,50 @@ +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'); + +interface LocaleFile { + locale: string; +} + +async function readPersisted(): Promise { + try { + const raw = await readFile(LOCALE_FILE, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + 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 { + await mkdir(dirname(LOCALE_FILE), { recursive: true }); + const payload: LocaleFile = { 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; + }); +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 1c32bf40..1fd824a9 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -19,6 +19,11 @@ const api = { ipcRenderer.on('codesign:update-available', listener); return () => ipcRenderer.removeListener('codesign:update-available', listener); }, + locale: { + getSystem: () => ipcRenderer.invoke('locale:get-system') as Promise, + getCurrent: () => ipcRenderer.invoke('locale:get-current') as Promise, + set: (locale: string) => ipcRenderer.invoke('locale:set', locale) as Promise, + }, }; contextBridge.exposeInMainWorld('codesign', api); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index e761430c..1f232744 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,17 +1,22 @@ +import { useT } from '@open-codesign/i18n'; import { buildSrcdoc } from '@open-codesign/runtime'; -import { BUILTIN_DEMOS } from '@open-codesign/templates'; +import { getDemos } from '@open-codesign/templates'; import { Button } from '@open-codesign/ui'; import { Send, Sparkles } from 'lucide-react'; import { useState } from 'react'; import { useCodesignStore } from './store'; export function App() { + const t = useT(); const messages = useCodesignStore((s) => s.messages); const previewHtml = useCodesignStore((s) => s.previewHtml); const isGenerating = useCodesignStore((s) => s.isGenerating); const sendPrompt = useCodesignStore((s) => s.sendPrompt); + const locale = useCodesignStore((s) => s.locale); const [prompt, setPrompt] = useState(''); + const demos = getDemos(locale); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!prompt.trim() || isGenerating) return; @@ -25,8 +30,12 @@ export function App() {
- open-codesign - pre-alpha + + {t('common.appName')} + + + {t('common.preAlpha')} +
@@ -34,10 +43,10 @@ export function App() { {messages.length === 0 ? (

- Try a starter prompt: + {t('preview.empty.starterChip')}

    - {BUILTIN_DEMOS.map((demo) => ( + {demos.map((demo) => (
  • @@ -93,11 +108,9 @@ export function App() {
    - {previewHtml ? 'Preview' : 'No design yet'} - - - BYOK · local-first · multi-model + {previewHtml ? t('preview.ready') : t('preview.noDesign')} + {t('common.tagline')}
    {previewHtml ? ( @@ -115,11 +128,10 @@ export function App() {

    - Design with AI + {t('preview.empty.title')}

    - Pick a starter on the left, or describe what you want to design. The result - renders here in a sandboxed preview. + {t('preview.empty.body')}

diff --git a/apps/desktop/src/renderer/src/main.tsx b/apps/desktop/src/renderer/src/main.tsx index c4f2b183..82513852 100644 --- a/apps/desktop/src/renderer/src/main.tsx +++ b/apps/desktop/src/renderer/src/main.tsx @@ -1,13 +1,29 @@ +import { initI18n, normalizeLocale } from '@open-codesign/i18n'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; import './index.css'; +import { useCodesignStore } from './store'; const container = document.getElementById('root'); if (!container) throw new Error('Root element #root not found'); -createRoot(container).render( - - - , -); +async function bootstrap(): Promise { + const persisted = await window.codesign?.locale.getCurrent(); + const initial = normalizeLocale(persisted ?? navigator.language); + await initI18n(initial); + useCodesignStore.setState({ locale: initial }); + + // Persist any normalization back to disk so subsequent boots are stable. + if (window.codesign && persisted !== initial) { + await window.codesign.locale.set(initial); + } + + createRoot(container as HTMLElement).render( + + + , + ); +} + +void bootstrap(); diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index f6e701d6..dc31ae65 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -1,3 +1,4 @@ +import { type Locale, setLocale as applyLocale, normalizeLocale } from '@open-codesign/i18n'; import type { ChatMessage } from '@open-codesign/shared'; import { create } from 'zustand'; import type { CodesignApi } from '../../preload/index'; @@ -13,6 +14,9 @@ interface CodesignState { previewHtml: string | null; isGenerating: boolean; errorMessage: string | null; + locale: Locale; + initLocale: () => Promise; + setLocale: (locale: string) => Promise; sendPrompt: (prompt: string) => Promise; } @@ -21,6 +25,23 @@ export const useCodesignStore = create((set, get) => ({ previewHtml: null, isGenerating: false, errorMessage: null, + locale: 'en', + + async initLocale() { + if (!window.codesign) return; + const raw = await window.codesign.locale.getCurrent(); + const normalized = normalizeLocale(raw); + await applyLocale(normalized); + set({ locale: normalized }); + }, + + async setLocale(locale: string) { + const normalized = await applyLocale(locale); + set({ locale: normalized }); + if (window.codesign) { + await window.codesign.locale.set(normalized); + } + }, async sendPrompt(prompt: string) { if (get().isGenerating) return; diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 00000000..a9d50a17 --- /dev/null +++ b/docs/I18N.md @@ -0,0 +1,92 @@ +# Internationalization + +open-codesign ships with English (`en`) and Simplified Chinese (`zh-CN`). This +doc explains how to add a string, add a locale, and follow our naming rules. + +## Stack + +- [`i18next`](https://www.i18next.com) (~50 KB) for the core engine +- [`react-i18next`](https://react.i18next.com) (~25 KB) for the React binding +- Locale JSON files live in `packages/i18n/src/locales/` +- Per-locale demo prompts live in `packages/templates/src/locales/` + +The `@open-codesign/i18n` package exposes: + +| Export | Purpose | +| --- | --- | +| `initI18n(locale)` | One-time initialization; call from `main.tsx` before `createRoot` | +| `setLocale(locale)` | Switch language at runtime; persists nothing on its own | +| `getCurrentLocale()` | Read the active locale outside React | +| `useT()` | React hook returning a typed `t(key, options?)` function | +| `availableLocales` | Tuple of supported locale codes | +| `normalizeLocale(value)` | Map a system/user string to a supported locale (or warn + fall back) | + +## How locale auto-detection works + +1. On first run, the renderer reads `window.codesign.locale.getCurrent()`. +2. The main process returns the value persisted at + `~/.config/open-codesign/locale.json` if present, otherwise + `app.getLocale()`. +3. `normalizeLocale` maps the raw string to one of `en` / `zh-CN`. Common + variants (`zh`, `zh-Hans`, `zh-CN`, `zh_CN`, `en-US`, `en-GB`) map cleanly; + anything else logs `console.warn` and falls back to `en`. +4. `initI18n(locale)` boots i18next with the resolved value before React + renders. + +User overrides flow through `useCodesignStore.getState().setLocale(code)` +which calls `applyLocale(code)` and writes to the locale file via the +`locale:set` IPC channel. (Once `wt/onboarding` lands, this will fold into +`config.toml` under `ui.locale`.) + +## Adding a string + +1. Pick a namespace (top-level key in the JSON: `common`, `preview`, `chat`, + `settings`, `onboarding`, `commands`, `errors`, `demos`). +2. Add the key to **both** `packages/i18n/src/locales/en.json` and + `packages/i18n/src/locales/zh-CN.json`. Missing entries log a warning and + render the key path — never silent. +3. In the component, call `t('namespace.key')`. +4. For interpolation use `{{name}}`; pass values as the second argument: + `t('onboarding.paste.recognized', { provider: 'Anthropic' })`. +5. For pluralization use the i18next `_one` / `_other` suffix (already wired in + the `onboarding.paste.connected_*` example). + +## Naming convention + +- Keys are `lowerCamelCase` for words and `kebab-case` only when they refer to + external IDs (HTTP status codes etc). +- Keep nesting shallow: aim for 3 dotted levels (e.g. `preview.empty.title`). + 4 levels are allowed only when grouping a tightly related set such as + `onboarding.paste.errors.401`. +- Use full sentences in English. Translators copy your tone. +- Never concatenate localized fragments. Use a full template with + interpolation instead. + +## Adding a locale + +1. Create `packages/i18n/src/locales/.json` mirroring the existing tree. +2. Add the code to `availableLocales` in `packages/i18n/src/index.ts` and to + the `resources` map. +3. Add a demo file under `packages/templates/src/locales/.ts` and + register it in `packages/templates/src/index.ts` `REGISTRY`. +4. Update `normalizeLocale` if the new code has variants worth coalescing. +5. Add the new code to this doc and the language picker in Settings. + +## Missing key policy + +We never silently swallow missing keys. Concretely: + +- The handler logs `console.warn` with the namespace, key, and active locale. +- In development the rendered text is wrapped in `⟦…⟧` so the bug is loud. +- In production the rendered text is the raw key (no fake fallback string). + +If you need an English fallback, write the English entry — don't lean on the +fallback chain to hide a missing translation. + +## Tier scope (per `docs/PRINCIPLES.md` §5) + +- Tier 1 (now): English + Simplified Chinese, JSON resources bundled with the + renderer, single language switch in Settings. +- Tier 2: lazy-load locales by file, contributed translations via PR, RTL + support. +- Tier 3: in-app translation editor, per-component string extraction tooling. diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 00000000..4ef34f35 --- /dev/null +++ b/packages/i18n/package.json @@ -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" + } +} diff --git a/packages/i18n/src/index.test.ts b/packages/i18n/src/index.test.ts new file mode 100644 index 00000000..5602e421 --- /dev/null +++ b/packages/i18n/src/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } 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', () => { + expect(normalizeLocale('fr-FR')).toBe('en'); + 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); + }); +}); + +describe('initI18n + setLocale', () => { + 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…'); + + await setLocale('zh-CN'); + expect(i18n.t('chat.placeholder')).toBe('想设计什么?'); + expect(i18n.t('common.preAlpha')).toBe('预览版'); + }); +}); diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 00000000..24bae48e --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,96 @@ +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; + // Map e.g. "zh", "zh-Hans", "zh-Hans-CN", "zh-TW" to zh-CN when reasonable. + 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; + +export async function initI18n(locale: string | undefined): Promise { + const target = normalizeLocale(locale); + if (initialized) { + if (i18next.language !== target) { + await i18next.changeLanguage(target); + } + return target; + } + + const proc = (globalThis as { process?: { env?: Record } }).process; + const isDev = proc?.env?.['NODE_ENV'] !== 'production'; + + 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 `⟦${key}⟧`; + return key; + }, + react: { useSuspense: false }, + }); + + initialized = true; + return target; +} + +export async function setLocale(locale: string): Promise { + 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 { + const { t } = useTranslation(); + return (key, options) => t(key, options ?? {}) as string; +} + +export { i18next as i18n }; +export { useTranslation } from 'react-i18next'; diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json new file mode 100644 index 00000000..cc9c86ad --- /dev/null +++ b/packages/i18n/src/locales/en.json @@ -0,0 +1,130 @@ +{ + "common": { + "appName": "open-codesign", + "send": "Send", + "cancel": "Cancel", + "retry": "Retry", + "save": "Save", + "close": "Close", + "settings": "Settings", + "advanced": "Advanced", + "about": "About", + "learnMore": "Learn more", + "copy": "Copy", + "copied": "Copied", + "comingSoon": "Coming soon", + "loading": "Loading…", + "preAlpha": "pre-alpha", + "tagline": "BYOK · local-first · multi-model" + }, + "preview": { + "empty": { + "title": "Design with AI", + "body": "Pick a starter on the left, or describe what you want to design. The result renders here in a sandboxed preview.", + "starterChip": "Try a starter prompt:" + }, + "loading": { + "title": "Generating your design…" + }, + "error": { + "title": "Generation failed", + "body": "Something went wrong while generating your design.", + "copyError": "Copy error details" + }, + "ready": "Preview", + "noDesign": "No design yet" + }, + "chat": { + "placeholder": "Describe what to design…", + "sendShortcut": "Send (Enter)", + "emptyHint": "Start with a starter prompt or your own idea." + }, + "settings": { + "title": "Settings", + "tabs": { + "models": "Models", + "appearance": "Appearance", + "storage": "Storage", + "advanced": "Advanced" + }, + "language": { + "label": "Language", + "system": "System default" + }, + "theme": { + "label": "Theme", + "light": "Light", + "dark": "Dark", + "system": "System" + } + }, + "onboarding": { + "welcome": { + "title": "Welcome to open-codesign", + "subtitle": "Turn natural-language prompts into design artifacts. Pick how you want to start.", + "tryFree": "Try a free model", + "useKey": "Use my API key", + "useOllama": "Use a local model (Ollama)" + }, + "paste": { + "title": "Paste your API key", + "placeholder": "sk-…", + "recognized": "Detected provider: {{provider}}", + "connected_one": "Connected {{count}} model", + "connected_other": "Connected {{count}} models", + "howToGet": "How do I get a key?", + "errors": { + "401": "That key was rejected. Check it in your provider dashboard.", + "402": "Your account has no credit. Top up and retry.", + "429": "Rate limited. Wait a moment and try again.", + "network": "Network error reaching the provider. Check your connection." + } + }, + "choose": { + "title": "Pick a default model", + "primary": "Primary", + "fast": "Fast", + "estimatedCost": "Estimated cost: {{amount}}", + "start": "Start designing" + } + }, + "commands": { + "title": "Commands", + "placeholder": "Type a command or search…", + "items": { + "newDesign": "New design", + "toggleTheme": "Toggle theme", + "openSettings": "Open settings", + "export": "Export current design" + } + }, + "errors": { + "generic": "Something went wrong.", + "providerAuthMissing": "No API key configured. Open Settings to add one.", + "providerError": "Provider error: {{message}}", + "ipcBadInput": "Invalid input passed to the main process.", + "exporterNotReady": "Exporter is still loading. Try again in a moment." + }, + "demos": { + "meditationApp": { + "title": "Calm Spaces meditation app", + "description": "Mobile prototype with phone frame, soft palette, interactive nav.", + "prompt": "Design a mobile app prototype for a meditation app called Calm Spaces. Show a phone frame containing a home screen with a meditation list, play button, and progress tracker. Use serene typography, soft greens and blues, and lots of white space." + }, + "caseStudy": { + "title": "Client case study one-pager", + "description": "Dark theme one-page PDF-ready layout with hero metrics.", + "prompt": "Create a one-page client case study. The client increased qualified leads 40% using our platform. Include before/after metrics, a CEO quote, and a logo placeholder. Clean, minimal, dark theme." + }, + "pitchDeck": { + "title": "B2B SaaS pitch deck", + "description": "8-12 slides for a healthcare-targeted SaaS pitch.", + "prompt": "Design a pitch deck for a B2B SaaS company targeting mid-market healthcare. 8 to 10 slides covering problem, market, product, traction, team, and ask." + }, + "marketingLanding": { + "title": "Marketing landing page", + "description": "Hero + features + CTA, tunable accent color.", + "prompt": "Design a modern marketing landing page for an AI productivity tool. Include a hero section, three feature cards, social proof, and a call to action. Use a warm neutral palette." + } + } +} diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json new file mode 100644 index 00000000..bed79a68 --- /dev/null +++ b/packages/i18n/src/locales/zh-CN.json @@ -0,0 +1,130 @@ +{ + "common": { + "appName": "open-codesign", + "send": "发送", + "cancel": "取消", + "retry": "重试", + "save": "保存", + "close": "关闭", + "settings": "设置", + "advanced": "高级", + "about": "关于", + "learnMore": "了解更多", + "copy": "复制", + "copied": "已复制", + "comingSoon": "即将推出", + "loading": "加载中…", + "preAlpha": "预览版", + "tagline": "自带密钥 · 本地优先 · 多模型" + }, + "preview": { + "empty": { + "title": "用 AI 设计", + "body": "从左侧选一个范例,或直接描述你想做的设计。结果会渲染在右侧的沙箱预览里。", + "starterChip": "试试这些范例提示词:" + }, + "loading": { + "title": "正在生成你的设计…" + }, + "error": { + "title": "生成失败", + "body": "生成过程中出错了。", + "copyError": "复制错误详情" + }, + "ready": "预览", + "noDesign": "还没有设计" + }, + "chat": { + "placeholder": "想设计什么?", + "sendShortcut": "发送(回车)", + "emptyHint": "可以从范例开始,也可以直接说出你的想法。" + }, + "settings": { + "title": "设置", + "tabs": { + "models": "模型", + "appearance": "外观", + "storage": "存储", + "advanced": "高级" + }, + "language": { + "label": "语言", + "system": "跟随系统" + }, + "theme": { + "label": "主题", + "light": "浅色", + "dark": "深色", + "system": "跟随系统" + } + }, + "onboarding": { + "welcome": { + "title": "欢迎来到 open-codesign", + "subtitle": "用一句话生成可交互的设计稿。挑一种方式开始吧。", + "tryFree": "试用免费模型", + "useKey": "使用我的 API Key", + "useOllama": "用本地模型(Ollama)" + }, + "paste": { + "title": "粘贴你的 API Key", + "placeholder": "sk-…", + "recognized": "已识别提供商:{{provider}}", + "connected_one": "已连接 {{count}} 个模型", + "connected_other": "已连接 {{count}} 个模型", + "howToGet": "怎么获取 API Key?", + "errors": { + "401": "Key 被拒绝了。去提供商后台核对一下。", + "402": "账户余额不足,充值后重试。", + "429": "请求被限流,稍等片刻再试。", + "network": "连接提供商失败,检查一下网络。" + } + }, + "choose": { + "title": "选一个默认模型", + "primary": "主力", + "fast": "快速", + "estimatedCost": "预计费用:{{amount}}", + "start": "开始设计" + } + }, + "commands": { + "title": "命令", + "placeholder": "输入命令或搜索…", + "items": { + "newDesign": "新建设计", + "toggleTheme": "切换主题", + "openSettings": "打开设置", + "export": "导出当前设计" + } + }, + "errors": { + "generic": "出错了。", + "providerAuthMissing": "还没配置 API Key。打开设置去添加一个。", + "providerError": "提供商错误:{{message}}", + "ipcBadInput": "传给主进程的参数不合法。", + "exporterNotReady": "导出器还在加载,稍等片刻再试。" + }, + "demos": { + "meditationApp": { + "title": "Calm Spaces 冥想 App", + "description": "带手机外框的移动端原型,柔和配色,可点击导航。", + "prompt": "为一个名叫 Calm Spaces 的冥想 App 设计移动端原型。展示一个带手机外框的主屏,包含冥想课程列表、播放按钮和进度追踪。字体安静、配色以柔和的绿色和蓝色为主,留白充足。" + }, + "caseStudy": { + "title": "客户案例单页", + "description": "深色主题、可直接导出 PDF 的单页布局,含核心指标。", + "prompt": "做一个一页纸的客户案例。客户使用我们的平台后,合格线索增长了 40%。包含前后对比指标、CEO 引言和 Logo 占位。简洁极简,深色主题。" + }, + "pitchDeck": { + "title": "B2B SaaS 融资演示", + "description": "面向医疗中端市场的 8 到 12 页 SaaS 路演稿。", + "prompt": "为一家面向中端医疗市场的 B2B SaaS 公司设计融资演示稿。8 到 10 页,覆盖问题、市场、产品、增长、团队和融资金额。" + }, + "marketingLanding": { + "title": "营销落地页", + "description": "Hero + 特性 + 行动召唤,主色可调。", + "prompt": "为一款 AI 效率工具设计现代风格的营销落地页。包含 Hero 区、三个特性卡片、社会证明和行动召唤。整体使用暖色调中性配色。" + } + } +} diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 00000000..5bfe4fd1 --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "compilerOptions": { + "outDir": "dist", + "jsx": "react-jsx" + } +} diff --git a/packages/templates/package.json b/packages/templates/package.json index 165d9aae..44bdc9a9 100644 --- a/packages/templates/package.json +++ b/packages/templates/package.json @@ -13,6 +13,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@open-codesign/i18n": "workspace:*", "@open-codesign/shared": "workspace:*" }, "devDependencies": { diff --git a/packages/templates/src/index.ts b/packages/templates/src/index.ts index e9469a34..1535d70e 100644 --- a/packages/templates/src/index.ts +++ b/packages/templates/src/index.ts @@ -1,8 +1,14 @@ /** * Built-in demo prompts. Aligned with the eight Claude Design demos * we committed to replicate (see docs/VISION.md). + * + * Per-locale variants live under ./locales/. */ +import { type Locale, availableLocales, normalizeLocale } from '@open-codesign/i18n'; +import { enDemos } from './locales/en'; +import { zhCNDemos } from './locales/zh-CN'; + export interface DemoTemplate { id: string; title: string; @@ -10,37 +16,24 @@ export interface DemoTemplate { prompt: string; } -export const BUILTIN_DEMOS: DemoTemplate[] = [ - { - id: 'meditation-app', - title: 'Calm Spaces meditation app', - description: 'Mobile prototype with phone frame, soft palette, interactive nav.', - prompt: - 'Design a mobile app prototype for a meditation app called Calm Spaces. Show a phone frame containing a home screen with a meditation list, play button, and progress tracker. Use serene typography, soft greens and blues, and lots of white space.', - }, - { - id: 'case-study-onepager', - title: 'Client case study one-pager', - description: 'Dark theme one-page PDF-ready layout with hero metrics.', - prompt: - 'Create a one-page client case study. The client increased qualified leads 40% using our platform. Include before/after metrics, a CEO quote, and a logo placeholder. Clean, minimal, dark theme.', - }, - { - id: 'pitch-deck', - title: 'B2B SaaS pitch deck', - description: '8-12 slides for a healthcare-targeted SaaS pitch.', - prompt: - 'Design a pitch deck for a B2B SaaS company targeting mid-market healthcare. 8 to 10 slides covering problem, market, product, traction, team, and ask.', - }, - { - id: 'marketing-landing', - title: 'Marketing landing page', - description: 'Hero + features + CTA, tunable accent color.', - prompt: - 'Design a modern marketing landing page for an AI productivity tool. Include a hero section, three feature cards, social proof, and a call to action. Use a warm neutral palette.', - }, -]; +const REGISTRY: Record = { + en: enDemos, + 'zh-CN': zhCNDemos, +}; + +export function getDemos(locale: string | undefined): DemoTemplate[] { + const target = normalizeLocale(locale); + const demos = REGISTRY[target]; + if (!demos) { + console.warn( + `[templates] no demos registered for locale "${target}"; falling back to "en". ` + + `Supported: ${availableLocales.join(', ')}`, + ); + return REGISTRY.en; + } + return demos; +} -export function getDemo(id: string): DemoTemplate | undefined { - return BUILTIN_DEMOS.find((d) => d.id === id); +export function getDemo(id: string, locale: string | undefined): DemoTemplate | undefined { + return getDemos(locale).find((d) => d.id === id); } diff --git a/packages/templates/src/locales/en.ts b/packages/templates/src/locales/en.ts new file mode 100644 index 00000000..35fb375c --- /dev/null +++ b/packages/templates/src/locales/en.ts @@ -0,0 +1,32 @@ +import type { DemoTemplate } from '../index'; + +export const enDemos: DemoTemplate[] = [ + { + id: 'meditation-app', + title: 'Calm Spaces meditation app', + description: 'Mobile prototype with phone frame, soft palette, interactive nav.', + prompt: + 'Design a mobile app prototype for a meditation app called Calm Spaces. Show a phone frame containing a home screen with a meditation list, play button, and progress tracker. Use serene typography, soft greens and blues, and lots of white space.', + }, + { + id: 'case-study-onepager', + title: 'Client case study one-pager', + description: 'Dark theme one-page PDF-ready layout with hero metrics.', + prompt: + 'Create a one-page client case study. The client increased qualified leads 40% using our platform. Include before/after metrics, a CEO quote, and a logo placeholder. Clean, minimal, dark theme.', + }, + { + id: 'pitch-deck', + title: 'B2B SaaS pitch deck', + description: '8-12 slides for a healthcare-targeted SaaS pitch.', + prompt: + 'Design a pitch deck for a B2B SaaS company targeting mid-market healthcare. 8 to 10 slides covering problem, market, product, traction, team, and ask.', + }, + { + id: 'marketing-landing', + title: 'Marketing landing page', + description: 'Hero + features + CTA, tunable accent color.', + prompt: + 'Design a modern marketing landing page for an AI productivity tool. Include a hero section, three feature cards, social proof, and a call to action. Use a warm neutral palette.', + }, +]; diff --git a/packages/templates/src/locales/zh-CN.ts b/packages/templates/src/locales/zh-CN.ts new file mode 100644 index 00000000..7b9c2d0a --- /dev/null +++ b/packages/templates/src/locales/zh-CN.ts @@ -0,0 +1,32 @@ +import type { DemoTemplate } from '../index'; + +export const zhCNDemos: DemoTemplate[] = [ + { + id: 'meditation-app', + title: 'Calm Spaces 冥想 App', + description: '带手机外框的移动端原型,柔和配色,可点击导航。', + prompt: + '为一个名叫 Calm Spaces 的冥想 App 设计移动端原型。展示一个带手机外框的主屏,包含冥想课程列表、播放按钮和进度追踪。字体安静、配色以柔和的绿色和蓝色为主,留白充足。', + }, + { + id: 'case-study-onepager', + title: '客户案例单页', + description: '深色主题、可直接导出 PDF 的单页布局,含核心指标。', + prompt: + '做一个一页纸的客户案例。客户使用我们的平台后,合格线索增长了 40%。包含前后对比指标、CEO 引言和 Logo 占位。简洁极简,深色主题。', + }, + { + id: 'pitch-deck', + title: 'B2B SaaS 融资演示', + description: '面向医疗中端市场的 8 到 12 页 SaaS 路演稿。', + prompt: + '为一家面向中端医疗市场的 B2B SaaS 公司设计融资演示稿。8 到 10 页,覆盖问题、市场、产品、增长、团队和融资金额。', + }, + { + id: 'marketing-landing', + title: '营销落地页', + description: 'Hero + 特性 + 行动召唤,主色可调。', + prompt: + '为一款 AI 效率工具设计现代风格的营销落地页。包含 Hero 区、三个特性卡片、社会证明和行动召唤。整体使用暖色调中性配色。', + }, +]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29b55268..ac448faa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@open-codesign/core': specifier: workspace:* version: link:../../packages/core + '@open-codesign/i18n': + specifier: workspace:* + version: link:../../packages/i18n '@open-codesign/providers': specifier: workspace:* version: link:../../packages/providers @@ -151,6 +154,28 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.17)(lightningcss@1.32.0) + packages/i18n: + dependencies: + i18next: + specifier: ^23.16.5 + version: 23.16.8 + react-i18next: + specifier: ^15.1.3 + version: 15.7.4(i18next@23.16.8)(react@19.2.5)(typescript@5.9.3) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: ^19.0.0 + version: 19.2.5 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.17)(lightningcss@1.32.0) + packages/providers: dependencies: '@mariozechner/pi-ai': @@ -195,6 +220,9 @@ importers: packages/templates: dependencies: + '@open-codesign/i18n': + specifier: workspace:* + version: link:../i18n '@open-codesign/shared': specifier: workspace:* version: link:../shared @@ -2397,6 +2425,9 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -2427,6 +2458,9 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + i18next@23.16.8: + resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -3107,6 +3141,22 @@ packages: peerDependencies: react: ^19.2.5 + react-i18next@15.7.4: + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} + peerDependencies: + i18next: '>= 23.4.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -3567,6 +3617,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -6399,6 +6453,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-cache-semantics@4.2.0: {} http-proxy-agent@5.0.0: @@ -6441,6 +6499,10 @@ snapshots: dependencies: ms: 2.1.3 + i18next@23.16.8: + dependencies: + '@babel/runtime': 7.29.2 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 @@ -7051,6 +7113,15 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-i18next@15.7.4(i18next@23.16.8)(react@19.2.5)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 23.16.8 + react: 19.2.5 + optionalDependencies: + typescript: 5.9.3 + react-refresh@0.17.0: {} react@19.2.5: {} @@ -7502,6 +7573,8 @@ snapshots: - supports-color - terser + void-elements@3.1.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4