-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Add advanced i18n validator script #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,126 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| #!/usr/bin/env node | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Advanced i18n validator | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - Validates key parity | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - Detects invalid JSON | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - Pretty CI output | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - Optional --fix mode | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import fs from "node:fs"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import path from "node:path"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const LOCALES_DIR = path.resolve("src/i18n/locales"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const BASE_LOCALE = "en"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const FIX_MODE = process.argv.includes("--fix"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const color = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| red: (t) => `\x1b[31m${t}\x1b[0m`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| yellow: (t) => `\x1b[33m${t}\x1b[0m`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| green: (t) => `\x1b[32m${t}\x1b[0m`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| cyan: (t) => `\x1b[36m${t}\x1b[0m`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| bold: (t) => `\x1b[1m${t}\x1b[0m`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function readJSON(file) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return JSON.parse(fs.readFileSync(file, "utf-8")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(color.red(`INVALID JSON → ${file}`)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw err; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function getKeys(obj, prefix = "") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const keys = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const [key, value] of Object.entries(obj)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fullKey = prefix ? `${prefix}.${key}` : key; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (value && typeof value === "object" && !Array.isArray(value)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| keys.push(...getKeys(value, fullKey)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| else keys.push(fullKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return keys.sort(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function setDeep(obj, keyPath, value = "") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const parts = keyPath.split("."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let cur = obj; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| while (parts.length > 1) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const p = parts.shift(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| cur[p] ??= {}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| cur = cur[p]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| cur[parts[0]] ??= value; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+45
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard If a target locale has a string where the base has an object, Safer shape-conflict handling-function setDeep(obj, keyPath, value = "") {
+function setDeep(obj, keyPath, value = "") {
const parts = keyPath.split(".");
let cur = obj;
while (parts.length > 1) {
const p = parts.shift();
- cur[p] ??= {};
+ if (cur[p] == null) {
+ cur[p] = {};
+ } else if (typeof cur[p] !== "object" || Array.isArray(cur[p])) {
+ return false;
+ }
cur = cur[p];
}
cur[parts[0]] ??= value;
+ return true;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| let errors = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let fixed = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const baseDir = path.join(LOCALES_DIR, BASE_LOCALE); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const namespaces = fs.readdirSync(baseDir).filter(f => f.endsWith(".json")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const locales = fs.readdirSync(LOCALES_DIR, { withFileTypes: true }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(d => d.isDirectory()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(d => d.name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(l => l !== BASE_LOCALE); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(color.cyan(`Checking ${locales.length} locales across ${namespaces.length} namespaces\n`)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const file of namespaces) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const basePath = path.join(baseDir, file); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const baseData = readJSON(basePath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const baseKeys = getKeys(baseData); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const locale of locales) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const localePath = path.join(LOCALES_DIR, locale, file); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!fs.existsSync(localePath)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(color.red(`MISSING FILE → ${locale}/${file}`)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const localeData = readJSON(localePath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const localeKeys = getKeys(localeData); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const missing = baseKeys.filter(k => !localeKeys.includes(k)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const extra = localeKeys.filter(k => !baseKeys.includes(k)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (missing.length || extra.length) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(color.bold(`\n${locale}/${file}`)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (missing.length) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(color.red(" Missing keys:")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| missing.forEach(k => console.log(" -", k)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors += missing.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (FIX_MODE) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| missing.forEach(k => setDeep(localeData, k)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fs.writeFileSync(localePath, JSON.stringify(localeData, null, 2)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fixed += missing.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (extra.length) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(color.yellow(" Extra keys:")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| extra.forEach(k => console.log(" +", k)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors += extra.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("\n----------------------------------"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (errors === 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(color.green("✓ i18n PASSED")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.exit(0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (FIX_MODE) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(color.green(`✓ Fixed ${fixed} missing keys`)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.exit(0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+120
to
+122
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t let Right now any Track remaining errors separately-if (FIX_MODE) {
+if (FIX_MODE && errors === fixed) {
console.log(color.green(`✓ Fixed ${fixed} missing keys`));
process.exit(0);
}This minimal version assumes every fixed item was a previously counted missing key. If you add conflict handling in 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+120
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(color.red(`✗ i18n FAILED — ${errors} issues found`)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.exit(1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wire this into the project entrypoint, otherwise it’s kinda invisible.
package.jsonstill runsnode scripts/i18n-check.mjs, sonpm run i18n:checkwon’t execute this new validator. Either replace the existing script or update the npm command; lowkey also consider renaming the file to avoid the space.Possible package script update
or cleaner:
+ "i18n:check": "node scripts/optimized-i18n-check.mjs",🤖 Prompt for AI Agents