SkateSpots/scripts/check-i18n.mjs
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- First-visit language auto-detect (Spanish devices land in es; stored choice wins).
- Sasquatch AI replies in the app language (skate trick names kept English).
- i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t()
  key missing from en/es or any en/es key drift.
- ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and
  ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace,
  trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters.
- Policy: only skate TRICK names stay English; user-generated content untouched.
2026-10-01 23:57:20 +07:00

119 lines
3.9 KiB
JavaScript

#!/usr/bin/env node
/**
* i18n guard — fails the build on translation drift.
*
* Because `t('some.key')` takes a plain string, TypeScript can't catch a typo or
* a key that only exists in one language. This script is the safety net:
*
* 1. Every `t('…')` key used anywhere in `src/` MUST exist in BOTH `en` and
* `es` (a missing key otherwise degrades to the raw key + a console warning).
* 2. `en` and `es` must be key-for-key identical, so neither language can drift.
*
* Translations are loaded with esbuild (already a dependency) so the TypeScript
* source files can be imported directly without a build step.
*/
import { readFileSync, readdirSync, statSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join, extname } from 'node:path';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { build } from 'esbuild';
const ROOT = new URL('..', import.meta.url).pathname;
const SRC = join(ROOT, 'src');
/** Flatten a nested translations object into a Set of dotted leaf keys. */
function flatten(obj, prefix = '', out = new Set()) {
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object') {
flatten(value, path, out);
} else if (typeof value === 'string') {
out.add(path);
}
}
return out;
}
/** Load a TS translation module by bundling it to a temp ESM file. */
async function loadTranslations(file) {
const result = await build({
entryPoints: [file],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent',
});
const dir = mkdtempSync(join(tmpdir(), 'i18n-check-'));
const tmp = join(dir, 'mod.mjs');
writeFileSync(tmp, result.outputFiles[0].text);
try {
const mod = await import(pathToFileURL(tmp).href);
return mod.default;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** Recursively list source files under a directory. */
function walk(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === 'i18n' || entry === 'test') continue;
files.push(...walk(full));
} else if (['.ts', '.tsx'].includes(extname(entry))) {
files.push(full);
}
}
return files;
}
/** Collect every `t('some.key')` usage across the source. */
function collectUsedKeys(files) {
const used = new Map(); // key -> [files]
const re = /(?<![\w.])t\(\s*['"]([^'"]+)['"]/g;
for (const file of files) {
const text = readFileSync(file, 'utf8');
let match;
while ((match = re.exec(text)) !== null) {
const key = match[1];
if (!used.has(key)) used.set(key, []);
used.get(key).push(file.replace(`${ROOT}/`, ''));
}
}
return used;
}
async function main() {
const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts')));
const es = flatten(await loadTranslations(join(SRC, 'i18n/es.ts')));
const problems = [];
// 1. en/es parity.
for (const key of en) if (!es.has(key)) problems.push(`missing in es: ${key}`);
for (const key of es) if (!en.has(key)) problems.push(`missing in en: ${key}`);
// 2. every used key exists in both languages.
const used = collectUsedKeys(walk(SRC));
for (const [key, files] of used) {
if (!en.has(key)) problems.push(`used but missing in en: ${key} (${files[0]})`);
if (!es.has(key)) problems.push(`used but missing in es: ${key} (${files[0]})`);
}
if (problems.length) {
console.error(`\n✖ i18n check failed (${problems.length}):`);
for (const p of problems.slice(0, 200)) console.error(` • ${p}`);
if (problems.length > 200) console.error(` …and ${problems.length - 200} more`);
process.exit(1);
}
console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used`);
}
main().catch((err) => {
console.error('i18n check crashed:', err);
process.exit(1);
});