From b48426236b05a896f14568fb23c90a5c2dadea63 Mon Sep 17 00:00:00 2001 From: Slinetrac Date: Wed, 5 Nov 2025 14:09:49 +0800 Subject: [PATCH] tmp --- docs/CONTRIBUTING_i18n.md | 65 ++++++++---- package.json | 3 +- scripts/validate-i18n-structure.mjs | 151 ++++++++++++++++++++++++++++ src/services/i18n.ts | 8 +- 4 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 scripts/validate-i18n-structure.mjs diff --git a/docs/CONTRIBUTING_i18n.md b/docs/CONTRIBUTING_i18n.md index 6695d689..e76a313a 100644 --- a/docs/CONTRIBUTING_i18n.md +++ b/docs/CONTRIBUTING_i18n.md @@ -50,6 +50,7 @@ PR checklist - Keep JSON files UTF-8 encoded. - Follow the repo’s locale file structure and naming conventions. - Run `pnpm format:i18n` to align with the baseline file for minimal diffs. +- Run `pnpm i18n:validate` to ensure locale structure & namespace rules still hold. - Test translations in a local dev build before opening a PR. - Reference related issues and explain any context for translations or changes. @@ -61,36 +62,60 @@ Notes ## Locale Key Structure Guidelines -- **Top-level scope** — Map each locale namespace to a route-level feature or domain module, mirroring folder names in `src/pages`/`src/services`. Prefer plural nouns for resource pages (`profiles`, `connections`) and reuse existing slugs where possible (`home`, `settings`). -- **Common strings** — Put reusable actions, statuses, and units in `common.*`. Before adding a new key elsewhere, check whether an equivalent entry already lives under `common`. -- **Feature layout** — Inside a namespace, group strings by their UI role using consistent buckets such as `page`, `actions`, `labels`, `tooltips`, `notifications`, `errors`, and `placeholders`. Avoid duplicating the same bucket at multiple levels. -- **Components and dialogs** — When a feature needs component-specific copy, nest it under `components.` or `dialogs.` instead of leaking implementation names like `proxyTunCard`. -- **Naming style** — Use lower camelCase for keys, align with the feature’s UI wording, and keep names semantic (`systemProxy` rather than `switch1`). Reserve template placeholders for dynamic values (e.g., `{{name}}`). -- **Example** +The locale files now follow a two-namespace layout designed to mirror the React/Rust feature tree: + +- **`shared.*`** — cross-cutting vocabulary (buttons, statuses, validation hints, window chrome, etc.). + - Buckets to prefer: `actions`, `labels`, `statuses`, `messages`, `placeholders`, `units`, `validation`, `window`, `editorModes`. + - Add to `shared` only when the copy is used (or is expected to be reused) by two or more features. Otherwise keep it in the owning feature under `entities`. +- **`entities..*`** — route-level or domain-level strings scoped to a single feature. + - Top-level nodes generally match folders under `src/pages`, `src/components`, or service domains (`settings`, `proxy`, `profile`, `home`, `validation`, `unlock`, …). + - Within a feature namespace, prefer consistent buckets like `view`, `page`, `sections`, `forms`, `fields`, `actions`, `tooltips`, `notifications`, `errors`, `dialogs`, `tables`, `components`. Choose the minimum depth needed to describe the UI. + +### Authoring guidelines + +1. **Follow the shared/feature split** — before inventing a new key, check whether an equivalent exists under `shared.*`. +2. **Use camelCase leaf keys** — keep names semantic (`systemProxy`, `updateInterval`) and avoid positional names (`item1`, `btn_ok`). +3. **Group by UI responsibility** — for example: + - `entities.settings.dns.fields.listen` + - `entities.settings.dns.dialog.title` + - `entities.settings.dns.sections.general` +4. **Component-specific copy** — nest under `components.` or `dialogs.` to keep implementation-specific strings organized but still discoverable. +5. **Dynamic placeholders** — continue using `{{placeholder}}` syntax and document required params in code when possible. + +### Minimal example ```json { - "profiles": { - "page": { - "title": "Profiles", - "description": "Manage subscription sources" - }, + "shared": { "actions": { - "import": "Import" - }, - "notifications": { - "importSuccess": "Profile imported successfully" - }, - "components": { - "batchDialog": { - "title": "Batch Operations" + "save": "Save", + "cancel": "Cancel" + } + }, + "entities": { + "profile": { + "view": { + "title": "Profiles", + "actions": { + "import": "Import", + "updateAll": "Update All Profiles" + }, + "notifications": { + "importSuccess": "Profile imported successfully" + } + }, + "components": { + "batchDialog": { + "title": "Batch Operations", + "items": "items" + } } } } } ``` -Reuse shared verbs (e.g., “New”, “Save”) directly from `common.actions.*` in the application code rather than duplicating them inside feature namespaces. +Whenever you need a common verb or label, reference `shared.*` directly in the code (`shared.actions.save`, `shared.labels.name`, …) instead of duplicating the copy in a feature namespace. ## Feedback & Contributions diff --git a/package.json b/package.json index b4af7c74..55666d55 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "lint": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache src", "lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src", "format": "prettier --write .", - "format:i18n": "node scripts/cleanup-unused-i18n.mjs --align --apply", "format:check": "prettier --check .", + "format:i18n": "node scripts/cleanup-unused-i18n.mjs --align --apply && pnpm i18n:validate", + "i18n:validate": "node scripts/validate-i18n-structure.mjs", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/scripts/validate-i18n-structure.mjs b/scripts/validate-i18n-structure.mjs new file mode 100644 index 00000000..153972e2 --- /dev/null +++ b/scripts/validate-i18n-structure.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { glob } from "glob"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const localesDir = path.join(repoRoot, "src", "locales"); +const baselineFile = path.join(localesDir, "en.json"); + +const bannedNamespaces = [ + "common", + "profiles", + "settings", + "proxies", + "rules", + "home", + "connections", + "logs", + "theme", + "unlock", + "validation", +]; + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function compareStructure(base, target, segments = []) { + const errors = []; + const baseKeys = Object.keys(base ?? {}).sort(); + const targetKeys = Object.keys(target ?? {}).sort(); + + const missing = baseKeys.filter((key) => !targetKeys.includes(key)); + const extra = targetKeys.filter((key) => !baseKeys.includes(key)); + + if (missing.length > 0) { + errors.push( + `Missing keys at ${segments.join(".") || ""}: ${missing.join(", ")}`, + ); + } + if (extra.length > 0) { + errors.push( + `Unexpected keys at ${segments.join(".") || ""}: ${extra.join(", ")}`, + ); + } + + for (const key of baseKeys) { + if (!targetKeys.includes(key)) continue; + const nextSegments = [...segments, key]; + const baseValue = base[key]; + const targetValue = target[key]; + + if (isRecord(baseValue) !== isRecord(targetValue)) { + errors.push( + `Type mismatch at ${nextSegments.join(".")}: expected ${ + isRecord(baseValue) ? "object" : "string" + }`, + ); + continue; + } + + if (isRecord(baseValue) && isRecord(targetValue)) { + errors.push(...compareStructure(baseValue, targetValue, nextSegments)); + } + } + + return errors; +} + +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +function validateLocaleParity() { + const baseline = readJson(baselineFile); + const localeFiles = readdirSync(localesDir) + .filter((file) => file.endsWith(".json")) + .map((file) => path.join(localesDir, file)); + + const issues = []; + + for (const localeFile of localeFiles) { + if (localeFile === baselineFile) continue; + const localeData = readJson(localeFile); + const diff = compareStructure(baseline, localeData); + if (diff.length) { + issues.push( + `Locale ${path.basename(localeFile)}:\n ${diff.join("\n ")}`, + ); + } + } + + return issues; +} + +async function validateCodePrefixes() { + const files = await glob("src/**/*.{ts,tsx,js,jsx,mts,cts}", { + cwd: repoRoot, + nodir: true, + ignore: ["src/locales/**"], + }); + + const violations = []; + + for (const relativePath of files) { + const absolutePath = path.join(repoRoot, relativePath); + const content = readFileSync(absolutePath, "utf8"); + + for (const ns of bannedNamespaces) { + const pattern = new RegExp(`["'\`]${ns}\\.`, "g"); + if (pattern.test(content)) { + violations.push( + `${relativePath}: forbidden namespace "${ns}." detected`, + ); + } + } + } + + return violations; +} + +async function main() { + const issues = validateLocaleParity(); + const prefixViolations = await validateCodePrefixes(); + const allProblems = [...issues, ...prefixViolations]; + + if (allProblems.length > 0) { + console.error("i18n structure validation failed:\n"); + for (const problem of allProblems) { + console.error(`- ${problem}`); + } + console.error( + "\nRun `pnpm format:i18n` or update locale files to match the baseline.", + ); + process.exit(1); + } + + console.log("i18n structure validation passed."); +} + +main().catch((error) => { + console.error("Unexpected error while validating i18n structure:"); + console.error(error); + process.exit(1); +}); diff --git a/src/services/i18n.ts b/src/services/i18n.ts index 533ba295..7b6c910e 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -1,8 +1,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -export const defaultNS = "translation" as const; - export const supportedLanguages = [ "en", "ru", @@ -44,17 +42,15 @@ i18n.use(initReactI18next).init({ resources: {}, lng: "zh", fallbackLng: "zh", - defaultNS, - ns: [defaultNS], interpolation: { escapeValue: false, }, }); export const changeLanguage = async (language: string) => { - if (!i18n.hasResourceBundle(language, defaultNS)) { + if (!i18n.hasResourceBundle(language, "translation")) { const resources = await loadLanguage(language); - i18n.addResourceBundle(language, defaultNS, resources); + i18n.addResourceBundle(language, "translation", resources); } await i18n.changeLanguage(language);