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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,11 @@ For the full script list, see [`package.json`](package.json).
- Web UI loads only bootstrap namespaces eagerly; use `useI18n(namespace)` for
route or feature copy and keep direct `i18nService.t(...)` calls in bootstrap
namespaces.
- Use shared i18n formatting helpers for user-visible dates, times, and
numbers instead of direct `Intl.*` or `toLocale*` calls.
- `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.
existence, dynamic key governance, no-growth i18n governance baselines,
locale-format no-growth baselines, and the no-hardcoded-CJK source budget.

### Logging

Expand Down
8 changes: 4 additions & 4 deletions BitFun-Installer/src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"modelName": "模型名稱(如 deepseek-v4-flash)",
"skip": "稍後配置",
"nextTheme": "下一步:主題",
"description": "配置和管理 AI 模型提供商",
"description": "設定和管理 AI 模型提供商",
"providerLabel": "選擇模型提供商",
"selectProvider": "或選擇預設提供商",
"customProvider": "自定義配置",
Expand All @@ -71,7 +71,7 @@
"description": "MiniMax 系列模型",
"urlOptions": {
"default": "Anthropic格式-默認",
"openai": "OpenAI兼容格式"
"openai": "OpenAI 相容格式"
}
},
"moonshot": {
Expand Down Expand Up @@ -127,13 +127,13 @@
"testSuccess": "測試成功",
"testFailed": "測試失敗",
"fillApiKeyBeforeFetch": "請先填寫 API Key 再獲取模型列表",
"fetchingModels": "正在拉取模型列表...",
"fetchingModels": "正在擷取模型清單...",
"fetchFailedFallback": "拉取模型列表失敗,已回退到常用預設模型",
"fetchEmptyFallback": "供應商未返回可用模型,已回退到常用預設模型",
"usingPresetModels": "當前顯示的是常用預設模型",
"addCustomModel": "添加自定義模型",
"form": {
"baseUrl": "API地址",
"baseUrl": "API 位址",
"apiKey": "API密鑰",
"apiKeyPlaceholder": "輸入您的 API Key",
"provider": "請求格式",
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ Captured data is logged as structured JSON under the `bitfun::devtools` target.
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.
- User-visible dates, times, and numbers should use shared i18n formatting
helpers instead of direct `Intl.*` or `toLocale*` calls.
- `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.
existence, dynamic key governance, no-growth i18n governance baselines,
locale-format no-growth baselines, and the no-hardcoded-CJK source budget.

### Platform-agnostic core

Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ The audit layer should distinguish these cases explicitly:
- Stable concept duplicates: surface values that duplicate an existing
`shared.*` term and should be migrated only when the local wording has the same
product meaning.
- Direct locale-format candidates: user-visible date, time, number, or currency
formatting that bypasses the surface i18n formatting helper.

Deletion is safe only for confirmed unused keys. Dynamic-key candidates need an
explicit allowlist or a code-level mapping comment before cleanup. Localization
Expand Down Expand Up @@ -220,6 +222,12 @@ Do not add checked-in execution plans for these governance improvements. Keep
durable architecture and development rules in this document and
`docs/development/i18n.md`; keep one-off rollout plans outside version control.

`scripts/i18n-literal-fallback-baseline.json` and
`scripts/i18n-locale-format-baseline.json` are temporary no-growth baselines for
existing call-site debt. They should move downward as literal fallback copy and
direct locale formatting are moved behind owned locale resources and i18n
formatting helpers.

## Backend And Frontend Language Contract

All surfaces must exchange canonical app locale ids:
Expand Down
18 changes: 14 additions & 4 deletions docs/development/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- Do not import `src/web-ui/src/locales` from `src/mobile-web`,
`BitFun-Installer`, Rust crates, or server apps.
- Persist and send canonical app locale ids, not aliases.
- Use surface i18n formatting helpers for user-visible dates, times, numbers,
and currency instead of direct `Intl.*` or `toLocale*` calls.
- In Web UI, request route or feature namespaces through `useI18n(namespace)`.
Add a namespace to `WEB_UI_BOOTSTRAP_NAMESPACES` only when a synchronous
`i18nService.t('namespace:key')` call must run during module initialization.
Expand Down Expand Up @@ -148,6 +150,12 @@ resources.
Dynamic fallbacks are acceptable only when the fallback is real runtime data,
such as a server-provided role name or description.

Direct user-visible locale formatting calls are tracked by
`scripts/i18n-locale-format-baseline.json`. New code should format through the
surface i18n API, such as Web UI `useI18n().formatNumber` or
`i18nService.formatDate`, so language changes and fallback rules stay aligned.
Lower the baseline whenever a call site moves behind the shared helper.

Repeated values are not automatically wrong. Generic atoms such as
`common:actions.cancel` should be reused when the meaning is identical, but
feature-specific labels, button text, and status words may stay local when their
Expand All @@ -167,9 +175,10 @@ static Web UI translation keys from `i18nService.t('namespace:key')`,
`i18nService.getT()('namespace:key')`, and namespace-aware
`useI18n(namespace)` / `useTranslation(namespace)` calls, direct `t(key,
"literal fallback")` arguments, object-form literal `defaultValue` budget
growth, and CJK source candidates outside approved resource owners. Keep
user-facing copy in locale/resource files rather than raising hardcoded-copy or
literal-fallback baselines.
growth, direct locale-format budget growth, and CJK source candidates outside
approved resource owners. Keep user-facing copy and formatting decisions in
locale/resource or i18n helper files rather than raising hardcoded-copy,
literal-fallback, or locale-format baselines.

Use `pnpm run i18n:audit -- --report-json <path>` when reviewing cleanup or
shared-term work. The JSON report separates confirmed unused keys, dynamic-key
Expand Down Expand Up @@ -260,4 +269,5 @@ Every i18n PR should state:
- Whether generated files were updated.
- Which i18n verification commands passed.
- Whether any surface intentionally does not support a new locale.
- Whether a hardcoded-copy budget changed, and why that increase is acceptable.
- Whether any hardcoded-copy, literal-fallback, or locale-format budget changed,
and why any increase is acceptable.
148 changes: 148 additions & 0 deletions scripts/i18n-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const root = process.cwd();
const contractPath = path.join(root, 'src', 'shared', 'i18n', 'contract', 'locales.json');
const hardcodedBaselinePath = path.join(root, 'scripts', 'i18n-hardcoded-baseline.json');
const literalFallbackBaselinePath = path.join(root, 'scripts', 'i18n-literal-fallback-baseline.json');
const localeFormatBaselinePath = path.join(root, 'scripts', 'i18n-locale-format-baseline.json');
const dynamicKeyAllowlistPath = path.join(root, 'scripts', 'i18n-dynamic-key-allowlist.json');
const l10nIdenticalAllowlistPath = path.join(root, 'scripts', 'i18n-l10n-identical-allowlist.json');
const governanceBaselinePath = path.join(root, 'scripts', 'i18n-governance-baseline.json');
Expand Down Expand Up @@ -48,6 +49,8 @@ const reportCategories = [
'dynamicKeyCandidates',
'sharedTermDuplicates',
'l10nQualityCandidates',
'literalDefaultValueFallbacks',
'localeFormatCandidates',
];
const governanceReport = {
version: 1,
Expand All @@ -64,6 +67,8 @@ const governanceReport = {
dynamicKeyCandidates: [],
sharedTermDuplicates: [],
l10nQualityCandidates: [],
literalDefaultValueFallbacks: [],
localeFormatCandidates: [],
};

function reportError(message) {
Expand Down Expand Up @@ -281,6 +286,14 @@ function finalizeGovernanceReport() {
byNamespace: countEntriesBy(governanceReport.l10nQualityCandidates, 'namespace', { emptyLabel: '<none>' }),
bySurface: countEntriesBy(governanceReport.l10nQualityCandidates, 'surface'),
},
literalDefaultValueFallbacks: {
byFile: countEntriesBy(governanceReport.literalDefaultValueFallbacks, 'file'),
byNamespace: countEntriesBy(governanceReport.literalDefaultValueFallbacks, 'namespace', { emptyLabel: '<none>' }),
},
localeFormatCandidates: {
byFile: countEntriesBy(governanceReport.localeFormatCandidates, 'file'),
bySurface: countEntriesBy(governanceReport.localeFormatCandidates, 'surface'),
},
};
}

Expand Down Expand Up @@ -1820,9 +1833,11 @@ function collectWebUiLiteralFallbackFindings() {
if (propertyNameToString(ts, property.name) !== 'defaultValue') continue;
if (!isLiteralFallbackInitializer(ts, property.initializer)) continue;

const [namespace, ...keyParts] = call.key.split(':');
findings.push({
file: call.file,
location: call.location,
namespace: keyParts.length > 0 ? namespace : null,
key: call.key,
});
}
Expand All @@ -1844,6 +1859,14 @@ function auditWebUiLiteralFallbackBudget() {
const findingsByFile = new Map();

for (const finding of collectWebUiLiteralFallbackFindings()) {
governanceReport.literalDefaultValueFallbacks.push({
surface: 'web-ui',
namespace: finding.namespace,
key: finding.key,
file: finding.file,
location: finding.location,
reason: 'literal-i18next-defaultValue',
});
findingsByFile.set(finding.file, [...(findingsByFile.get(finding.file) ?? []), finding]);
}

Expand Down Expand Up @@ -1933,6 +1956,130 @@ function countCjkSourceLines(scanRoot, predicate) {
return findings;
}

function shouldSkipLocaleFormatSourceScan(file) {
const normalized = toPosixPath(path.relative(root, file));
return (
normalized === 'src/web-ui/src/infrastructure/i18n/core/I18nService.ts' ||
normalized.endsWith('/generatedLocaleContract.ts') ||
normalized.endsWith('.test.ts') ||
normalized.endsWith('.test.tsx') ||
normalized.endsWith('.spec.ts') ||
normalized.endsWith('.spec.tsx')
);
}

function collectLocaleFormatUsageFindings() {
const formatPattern = /\b(?:new\s+)?Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\s*\(|\.\s*toLocale(?:String|DateString|TimeString)\s*\(/g;
const specs = [
{
surface: 'web-ui',
root: webSourceDir,
predicate: (file) => (
(file.endsWith('.ts') || file.endsWith('.tsx')) &&
!shouldSkipSourceScan(file) &&
!shouldSkipLocaleFormatSourceScan(file)
),
},
{
surface: 'mobile-web',
root: mobileWebSourceDir,
predicate: (file) => (
(file.endsWith('.ts') || file.endsWith('.tsx')) &&
!shouldSkipMobileWebSourceScan(file) &&
!shouldSkipLocaleFormatSourceScan(file)
),
},
{
surface: 'installer',
root: installerSourceDir,
predicate: (file) => (
(file.endsWith('.ts') || file.endsWith('.tsx')) &&
!shouldSkipInstallerSourceScan(file) &&
!shouldSkipLocaleFormatSourceScan(file)
),
},
{
surface: 'core-miniapp',
root: path.join(root, 'src', 'crates', 'core', 'src', 'miniapp', 'builtin', 'assets'),
predicate: (file) => file.endsWith('.js'),
},
];
const findings = [];

for (const spec of specs) {
for (const file of listFiles(spec.root, spec.predicate)) {
const relativeFile = toPosixPath(path.relative(root, file));
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => {
formatPattern.lastIndex = 0;
let match;
while ((match = formatPattern.exec(line)) != null) {
findings.push({
surface: spec.surface,
file: relativeFile,
location: `${relativeFile}:${index + 1}`,
expression: match[0].trim(),
snippet: line.trim().slice(0, 200),
reason: 'direct-locale-format-call',
});
}
});
}
}

return findings.sort(sortByReportIdentity);
}

function auditLocaleFormatUsageBudget() {
if (!fs.existsSync(localeFormatBaselinePath)) {
reportError('Missing scripts/i18n-locale-format-baseline.json');
return;
}

const baseline = readJsonFile(localeFormatBaselinePath);
if (baseline.version !== 1) {
reportError('scripts/i18n-locale-format-baseline.json must use version 1');
}
if (!Array.isArray(baseline.budgets)) {
reportError('scripts/i18n-locale-format-baseline.json must define a budgets array');
return;
}

const budgetByFile = new Map((baseline.budgets ?? []).map((budget) => [budget.path, budget]));
const findingsByFile = new Map();

for (const finding of collectLocaleFormatUsageFindings()) {
governanceReport.localeFormatCandidates.push(finding);
findingsByFile.set(finding.file, [...(findingsByFile.get(finding.file) ?? []), finding]);
}

for (const [file, findings] of findingsByFile.entries()) {
const budget = budgetByFile.get(file);
if (!budget) {
reportError(
`${file} has ${findings.length} direct locale formatting call(s) but is missing from scripts/i18n-locale-format-baseline.json. First entries: ${
findings.slice(0, 8).map((finding) => `${finding.location} ${finding.expression}`).join(', ')
}`,
);
continue;
}

if (typeof budget.maxLocaleFormatCalls !== 'number') {
reportError(`${file} has an invalid locale format baseline entry`);
} else if (findings.length > budget.maxLocaleFormatCalls) {
reportError(`${file} has ${findings.length} direct locale formatting call(s), budget is ${budget.maxLocaleFormatCalls}`);
} else if (findings.length < budget.maxLocaleFormatCalls) {
reportError(`${file} has ${findings.length} direct locale formatting call(s), below baseline ${budget.maxLocaleFormatCalls}; lower scripts/i18n-locale-format-baseline.json.`);
}
}

for (const [file, budget] of budgetByFile.entries()) {
if (budget.maxLocaleFormatCalls > 0 && !findingsByFile.has(file)) {
reportError(`${file} no longer has direct locale formatting call(s); remove it from scripts/i18n-locale-format-baseline.json.`);
}
}
}

function auditHardcodedSourceBudgets() {
const baseline = readJsonFile(hardcodedBaselinePath);
const budgetById = new Map((baseline.budgets ?? []).map((budget) => [budget.id, budget.maxCjkLines]));
Expand Down Expand Up @@ -1997,6 +2144,7 @@ auditInstallerPlaceholderParity();
auditCoreFluentParity();
auditRelayStaticHomepageResources();
auditSourceText();
auditLocaleFormatUsageBudget();
auditHardcodedSourceBudgets();
auditI18nGovernanceReport(namespaces);
writeGovernanceReport();
Expand Down
Loading
Loading