From f565594597cf2814c5fb82e8953f70e12f415e20 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sat, 18 Apr 2026 15:43:11 +0800 Subject: [PATCH 1/4] 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> --- packages/i18n/package.json | 30 ++++++ packages/i18n/src/i18n.test.ts | 72 ++++++++++++++ packages/i18n/src/index.ts | 112 ++++++++++++++++++++++ packages/i18n/src/locales/en.json | 134 +++++++++++++++++++++++++++ packages/i18n/src/locales/zh-CN.json | 134 +++++++++++++++++++++++++++ packages/i18n/tsconfig.json | 8 ++ pnpm-lock.yaml | 70 ++++++++++++++ 7 files changed, 560 insertions(+) create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/src/i18n.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 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/i18n.test.ts b/packages/i18n/src/i18n.test.ts new file mode 100644 index 00000000..4be7f25a --- /dev/null +++ b/packages/i18n/src/i18n.test.ts @@ -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(); + }); +}); diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 00000000..ea27e15f --- /dev/null +++ b/packages/i18n/src/index.ts @@ -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 } }).process; + return proc?.env?.['NODE_ENV'] !== 'production'; +} + +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 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 { + 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..e89605f6 --- /dev/null +++ b/packages/i18n/src/locales/en.json @@ -0,0 +1,134 @@ +{ + "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." + }, + "advanced": { + "title": "Advanced — custom base URL", + "description": "If you use a proxy or relay, paste the full base URL (must include /v1)." + } + }, + "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..79ed9914 --- /dev/null +++ b/packages/i18n/src/locales/zh-CN.json @@ -0,0 +1,134 @@ +{ + "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": "连接提供商失败,检查一下网络。" + }, + "advanced": { + "title": "高级 — 自定义 Base URL", + "description": "如果使用代理或中转站,请粘贴完整的 Base URL(必须包含 /v1)。" + } + }, + "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/pnpm-lock.yaml b/pnpm-lock.yaml index 39facade..6dedac87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,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': @@ -204,6 +226,9 @@ importers: packages/templates: dependencies: + '@open-codesign/i18n': + specifier: workspace:* + version: link:../i18n '@open-codesign/shared': specifier: workspace:* version: link:../shared @@ -2726,6 +2751,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==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -2759,6 +2787,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} @@ -3482,6 +3513,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'} @@ -4013,6 +4060,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'} + vue@3.5.32: resolution: {integrity: sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==} peerDependencies: @@ -7241,6 +7292,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-void-elements@3.0.0: {} http-cache-semantics@4.2.0: {} @@ -7285,6 +7340,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 @@ -7944,6 +8003,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: {} @@ -8519,6 +8587,8 @@ snapshots: - supports-color - terser + void-elements@3.1.0: {} + vue@3.5.32(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.32 From 6a2ef5dd4626fe8fa73533c3cfac378ca40915f3 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sat, 18 Apr 2026 15:43:24 +0800 Subject: [PATCH 2/4] 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> --- packages/templates/package.json | 1 + packages/templates/src/index.ts | 65 +++++++++++++------------ packages/templates/src/locales/en.ts | 32 ++++++++++++ packages/templates/src/locales/zh-CN.ts | 32 ++++++++++++ 4 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 packages/templates/src/locales/en.ts create mode 100644 packages/templates/src/locales/zh-CN.ts 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 7909cf79..3a9ee810 100644 --- a/packages/templates/src/index.ts +++ b/packages/templates/src/index.ts @@ -1,8 +1,16 @@ /** * 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/. Use `getDemos(locale)` / + * `getDemo(id, locale)` for new code; `BUILTIN_DEMOS` is kept as an + * English alias for backward compatibility with pre-i18n callers. */ +import { type Locale, availableLocales, normalizeLocale } from '@open-codesign/i18n'; +import { enDemos } from './locales/en'; +import { zhCNDemos } from './locales/zh-CN'; + export { SYSTEM_PROMPTS, type SystemPromptId } from './system/index'; export interface DemoTemplate { @@ -12,37 +20,30 @@ 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 enDemos; + } + return demos; +} -export function getDemo(id: string): DemoTemplate | undefined { - return BUILTIN_DEMOS.find((d) => d.id === id); +export function getDemo(id: string, locale?: string): DemoTemplate | undefined { + return getDemos(locale).find((d) => d.id === id); } + +/** + * @deprecated Use `getDemos(locale)`. Kept as an English-only alias so existing + * imports do not break while the renderer migrates to the locale-aware API. + */ +export const BUILTIN_DEMOS: DemoTemplate[] = enDemos; 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 区、三个特性卡片、社会证明和行动召唤。整体使用暖色调中性配色。', + }, +]; From 7d8a08bba56c5b34584e664a5aa19e143d8e4da7 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sat, 18 Apr 2026 15:43:35 +0800 Subject: [PATCH 3/4] feat(desktop): locale IPC handlers (main process) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- apps/desktop/src/main/locale-ipc.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 apps/desktop/src/main/locale-ipc.ts diff --git a/apps/desktop/src/main/locale-ipc.ts b/apps/desktop/src/main/locale-ipc.ts new file mode 100644 index 00000000..ddd1917f --- /dev/null +++ b/apps/desktop/src/main/locale-ipc.ts @@ -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 { + 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 = { 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; + }); +} From fd85516221b55fef9586aa4cc6b24e77c80bcb2b Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sat, 18 Apr 2026 15:43:35 +0800 Subject: [PATCH 4/4] docs(i18n): add I18N.md covering architecture, conventions, and how-to Signed-off-by: hqhq1025 <1506751656@qq.com> --- docs/I18N.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/I18N.md diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 00000000..aa5339ce --- /dev/null +++ b/docs/I18N.md @@ -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 ; + } + ``` + +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/.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..{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.