Skip to content
Open
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
126 changes: 126 additions & 0 deletions scripts/optimized i18n-check.mjs
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");
Comment on lines +1 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wire this into the project entrypoint, otherwise it’s kinda invisible.

package.json still runs node scripts/i18n-check.mjs, so npm run i18n:check won’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
- "i18n:check": "node scripts/i18n-check.mjs",
+ "i18n:check": "node \"scripts/optimized i18n-check.mjs\"",

or cleaner:

+ "i18n:check": "node scripts/optimized-i18n-check.mjs",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/optimized` i18n-check.mjs around lines 1 - 15, The project
package.json still references the old script so the new "scripts/optimized
i18n-check.mjs" isn't run; either update the "i18n:check" npm script in
package.json to point to the new filename (preferably after renaming
"scripts/optimized i18n-check.mjs" to remove the space, e.g.
"scripts/optimized-i18n-check.mjs") or replace the original
"scripts/i18n-check.mjs" with this new implementation; update any npm scripts
that call "node scripts/i18n-check.mjs" to instead call "node
scripts/optimized-i18n-check.mjs" (or your chosen no-space name) so npm run
i18n:check executes the new validator.


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard --fix against shape conflicts before descending.

If a target locale has a string where the base has an object, setDeep() descends into that primitive and can blow up during --fix. Safer: detect non-object/array path conflicts and leave them as errors instead of trying to mutate through them.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
function setDeep(obj, keyPath, value = "") {
const parts = keyPath.split(".");
let cur = obj;
while (parts.length > 1) {
const p = parts.shift();
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;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/optimized` i18n-check.mjs around lines 45 - 54, The setDeep function
currently blindly descends and creates objects, which will crash or corrupt when
a target path contains a primitive/array where an object is expected; update
setDeep to check each intermediate property (inside the while loop) and if
cur[p] exists but is not a plain object (i.e., typeof !== "object" || cur[p] ===
null || Array.isArray(cur[p])) stop/return (or surface an error) instead of
trying to assign into it; only create a new {} when the property is undefined,
and only assign the final value when there is no shape conflict. Use the
function name setDeep and the variables keyPath, parts, cur, p to locate and
modify the logic.


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t let --fix mask unresolved errors.

Right now any --fix run exits 0, even when there are extra keys or missing files that were never fixed. That makes CI go green while translations are still out of sync — spooky 2am behavior.

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 setDeep(), only increment fixed when the write actually succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/optimized` i18n-check.mjs around lines 120 - 122, The current
FIX_MODE branch always exits 0 which hides unresolved problems; update the
FIX_MODE handling in the script to track remaining errors and only exit 0 when
none remain. Specifically, ensure you maintain separate counters (e.g.,
totalErrors, fixed) and/or a remainingErrors variable, increment fixed only when
setDeep() actually writes successfully, then in the FIX_MODE block log how many
were fixed and how many remain and call process.exit(0) only if remainingErrors
=== 0 (otherwise process.exit(1)). Also ensure any missing-file or extra-key
conditions contribute to totalErrors so the exit code reflects true state.

}
Comment on lines +120 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return failure when --fix leaves unresolved i18n errors

The --fix path exits with status 0 whenever any issue was found, even if those issues were not fixable (for example, extra keys or missing files). In this script, errors is incremented for extra keys, but the unconditional if (FIX_MODE) { ... process.exit(0) } causes CI to pass despite remaining validation errors. Repro: run with --fix on a locale file that only has extra keys; it reports the extras and still succeeds.

Useful? React with 👍 / 👎.


console.log(color.red(`✗ i18n FAILED — ${errors} issues found`));
process.exit(1);
Loading