tmp
This commit is contained in:
@@ -50,6 +50,7 @@ PR checklist
|
|||||||
- Keep JSON files UTF-8 encoded.
|
- Keep JSON files UTF-8 encoded.
|
||||||
- Follow the repo’s locale file structure and naming conventions.
|
- 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 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.
|
- Test translations in a local dev build before opening a PR.
|
||||||
- Reference related issues and explain any context for translations or changes.
|
- Reference related issues and explain any context for translations or changes.
|
||||||
|
|
||||||
@@ -61,36 +62,60 @@ Notes
|
|||||||
|
|
||||||
## Locale Key Structure Guidelines
|
## 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`).
|
The locale files now follow a two-namespace layout designed to mirror the React/Rust feature tree:
|
||||||
- **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.
|
- **`shared.*`** — cross-cutting vocabulary (buttons, statuses, validation hints, window chrome, etc.).
|
||||||
- **Components and dialogs** — When a feature needs component-specific copy, nest it under `components.<ComponentName>` or `dialogs.<DialogName>` instead of leaking implementation names like `proxyTunCard`.
|
- Buckets to prefer: `actions`, `labels`, `statuses`, `messages`, `placeholders`, `units`, `validation`, `window`, `editorModes`.
|
||||||
- **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}}`).
|
- 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`.
|
||||||
- **Example**
|
- **`entities.<feature>.*`** — 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.<ComponentName>` or `dialogs.<DialogName>` 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
|
```json
|
||||||
{
|
{
|
||||||
"profiles": {
|
"shared": {
|
||||||
"page": {
|
|
||||||
"title": "Profiles",
|
|
||||||
"description": "Manage subscription sources"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"import": "Import"
|
"save": "Save",
|
||||||
},
|
"cancel": "Cancel"
|
||||||
"notifications": {
|
}
|
||||||
"importSuccess": "Profile imported successfully"
|
},
|
||||||
},
|
"entities": {
|
||||||
"components": {
|
"profile": {
|
||||||
"batchDialog": {
|
"view": {
|
||||||
"title": "Batch Operations"
|
"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
|
## Feedback & Contributions
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@
|
|||||||
"lint": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache src",
|
"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",
|
"lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:i18n": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
|
||||||
"format:check": "prettier --check .",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
151
scripts/validate-i18n-structure.mjs
Normal file
151
scripts/validate-i18n-structure.mjs
Normal file
@@ -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(".") || "<root>"}: ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (extra.length > 0) {
|
||||||
|
errors.push(
|
||||||
|
`Unexpected keys at ${segments.join(".") || "<root>"}: ${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);
|
||||||
|
});
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
export const defaultNS = "translation" as const;
|
|
||||||
|
|
||||||
export const supportedLanguages = [
|
export const supportedLanguages = [
|
||||||
"en",
|
"en",
|
||||||
"ru",
|
"ru",
|
||||||
@@ -44,17 +42,15 @@ i18n.use(initReactI18next).init({
|
|||||||
resources: {},
|
resources: {},
|
||||||
lng: "zh",
|
lng: "zh",
|
||||||
fallbackLng: "zh",
|
fallbackLng: "zh",
|
||||||
defaultNS,
|
|
||||||
ns: [defaultNS],
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const changeLanguage = async (language: string) => {
|
export const changeLanguage = async (language: string) => {
|
||||||
if (!i18n.hasResourceBundle(language, defaultNS)) {
|
if (!i18n.hasResourceBundle(language, "translation")) {
|
||||||
const resources = await loadLanguage(language);
|
const resources = await loadLanguage(language);
|
||||||
i18n.addResourceBundle(language, defaultNS, resources);
|
i18n.addResourceBundle(language, "translation", resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
await i18n.changeLanguage(language);
|
await i18n.changeLanguage(language);
|
||||||
|
|||||||
Reference in New Issue
Block a user