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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions BitFun-Installer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,16 @@ 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 (
<div className="installer-app">
<div className="titlebar" data-tauri-drag-region>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="titlebar-title">
{isFullscreen ? t('titlebar.default') : (
{isFullscreen ? defaultTitle : (
<>
<span style={{ opacity: 0.4 }}>{stepNum} / 4</span>
<span style={{ margin: '0 6px', opacity: 0.2 }}>·</span>
Expand Down
3 changes: 0 additions & 3 deletions BitFun-Installer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,5 @@
"completed": "Uninstall completed. You can close this window.",
"cancel": "Cancel",
"close": "Close"
},
"titlebar": {
"default": "BitFun"
}
}
3 changes: 0 additions & 3 deletions BitFun-Installer/src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,5 @@
"completed": "卸載已完成,可關閉窗口。",
"cancel": "取消",
"close": "關閉"
},
"titlebar": {
"default": "BitFun"
}
}
3 changes: 0 additions & 3 deletions BitFun-Installer/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,5 @@
"completed": "卸载已完成,可关闭窗口。",
"cancel": "取消",
"close": "关闭"
},
"titlebar": {
"default": "BitFun"
}
}
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 12 additions & 5 deletions docs/architecture/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)`
Expand Down Expand Up @@ -186,14 +190,17 @@ quality candidates are translation work, not structural cleanup, and should not
be treated as unused keys.

`pnpm run i18n:audit -- --report-json <path>` 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
`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
Expand Down
12 changes: 12 additions & 0 deletions docs/development/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,6 +132,12 @@ 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, 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
translation completeness harder to enforce. Existing Web UI
Expand Down Expand Up @@ -196,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.
Expand Down
40 changes: 40 additions & 0 deletions scripts/generate-i18n-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Expand Down Expand Up @@ -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]]));
}
Expand Down Expand Up @@ -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);
Expand Down
103 changes: 102 additions & 1 deletion scripts/i18n-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ function finalizeGovernanceReport() {
},
dynamicKeyCandidates: {
byAllowlistId: countEntriesBy(governanceReport.dynamicKeyCandidates, 'allowlistId'),
byProofState: countEntriesBy(governanceReport.dynamicKeyCandidates, 'proofState'),
bySurface: countEntriesBy(governanceReport.dynamicKeyCandidates, 'surface'),
},
sharedTermDuplicates: {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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');
Expand Down Expand Up @@ -1011,6 +1065,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');
Expand Down Expand Up @@ -1052,9 +1132,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`);
Expand All @@ -1065,6 +1149,21 @@ 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) : '';
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;
Expand Down Expand Up @@ -1178,6 +1277,8 @@ 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,
});
Expand Down
Loading
Loading