From 8b6d8528754b5c63493637d82781300019082249 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 30 May 2026 17:37:55 +0800 Subject: [PATCH 1/2] refactor(i18n): tighten shared terms and dynamic key proofs --- BitFun-Installer/src/App.tsx | 5 +- BitFun-Installer/src/i18n/locales/en.json | 3 -- BitFun-Installer/src/i18n/locales/zh-TW.json | 3 -- BitFun-Installer/src/i18n/locales/zh.json | 3 -- docs/architecture/i18n.md | 7 +-- docs/development/i18n.md | 5 ++ scripts/i18n-audit.mjs | 43 +++++++++++++++++ scripts/i18n-contract.test.mjs | 51 ++++++++++++++++++++ scripts/i18n-dynamic-key-allowlist.json | 48 ++++++++++++++++++ scripts/i18n-governance-baseline.json | 6 +-- 10 files changed, 157 insertions(+), 17 deletions(-) diff --git a/BitFun-Installer/src/App.tsx b/BitFun-Installer/src/App.tsx index 44452c650..a6bcfa5c9 100644 --- a/BitFun-Installer/src/App.tsx +++ b/BitFun-Installer/src/App.tsx @@ -112,7 +112,8 @@ function App() { const isFullscreen = installer.step === 'lang' || installer.step === 'uninstall'; const stepNum = STEP_NUMBERS[installer.step]; - const title = STEP_TITLES[installer.step] || t('titlebar.default'); + const defaultTitle = t('shared.product.name'); + const title = STEP_TITLES[installer.step] || defaultTitle; const useSuccessStepColor = installer.installationCompleted; return ( @@ -120,7 +121,7 @@ function App() {
- {isFullscreen ? t('titlebar.default') : ( + {isFullscreen ? defaultTitle : ( <> {stepNum} / 4 · diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index e87f77d64..7a442f200 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -191,8 +191,5 @@ "completed": "Uninstall completed. You can close this window.", "cancel": "Cancel", "close": "Close" - }, - "titlebar": { - "default": "BitFun" } } diff --git a/BitFun-Installer/src/i18n/locales/zh-TW.json b/BitFun-Installer/src/i18n/locales/zh-TW.json index 504133ca2..7e68229c0 100644 --- a/BitFun-Installer/src/i18n/locales/zh-TW.json +++ b/BitFun-Installer/src/i18n/locales/zh-TW.json @@ -191,8 +191,5 @@ "completed": "卸載已完成,可關閉窗口。", "cancel": "取消", "close": "關閉" - }, - "titlebar": { - "default": "BitFun" } } diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 99440a730..e5fd928f3 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -191,8 +191,5 @@ "completed": "卸载已完成,可关闭窗口。", "cancel": "取消", "close": "关闭" - }, - "titlebar": { - "default": "BitFun" } } diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md index df0245098..3623aec0f 100644 --- a/docs/architecture/i18n.md +++ b/docs/architecture/i18n.md @@ -191,9 +191,10 @@ not automatically delete keys or force shared-term migrations. Dynamic key contracts are tracked in `scripts/i18n-dynamic-key-allowlist.json`. Each entry must name the owning code -path, surface, and exact keys or prefixes. Stale entries fail `i18n:audit`, so -metadata-driven keys, backend error-code mappings, fallback-key arrays, and -template factories stay visible before cleanup work deletes resource keys. +path, source references, surface, and exact keys or prefixes. Stale entries or +source references fail `i18n:audit`, so metadata-driven keys, backend error-code +mappings, fallback-key arrays, and template factories stay visible before cleanup +work deletes resource keys. `scripts/i18n-governance-baseline.json` is the no-growth baseline for confirmed unused keys, shared-term duplicate candidates, and l10n quality candidates. The diff --git a/docs/development/i18n.md b/docs/development/i18n.md index 8854e21ad..c67697d5b 100644 --- a/docs/development/i18n.md +++ b/docs/development/i18n.md @@ -130,6 +130,11 @@ template literals, string concatenation, metadata fields such as `labelKey`, and backend or bridge contracts that map enum/error codes into i18n keys. Only delete the key when no release surface can reach it. +When a key is kept for a dynamic contract, document it in +`scripts/i18n-dynamic-key-allowlist.json` with the owning path, exact keys or +prefixes, and `sourceReferences` that appear in the owning code. `i18n:audit` +fails stale keys and stale source references. + Do not rely on literal fallback strings in component code. Add the missing key to the relevant locale file instead. Literal fallback strings make audits and translation completeness harder to enforce. Existing Web UI diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs index ac756bc96..e88fde0db 100644 --- a/scripts/i18n-audit.mjs +++ b/scripts/i18n-audit.mjs @@ -1011,6 +1011,32 @@ function validateOptionalStringArray(entry, field, label) { return optionalStringArray(entry, field); } +function readDynamicKeyOwnerSource(entry) { + if (!isNonEmptyString(entry.owner)) { + return ''; + } + + const ownerPath = path.join(root, entry.owner); + if (!fs.existsSync(ownerPath)) { + reportError(`Dynamic key allowlist "${entry.id}" owner path does not exist: ${entry.owner}`); + return ''; + } + + const stat = fs.statSync(ownerPath); + if (stat.isFile()) { + return fs.readFileSync(ownerPath, 'utf8'); + } + + if (!stat.isDirectory()) { + reportError(`Dynamic key allowlist "${entry.id}" owner path is not a file or directory: ${entry.owner}`); + return ''; + } + + return listFiles(ownerPath, (file) => /\.(?:cjs|js|jsx|mjs|rs|ts|tsx)$/.test(file)) + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n'); +} + function readDynamicKeyAllowlist() { if (!fs.existsSync(dynamicKeyAllowlistPath)) { reportError('Missing scripts/i18n-dynamic-key-allowlist.json'); @@ -1052,9 +1078,13 @@ function readDynamicKeyAllowlist() { const keys = validateOptionalStringArray(entry, 'keys', 'Dynamic key allowlist'); const prefixes = validateOptionalStringArray(entry, 'keyPrefixes', 'Dynamic key allowlist'); + const sourceReferences = validateOptionalStringArray(entry, 'sourceReferences', 'Dynamic key allowlist'); if (keys.length === 0 && prefixes.length === 0) { reportError(`Dynamic key allowlist "${entry.id}" must define keys or keyPrefixes`); } + if (sourceReferences.length === 0) { + reportError(`Dynamic key allowlist "${entry.id}" must define sourceReferences`); + } for (const key of keys) { if (!isNonEmptyString(key)) { reportError(`Dynamic key allowlist "${entry.id}" has an invalid key entry`); @@ -1065,6 +1095,18 @@ function readDynamicKeyAllowlist() { reportError(`Dynamic key allowlist "${entry.id}" has an invalid keyPrefixes entry`); } } + for (const sourceReference of sourceReferences) { + if (!isNonEmptyString(sourceReference)) { + reportError(`Dynamic key allowlist "${entry.id}" has an invalid sourceReferences entry`); + } + } + + const ownerSource = sourceReferences.length > 0 ? readDynamicKeyOwnerSource(entry) : ''; + for (const sourceReference of sourceReferences.filter(isNonEmptyString)) { + if (!ownerSource.includes(sourceReference)) { + reportError(`Dynamic key allowlist "${entry.id}" source reference "${sourceReference}" was not found under ${entry.owner}`); + } + } } return allowlist; @@ -1178,6 +1220,7 @@ function collectDynamicKeyCandidates(resourceGroups) { resourceKey: group.resourceKey, owner: entry.owner, reason: entry.description, + sourceReferences: entry.sourceReferences ?? [], locales: group.locales, files: group.files, }); diff --git a/scripts/i18n-contract.test.mjs b/scripts/i18n-contract.test.mjs index 2c0f07114..8ef5e8f90 100644 --- a/scripts/i18n-contract.test.mjs +++ b/scripts/i18n-contract.test.mjs @@ -516,6 +516,35 @@ test('web-ui uses shared terms for stable navigation and feature labels', { conc } }); +test('installer uses the shared product name for titlebar defaults', { concurrency: false }, () => { + const appSource = readText('BitFun-Installer/src/App.tsx'); + const localePaths = [ + 'BitFun-Installer/src/i18n/locales/en.json', + 'BitFun-Installer/src/i18n/locales/zh.json', + 'BitFun-Installer/src/i18n/locales/zh-TW.json', + ]; + + assert.match( + appSource, + /shared\.product\.name/, + 'installer titlebar default should read the canonical shared product name', + ); + assert.doesNotMatch( + appSource, + /titlebar\.default/, + 'installer source should not call the removed copied titlebar.default key', + ); + + for (const localePath of localePaths) { + const resource = readJson(localePath); + assert.equal( + resource.titlebar, + undefined, + `${localePath} should not duplicate the shared product name under titlebar.default`, + ); + } +}); + test('i18n audit enforces governance candidate baselines', { concurrency: false }, () => { const baselinePath = 'scripts/i18n-governance-baseline.json'; const baseline = readJson(baselinePath); @@ -623,6 +652,28 @@ test('i18n audit fails stale dynamic key allowlist entries', { concurrency: fals }); }); +test('i18n audit fails stale dynamic key source references', { concurrency: false }, () => { + const allowlistPath = 'scripts/i18n-dynamic-key-allowlist.json'; + const allowlist = readJson(allowlistPath); + + assert.ok( + allowlist.entries.every((entry) => Array.isArray(entry.sourceReferences) && entry.sourceReferences.length > 0), + 'dynamic key allowlist entries should include sourceReferences that prove the owning code path', + ); + + allowlist.entries[0].sourceReferences = ['__missing_i18n_dynamic_key_source_reference__']; + + withTemporaryTextFile(allowlistPath, `${JSON.stringify(allowlist, null, 2)}\n`, () => { + const result = runI18nAudit(); + assert.notEqual(result.status, 0, 'stale dynamic key source references must fail ordinary i18n:audit'); + assert.match( + `${result.stdout}\n${result.stderr}`, + /source reference "__missing_i18n_dynamic_key_source_reference__"/, + 'audit output should identify the missing dynamic key source reference', + ); + }); +}); + test('i18n audit validates static hook translation keys with namespace context', () => { const auditSource = readText('scripts/i18n-audit.mjs'); diff --git a/scripts/i18n-dynamic-key-allowlist.json b/scripts/i18n-dynamic-key-allowlist.json index 07ee4542a..a73b93063 100644 --- a/scripts/i18n-dynamic-key-allowlist.json +++ b/scripts/i18n-dynamic-key-allowlist.json @@ -6,6 +6,10 @@ "surface": "installer", "owner": "BitFun-Installer/src/utils/installPathErrors.ts", "description": "Installer backend returns INSTALL_PATH::snake_case codes that the frontend maps to errors.installPath.camelCase.", + "sourceReferences": [ + "const key = `errors.installPath.${snakeToCamelKey(code)}`", + "installPathErrorShowsAdminHint" + ], "keys": [ "errors.installPath.notAbsolute", "errors.installPath.filesystemRoot", @@ -21,6 +25,11 @@ "surface": "installer", "owner": "BitFun-Installer/src/data/modelProviders.ts", "description": "Installer model-provider templates resolve nameKey, descriptionKey, and noteKey metadata at render time.", + "sourceReferences": [ + "nameKey: 'model.providers.openbitfun.name'", + "descriptionKey: 'model.providers.openbitfun.description'", + "noteKey: 'model.providers.minimax.urlOptions.default'" + ], "keyPrefixes": [ "model.providers." ] @@ -31,6 +40,11 @@ "namespace": "settings", "owner": "src/web-ui/src/app/scenes/settings/settingsConfig.ts", "description": "Settings categories and tabs store nameKey, labelKey, and descriptionKey metadata resolved with the settings namespace.", + "sourceReferences": [ + "nameKey: 'configCenter.categories.general'", + "labelKey: 'configCenter.tabs.basics'", + "descriptionKey: 'configCenter.tabDescriptions.basics'" + ], "keyPrefixes": [ "configCenter.categories.", "configCenter.tabs.", @@ -43,6 +57,10 @@ "namespace": "settings", "owner": "src/web-ui/src/shared/constants/shortcuts.ts", "description": "Keyboard shortcut descriptors store descriptionKey metadata resolved with the settings namespace.", + "sourceReferences": [ + "descriptionKey: 'keyboard.shortcuts.panel.toggleLeft'", + "getShortcutDescriptionI18nKey" + ], "keyPrefixes": [ "keyboard.shortcuts." ] @@ -53,6 +71,10 @@ "namespace": "settings/editor", "owner": "src/web-ui/src/infrastructure/config/components/EditorConfig.tsx", "description": "Editor option descriptors store labelKey metadata resolved with the settings/editor namespace.", + "sourceReferences": [ + "labelKey: 'appearance.cursorStyles.line'", + "label: t(o.labelKey)" + ], "keys": [ "display.minimapPositionLeft", "display.minimapPositionRight", @@ -75,6 +97,11 @@ "namespace": "settings/quick-actions", "owner": "src/web-ui/src/infrastructure/config/services/quickActionLocalization.ts", "description": "Built-in quick action defaults resolve labelKey metadata with the settings/quick-actions namespace.", + "sourceReferences": [ + "labelKey: 'quickActions.defaults.commit.label'", + "promptKey: 'quickActions.defaults.commit.prompt'", + "t(builtin.labelKey" + ], "keyPrefixes": [ "quickActions.defaults." ] @@ -85,6 +112,10 @@ "namespace": "flow-chat", "owner": "src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts", "description": "Agent companion activity descriptors store labelKey metadata resolved with the flow-chat namespace.", + "sourceReferences": [ + "labelKey: 'agentCompanion.activity.thinking'", + "labelKey: 'agentCompanion.activity.failed'" + ], "keyPrefixes": [ "agentCompanion.activity." ] @@ -95,6 +126,10 @@ "namespace": "flow-chat", "owner": "src/web-ui/src/flow_chat/utils/deepReviewExperience.ts", "description": "Deep Review degradation descriptors store labelKey and descriptionKey metadata resolved with the flow-chat namespace.", + "sourceReferences": [ + "labelKey: 'deepReviewActionBar.degradation.reduceReviewers'", + "descriptionKey: 'deepReviewActionBar.degradation.reduceReviewersDesc'" + ], "keys": [ "deepReviewActionBar.degradation.reduceReviewers", "deepReviewActionBar.degradation.reduceReviewersDesc", @@ -110,6 +145,10 @@ "namespace": "errors", "owner": "src/web-ui/src/shared/ai-errors/aiErrorPresenter.ts", "description": "AI error presenters map action codes to errors:ai.actions.* label keys.", + "sourceReferences": [ + "retry: 'errors:ai.actions.retry'", + "actions: actionCodes.map" + ], "keyPrefixes": [ "ai.actions." ] @@ -120,6 +159,10 @@ "namespace": "scenes/profile", "owner": "src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx", "description": "Assistant persona document labels are generated from Markdown filenames into nursery.assistant.personaDocs.* keys.", + "sourceReferences": [ + "fileName.replace(/\\.md$/i", + "nursery.assistant.personaDocs.${labelKey}" + ], "keyPrefixes": [ "nursery.assistant.personaDocs." ] @@ -130,6 +173,11 @@ "namespace": "scenes/skills", "owner": "src/web-ui/src/app/scenes/skills", "description": "Skills filters and suite modes store labelKey and descKey metadata resolved with the scenes/skills namespace.", + "sourceReferences": [ + "labelKey: 'filters.all'", + "labelKey: 'suite.modes.agentic'", + "descKey: 'suite.modeDescriptions.agentic'" + ], "keyPrefixes": [ "filters.", "categories.", diff --git a/scripts/i18n-governance-baseline.json b/scripts/i18n-governance-baseline.json index acdbaa679..f24ac301e 100644 --- a/scripts/i18n-governance-baseline.json +++ b/scripts/i18n-governance-baseline.json @@ -6,10 +6,10 @@ "maxTotal": 0 }, "sharedTermDuplicates": { - "maxTotal": 214, + "maxTotal": 211, "bySurface": { "core": 18, - "installer": 3, + "installer": 0, "mobile-web": 8, "relay-static-homepage": 2, "web-ui": 183 @@ -29,7 +29,7 @@ "modes.assistant": 2, "modes.expert": 3, "modes.review": 1, - "product.name": 6, + "product.name": 3, "statuses.cancelled": 29, "statuses.done": 26, "statuses.failed": 44, From dcb0b55909c9908c384c2b5d48938b007d487580 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 30 May 2026 18:17:06 +0800 Subject: [PATCH 2/2] refactor(i18n): harden dynamic proofs and shared terms --- AGENTS.md | 2 + CONTRIBUTING.md | 2 + docs/architecture/i18n.md | 10 +- docs/development/i18n.md | 9 +- scripts/generate-i18n-contract.mjs | 40 ++++++ scripts/i18n-audit.mjs | 60 ++++++++- scripts/i18n-contract.test.mjs | 118 ++++++++++++++++-- scripts/i18n-governance-baseline.json | 10 +- .../relay-server/static/homepage/i18n.json | 12 +- .../static/homepage/i18n.shared.json | 17 +++ .../relay-server/static/homepage/index.html | 9 +- src/crates/core/locales/en-US.ftl | 1 - src/crates/core/locales/zh-CN.ftl | 1 - src/crates/core/locales/zh-TW.ftl | 1 - src/crates/core/src/service/i18n/service.rs | 30 ++++- 15 files changed, 290 insertions(+), 32 deletions(-) create mode 100644 src/apps/relay-server/static/homepage/i18n.shared.json diff --git a/AGENTS.md b/AGENTS.md index 58fa2d6ef..f97644c47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,8 @@ For the full script list, see [`package.json`](package.json). in the owning product surface. - Do not import Web UI locale resources into smaller product surfaces such as `src/mobile-web` or `BitFun-Installer`. See `docs/architecture/i18n.md`. +- Static self-contained pages may use generated page-scoped shared-term files; + they must not import Web UI locale catalogs. - Web UI loads only bootstrap namespaces eagerly; use `useI18n(namespace)` for route or feature copy and keep direct `i18nService.t(...)` calls in bootstrap namespaces. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9408b2b7..3100a6772 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,8 @@ Captured data is logged as structured JSON under the `bitfun::devtools` target. workflow copy in the owning surface. - Web UI route or feature copy should use `useI18n(namespace)`. Do not import Web UI locale catalogs into mobile-web, installer, backend, or static pages. +- Static self-contained pages may use generated page-scoped shared-term files + instead of copying stable labels. - `pnpm run i18n:audit` enforces key/placeholder parity, direct static key existence, dynamic key governance, no-growth i18n governance baselines, and the no-hardcoded-CJK source budget. diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md index 3623aec0f..6a1dd73a2 100644 --- a/docs/architecture/i18n.md +++ b/docs/architecture/i18n.md @@ -32,6 +32,7 @@ Generated outputs: - `BitFun-Installer/src/i18n/generatedLocaleContract.ts` - `src/crates/core/src/service/i18n/generated_locale_contract.rs` - `BitFun-Installer/src-tauri/src/installer/generated_locale_contract.rs` +- `src/apps/relay-server/static/homepage/i18n.shared.json` Do not edit generated files manually. Change the canonical contract and rerun the generator. @@ -96,7 +97,10 @@ to their current locale data. contract and audit baseline, but it does not import runtime locale catalogs. Keep its static copy in its own resource file, such as `src/apps/relay-server/static/homepage/i18n.json`, with English HTML fallback - text for first paint and fetch-failure resilience. + text for first paint and fetch-failure resilience. If it needs a shared + concept label, reference it as `{ "$shared": "features.remoteControl" }` and + load the generated page-scoped `i18n.shared.json` file rather than any Web UI + catalog. Direct Web UI `i18nService.t('namespace:key')` calls are allowed only for bootstrap namespaces. Route, scene, and feature UI should use `useI18n(namespace)` @@ -186,7 +190,9 @@ quality candidates are translation work, not structural cleanup, and should not be treated as unused keys. `pnpm run i18n:audit -- --report-json ` emits the machine-readable -governance snapshot for these categories. The report is a review input: it does +governance snapshot for these categories. Dynamic-key entries include a +`proofState`; current valid contracts must be `source-proven`, while stale or +unproved source references fail the audit. The report is a review input: it does not automatically delete keys or force shared-term migrations. Dynamic key contracts are tracked in diff --git a/docs/development/i18n.md b/docs/development/i18n.md index c67697d5b..cc45aec4a 100644 --- a/docs/development/i18n.md +++ b/docs/development/i18n.md @@ -65,6 +65,8 @@ Generated runtime contracts expose shared terms as `shared.*`: - Installer: message keys under `shared.*`, for example `t('shared.features.deepReview')`. - Backend: `I18nService::translate*` accepts `shared.*` keys and follows the contract fallback chain. +- Relay static homepage: `$shared` references in `i18n.json` are resolved from + its generated page-scoped `i18n.shared.json`. Shared terms are lower-priority concept labels, not implicit global fallbacks. When product-surface or scene-specific wording exists, code must use that local @@ -133,7 +135,8 @@ delete the key when no release surface can reach it. When a key is kept for a dynamic contract, document it in `scripts/i18n-dynamic-key-allowlist.json` with the owning path, exact keys or prefixes, and `sourceReferences` that appear in the owning code. `i18n:audit` -fails stale keys and stale source references. +fails stale keys and stale source references, and the governance report marks +valid dynamic contracts as `source-proven`. Do not rely on literal fallback strings in component code. Add the missing key to the relevant locale file instead. Literal fallback strings make audits and @@ -201,6 +204,10 @@ Web UI eagerly loads only bootstrap namespaces and lazy-loads all other namespaces. Mobile Web, Installer, Backend, and static relay pages must not import Web UI locale catalogs to reuse copy. +Static relay pages may use shared concept labels only through a tiny generated +resource, such as `src/apps/relay-server/static/homepage/i18n.shared.json`. +Do not replace that with Web UI namespace loading. + For large feature copy, create a dedicated lazy Web UI namespace instead of putting it in a bootstrap namespace. Static self-contained pages should keep their own small resource files and retain English fallback HTML. diff --git a/scripts/generate-i18n-contract.mjs b/scripts/generate-i18n-contract.mjs index 756d81f2d..8f7ce7b9a 100644 --- a/scripts/generate-i18n-contract.mjs +++ b/scripts/generate-i18n-contract.mjs @@ -27,8 +27,14 @@ const outputs = [ path: path.join(root, 'BitFun-Installer', 'src-tauri', 'src', 'installer', 'generated_locale_contract.rs'), generate: generateInstallerRustLocaleContract, }, + { + path: path.join(root, 'src', 'apps', 'relay-server', 'static', 'homepage', 'i18n.shared.json'), + generate: generateRelayHomepageSharedTerms, + }, ]; +const RELAY_HOMEPAGE_SHARED_TERM_KEYS = ['features.remoteControl']; + function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } @@ -152,6 +158,22 @@ function flattenSharedTerms(value, prefix = '') { .sort(([left], [right]) => left.localeCompare(right)); } +function getNestedSharedTerm(terms, key) { + return key.split('.').reduce((current, part) => ( + current != null && typeof current === 'object' ? current[part] : undefined + ), terms); +} + +function setNestedSharedTerm(target, key, value) { + const parts = key.split('.'); + let current = target; + for (const part of parts.slice(0, -1)) { + current[part] ??= {}; + current = current[part]; + } + current[parts.at(-1)] = value; +} + function sharedTermsForLocales(sharedTermsByLocale, locales) { return Object.fromEntries(locales.map((locale) => [locale.id, sharedTermsByLocale[locale.id]])); } @@ -548,6 +570,24 @@ mod tests { `; } +function generateRelayHomepageSharedTerms(contract, sharedTermsByLocale) { + const localeMap = getLocaleMap(contract); + const locales = (contract.surfaceOrders['relay-static-homepage'] ?? contract.locales.map((locale) => locale.id)) + .map((localeId) => localeMap.get(localeId)); + const sharedTerms = {}; + + for (const locale of locales) { + sharedTerms[locale.id] = {}; + for (const key of RELAY_HOMEPAGE_SHARED_TERM_KEYS) { + const value = getNestedSharedTerm(sharedTermsByLocale[locale.id], key); + assert(typeof value === 'string', `relay static homepage shared term ${locale.id}:${key} must exist`); + setNestedSharedTerm(sharedTerms[locale.id], key, value); + } + } + + return `${JSON.stringify(sharedTerms, null, 2)}\n`; +} + function main() { const contract = readJson(contractPath); validateContract(contract); diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs index e88fde0db..e8c1bd734 100644 --- a/scripts/i18n-audit.mjs +++ b/scripts/i18n-audit.mjs @@ -268,6 +268,7 @@ function finalizeGovernanceReport() { }, dynamicKeyCandidates: { byAllowlistId: countEntriesBy(governanceReport.dynamicKeyCandidates, 'allowlistId'), + byProofState: countEntriesBy(governanceReport.dynamicKeyCandidates, 'proofState'), bySurface: countEntriesBy(governanceReport.dynamicKeyCandidates, 'surface'), }, sharedTermDuplicates: { @@ -329,6 +330,26 @@ function readJsonEntries(locale, namespace) { } } +const sharedTermEntryCache = new Map(); + +function readSharedTermMap(locale) { + if (sharedTermEntryCache.has(locale)) { + return sharedTermEntryCache.get(locale); + } + + const file = path.join(sharedTermsDir, locale, 'terms.json'); + try { + const entries = new Map(flattenStringEntries(readJsonFile(file))); + sharedTermEntryCache.set(locale, entries); + return entries; + } catch (error) { + reportError(`Failed to parse ${toPosixPath(path.relative(root, file))}: ${error.message}`); + const entries = new Map(); + sharedTermEntryCache.set(locale, entries); + return entries; + } +} + function readInstallerJsonKeys(uiLocale) { const file = path.join(installerLocalesDir, `${uiLocale}.json`); try { @@ -807,7 +828,7 @@ function readRelayHomepageMessages() { const entriesByLocale = new Map(); for (const [locale, messages] of Object.entries(resource)) { - entriesByLocale.set(locale, new Map(flattenStringEntries(messages))); + entriesByLocale.set(locale, new Map(flattenRelayHomepageEntries(messages, locale))); } return { @@ -816,6 +837,39 @@ function readRelayHomepageMessages() { }; } +function flattenRelayHomepageEntries(value, locale, prefix = '') { + if (isPlainObject(value) && Object.hasOwn(value, '$shared')) { + const keys = Object.keys(value); + if (keys.length !== 1) { + reportError(`relay static homepage ${locale} key "${prefix}" mixes $shared with local fields`); + } + const sharedKey = value.$shared; + if (!isNonEmptyString(sharedKey)) { + reportError(`relay static homepage ${locale} key "${prefix}" has an invalid $shared reference`); + return prefix ? [[prefix, '']] : []; + } + if (!readSharedTermMap(locale).has(sharedKey)) { + reportError(`relay static homepage ${locale} key "${prefix}" references missing shared term "${sharedKey}"`); + } + return prefix ? [[prefix, `shared:${sharedKey}`]] : []; + } + + if (typeof value === 'string') { + return prefix ? [[prefix, value]] : []; + } + if (Array.isArray(value)) { + const text = value.filter((item) => typeof item === 'string').join('\n'); + return prefix ? [[prefix, text]] : []; + } + if (value == null || typeof value !== 'object') { + return prefix ? [[prefix, '']] : []; + } + + return Object.entries(value) + .flatMap(([key, child]) => flattenRelayHomepageEntries(child, locale, prefix ? `${prefix}.${key}` : key)) + .sort(([left], [right]) => left.localeCompare(right)); +} + function collectRelayHomepageDataKeys() { const htmlPath = path.join(relayHomepageDir, 'index.html'); const html = fs.readFileSync(htmlPath, 'utf8'); @@ -1102,11 +1156,14 @@ function readDynamicKeyAllowlist() { } const ownerSource = sourceReferences.length > 0 ? readDynamicKeyOwnerSource(entry) : ''; + let proofState = sourceReferences.length > 0 ? 'source-proven' : 'source-unproven'; for (const sourceReference of sourceReferences.filter(isNonEmptyString)) { if (!ownerSource.includes(sourceReference)) { reportError(`Dynamic key allowlist "${entry.id}" source reference "${sourceReference}" was not found under ${entry.owner}`); + proofState = 'source-unproven'; } } + entry.proofState = proofState; } return allowlist; @@ -1220,6 +1277,7 @@ function collectDynamicKeyCandidates(resourceGroups) { resourceKey: group.resourceKey, owner: entry.owner, reason: entry.description, + proofState: entry.proofState ?? 'source-unproven', sourceReferences: entry.sourceReferences ?? [], locales: group.locales, files: group.files, diff --git a/scripts/i18n-contract.test.mjs b/scripts/i18n-contract.test.mjs index 8ef5e8f90..aa803dccd 100644 --- a/scripts/i18n-contract.test.mjs +++ b/scripts/i18n-contract.test.mjs @@ -14,6 +14,9 @@ const expectedGeneratedFiles = [ 'src/crates/core/src/service/i18n/generated_locale_contract.rs', 'BitFun-Installer/src-tauri/src/installer/generated_locale_contract.rs', ]; +const expectedGeneratedJsonFiles = [ + 'src/apps/relay-server/static/homepage/i18n.shared.json', +]; function readJson(relativePath) { return JSON.parse(fs.readFileSync(path.join(root, relativePath), 'utf8')); @@ -84,6 +87,10 @@ function extractStringArrayConstant(source, exportName) { return [...body.slice(0, end).matchAll(/'([^']+)'/g)].map((entry) => entry[1]); } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + test('i18n contract generated files are in sync with the canonical contract', () => { assert.ok(fs.existsSync(contractPath), 'missing shared i18n locale contract'); @@ -98,6 +105,11 @@ test('i18n contract generated files are in sync with the canonical contract', () assert.ok(fs.existsSync(absolutePath), `missing generated contract file: ${relativePath}`); assert.match(fs.readFileSync(absolutePath, 'utf8'), /generated by scripts\/generate-i18n-contract\.mjs/); } + for (const relativePath of expectedGeneratedJsonFiles) { + const absolutePath = path.join(root, relativePath); + assert.ok(fs.existsSync(absolutePath), `missing generated contract JSON file: ${relativePath}`); + JSON.parse(fs.readFileSync(absolutePath, 'utf8')); + } execFileSync(process.execPath, ['scripts/generate-i18n-contract.mjs', '--check'], { cwd: root, @@ -335,11 +347,14 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: ); assert.equal(report.summary.baseline.path, 'scripts/i18n-governance-baseline.json'); assert.equal(report.summary.baseline.enforced, true); - assert.equal( - report.summary.byCategory.sharedTermDuplicates.bySharedKey['product.name'], - report.sharedTermDuplicates.filter((entry) => entry.sharedKey === 'product.name').length, - 'report should summarize shared-term duplicates by shared term', - ); + const sharedDuplicateSample = report.sharedTermDuplicates[0]; + if (sharedDuplicateSample) { + assert.equal( + report.summary.byCategory.sharedTermDuplicates.bySharedKey[sharedDuplicateSample.sharedKey], + report.sharedTermDuplicates.filter((entry) => entry.sharedKey === sharedDuplicateSample.sharedKey).length, + 'report should summarize shared-term duplicates by shared term', + ); + } assert.equal( report.summary.byCategory.l10nQualityCandidates.bySurface['web-ui'], report.l10nQualityCandidates.filter((entry) => entry.surface === 'web-ui').length, @@ -350,6 +365,15 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: report.l10nQualityCandidates.filter((entry) => entry.namespace === 'flow-chat').length, 'report should summarize l10n candidates by namespace', ); + assert.equal( + report.summary.byCategory.dynamicKeyCandidates.byProofState['source-proven'], + report.dynamicKeyCandidates.length, + 'report should summarize source-proven dynamic key contracts separately from unproved candidates', + ); + assert.ok( + report.dynamicKeyCandidates.every((entry) => entry.proofState === 'source-proven'), + 'dynamic key contracts should include their proof state for review and metrics', + ); assert.ok( report.dynamicKeyCandidates.some((entry) => ( entry.allowlistId === 'installer-install-path-errors' && @@ -359,12 +383,8 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: 'installer backend error-code mapping should be reported as a dynamic key contract', ); assert.ok( - report.sharedTermDuplicates.some((entry) => ( - entry.sharedKey === 'product.name' && - entry.value === 'BitFun' && - entry.resourceKey !== 'shared:product.name' - )), - 'stable product name duplicates should be reported as shared-term candidates', + report.sharedTermDuplicates.every((entry) => entry.resourceKey !== entry.sharedResourceKey), + 'shared-term duplicate findings should only report surface copies, not the shared source entry itself', ); assert.ok( report.l10nQualityCandidates.some((entry) => ( @@ -545,6 +565,75 @@ test('installer uses the shared product name for titlebar defaults', { concurren } }); +test('core and relay static homepage reuse shared product and feature terms', { concurrency: false }, () => { + const reportPath = 'scripts/.tmp-i18n-core-relay-shared-terms-report.json'; + const absoluteReportPath = path.join(root, reportPath); + fs.rmSync(absoluteReportPath, { force: true }); + + try { + const result = runI18nAudit(['--report-json', reportPath]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + + const report = readJson(reportPath); + const blockedDuplicates = report.sharedTermDuplicates + .filter((entry) => ( + (entry.surface === 'core' && entry.sharedKey === 'product.name') || + (entry.surface === 'relay-static-homepage' && entry.sharedKey === 'features.remoteControl') + )) + .map((entry) => `${entry.surface}:${entry.sharedKey}:${entry.resourceKey}:${entry.locale}`) + .sort(); + assert.deepEqual( + blockedDuplicates, + [], + 'core product name and relay remote-control label should be resolved from shared terms instead of copied values', + ); + + for (const locale of ['en-US', 'zh-CN', 'zh-TW']) { + const fluentSource = readText(`src/crates/core/locales/${locale}.ftl`); + assert.doesNotMatch(fluentSource, /^app-name\s*=/m, `${locale}.ftl should not copy shared.product.name`); + } + + const coreServiceSource = readText('src/crates/core/src/service/i18n/service.rs'); + assert.match( + coreServiceSource, + /legacy_shared_term_key/, + 'core i18n service should keep a compatibility alias for legacy app-name callers', + ); + + const relayMessages = readJson('src/apps/relay-server/static/homepage/i18n.json'); + assert.deepEqual( + relayMessages['en-US'].flowMobileSub, + { $shared: 'features.remoteControl' }, + 'relay homepage should reference the shared remote-control term instead of copying it', + ); + const relayShared = readJson('src/apps/relay-server/static/homepage/i18n.shared.json'); + const sharedTerms = readJson('src/shared/i18n/resources/shared/zh-TW/terms.json'); + assert.equal(relayShared['zh-TW'].features.remoteControl, sharedTerms.features.remoteControl); + + const relayHtml = readText('src/apps/relay-server/static/homepage/index.html'); + assert.match(relayHtml, /i18n\.shared\.json/, 'relay homepage should load its small generated shared-term resource'); + assert.match(relayHtml, /\$shared/, 'relay homepage runtime should resolve shared-term references'); + } finally { + fs.rmSync(absoluteReportPath, { force: true }); + } +}); + +test('i18n audit fails stale relay static shared-term references', { concurrency: false }, () => { + const relayPath = 'src/apps/relay-server/static/homepage/i18n.json'; + const relayMessages = readJson(relayPath); + relayMessages['en-US'].flowMobileSub = { $shared: 'features.__missingForTest' }; + + withTemporaryTextFile(relayPath, `${JSON.stringify(relayMessages, null, 2)}\n`, () => { + const result = runI18nAudit(); + assert.notEqual(result.status, 0, 'stale relay $shared references must fail i18n:audit'); + assert.match( + `${result.stdout}\n${result.stderr}`, + /relay static homepage en-US key "flowMobileSub" references missing shared term "features\.__missingForTest"/, + 'audit output should identify the stale relay shared-term reference', + ); + }); +}); + test('i18n audit enforces governance candidate baselines', { concurrency: false }, () => { const baselinePath = 'scripts/i18n-governance-baseline.json'; const baseline = readJson(baselinePath); @@ -565,15 +654,18 @@ test('i18n audit enforces governance candidate baselines', { concurrency: false test('i18n audit enforces governance baseline dimensions', { concurrency: false }, () => { const baselinePath = 'scripts/i18n-governance-baseline.json'; const baseline = readJson(baselinePath); + const sharedKeyUnderTest = Object.entries(baseline.budgets.sharedTermDuplicates.bySharedKey) + .find(([, count]) => count > 0)?.[0]; + assert.ok(sharedKeyUnderTest, 'baseline should contain at least one sharedKey dimension with current debt'); - baseline.budgets.sharedTermDuplicates.bySharedKey['product.name'] = 0; + baseline.budgets.sharedTermDuplicates.bySharedKey[sharedKeyUnderTest] = 0; withTemporaryTextFile(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`, () => { const result = runI18nAudit(); assert.notEqual(result.status, 0, 'shared-term duplicate growth by sharedKey must fail ordinary i18n:audit'); assert.match( `${result.stdout}\n${result.stderr}`, - /sharedTermDuplicates sharedKey product\.name has \d+ candidate\(s\), baseline is 0/, + new RegExp(`sharedTermDuplicates sharedKey ${escapeRegExp(sharedKeyUnderTest)} has \\d+ candidate\\(s\\), baseline is 0`), 'audit output should identify the sharedKey dimension that failed', ); }); diff --git a/scripts/i18n-governance-baseline.json b/scripts/i18n-governance-baseline.json index f24ac301e..7d6040ef6 100644 --- a/scripts/i18n-governance-baseline.json +++ b/scripts/i18n-governance-baseline.json @@ -6,12 +6,12 @@ "maxTotal": 0 }, "sharedTermDuplicates": { - "maxTotal": 211, + "maxTotal": 206, "bySurface": { - "core": 18, + "core": 15, "installer": 0, "mobile-web": 8, - "relay-static-homepage": 2, + "relay-static-homepage": 0, "web-ui": 183 }, "bySharedKey": { @@ -23,13 +23,13 @@ "connectionMethods.lan": 2, "features.codeAgent": 1, "features.deepReview": 4, - "features.remoteControl": 2, + "features.remoteControl": 0, "features.settings": 1, "features.workspace": 2, "modes.assistant": 2, "modes.expert": 3, "modes.review": 1, - "product.name": 3, + "product.name": 0, "statuses.cancelled": 29, "statuses.done": 26, "statuses.failed": 44, diff --git a/src/apps/relay-server/static/homepage/i18n.json b/src/apps/relay-server/static/homepage/i18n.json index 431d462b8..26096d5cc 100644 --- a/src/apps/relay-server/static/homepage/i18n.json +++ b/src/apps/relay-server/static/homepage/i18n.json @@ -14,7 +14,9 @@ "flowRelay": "中继服务", "flowRelaySub": "配对 & 转发", "flowMobile": "移动端", - "flowMobileSub": "远程控制" + "flowMobileSub": { + "$shared": "features.remoteControl" + } }, "zh-TW": { "languageToggle": "語言:繁體", @@ -31,7 +33,9 @@ "flowRelay": "中繼服務", "flowRelaySub": "配對 & 轉發", "flowMobile": "移動端", - "flowMobileSub": "遠程控制" + "flowMobileSub": { + "$shared": "features.remoteControl" + } }, "en-US": { "languageToggle": "Language: EN", @@ -48,6 +52,8 @@ "flowRelay": "Relay Server", "flowRelaySub": "Pair and Forward", "flowMobile": "Mobile", - "flowMobileSub": "Remote Control" + "flowMobileSub": { + "$shared": "features.remoteControl" + } } } diff --git a/src/apps/relay-server/static/homepage/i18n.shared.json b/src/apps/relay-server/static/homepage/i18n.shared.json new file mode 100644 index 000000000..118abe8e3 --- /dev/null +++ b/src/apps/relay-server/static/homepage/i18n.shared.json @@ -0,0 +1,17 @@ +{ + "zh-CN": { + "features": { + "remoteControl": "远程控制" + } + }, + "en-US": { + "features": { + "remoteControl": "Remote Control" + } + }, + "zh-TW": { + "features": { + "remoteControl": "遠端控制" + } + } +} diff --git a/src/apps/relay-server/static/homepage/index.html b/src/apps/relay-server/static/homepage/index.html index a0ab330a3..93d7159e6 100644 --- a/src/apps/relay-server/static/homepage/index.html +++ b/src/apps/relay-server/static/homepage/index.html @@ -236,14 +236,17 @@

Self-Host

diff --git a/src/crates/core/locales/en-US.ftl b/src/crates/core/locales/en-US.ftl index 1edd0a0c4..b294c5f84 100644 --- a/src/crates/core/locales/en-US.ftl +++ b/src/crates/core/locales/en-US.ftl @@ -2,7 +2,6 @@ # English (US) (en-US) Fluent Translation File # ==================== General ==================== -app-name = BitFun app-version = Version { $version } loading = Loading... welcome = Welcome to BitFun diff --git a/src/crates/core/locales/zh-CN.ftl b/src/crates/core/locales/zh-CN.ftl index 567163da3..599567a0a 100644 --- a/src/crates/core/locales/zh-CN.ftl +++ b/src/crates/core/locales/zh-CN.ftl @@ -2,7 +2,6 @@ # Chinese Simplified (zh-CN) Fluent Translation File # ==================== 通用 ==================== -app-name = BitFun app-version = 版本 { $version } loading = 加载中... welcome = 欢迎使用 BitFun diff --git a/src/crates/core/locales/zh-TW.ftl b/src/crates/core/locales/zh-TW.ftl index 9ee362ea6..bfcc2286d 100644 --- a/src/crates/core/locales/zh-TW.ftl +++ b/src/crates/core/locales/zh-TW.ftl @@ -2,7 +2,6 @@ # Chinese Traditional (zh-TW) Fluent Translation File # ==================== 通用 ==================== -app-name = BitFun app-version = 版本 { $version } loading = 加載中... welcome = 歡迎使用 BitFun diff --git a/src/crates/core/src/service/i18n/service.rs b/src/crates/core/src/service/i18n/service.rs index efbb70516..689a33f9f 100644 --- a/src/crates/core/src/service/i18n/service.rs +++ b/src/crates/core/src/service/i18n/service.rs @@ -185,10 +185,19 @@ impl I18nService { } fn format_shared_term(locale: LocaleId, key: &str) -> Option { - let shared_key = key.strip_prefix("shared.")?; + let shared_key = Self::legacy_shared_term_key(key)?; generated_shared_term(locale, shared_key).map(str::to_string) } + fn legacy_shared_term_key(key: &str) -> Option<&str> { + match key { + // Keep backend callers of the legacy Fluent id working while the + // product name is owned by the shared i18n term catalog. + "app-name" => Some("product.name"), + _ => key.strip_prefix("shared."), + } + } + /// Formats a message. fn format_message( bundle: &ConcurrentBundle, @@ -266,6 +275,25 @@ mod tests { ); } + #[tokio::test] + async fn translate_keeps_legacy_app_name_alias_on_shared_product_name() { + let service = I18nService::new(); + service.initialize().await.unwrap(); + + assert_eq!( + service + .translate_with_locale(&LocaleId::EnUS, "app-name", None) + .await, + "BitFun" + ); + assert_eq!( + service + .translate_with_locale(&LocaleId::ZhTW, "app-name", None) + .await, + "BitFun" + ); + } + #[tokio::test] async fn translate_returns_key_when_shared_term_and_fluent_message_are_missing() { let service = I18nService::new();