SkateSpots/scripts/check-i18n.mjs
SkateSpots fa0b9cfe1c Fix raw i18n keys on coach chips; add structured rate + form polish
The coach option chips rendered raw 'labUi.coachForm.…' keys because the 29
per-option translation keys were referenced dynamically (t(option.key)) and had
never been added — the literal-only i18n guard missed them. Add every option
key (EN/ES) and extend scripts/check-i18n.mjs to also scan data tables for
key/titleKey/labelKey/badgeKey entries so this can't recur.

Also fix the chip layout: the global mobile 'button { min-height:44px }' rule
turned every chip into a 44px slab; add min-h-0 to the coach option chips,
language chips, Lab facet chips and Lab active-filter chips.

Remaining nice-to-haves:
- Structured rate: rate_amount + rate_currency (sats/USD/EUR/GBP) + rate_unit
  (hour/session/half-day/day/camp), rendered on the detail page; legacy
  freeform 'rate' still shows and acts as an optional note.
- Section the long coach form with a FormSection divider.
- Restyle the coach identity block from a red error-looking box to a neutral
  avatar card.
2026-10-04 10:05:11 +07:00

128 lines
4.4 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 tRe = /(?<![\w.])t\(\s*['"]([^'"]+)['"]/g;
// Also pick up translation keys declared in data tables, e.g.
// `{ value: 'group', key: 'labUi.coachForm.formatGroup' }` or
// `titleKey:` / `labelKey:` / `badgeKey:`. These are looked up dynamically via
// `t(option.key)`, so the literal-`t()` scan alone would miss them.
const dataRe = /\b(?:key|titleKey|labelKey|badgeKey):\s*['"]([a-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)['"]/g;
for (const file of files) {
const text = readFileSync(file, 'utf8');
const rel = file.replace(`${ROOT}/`, '');
for (const re of [tRe, dataRe]) {
re.lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const key = match[1];
if (!used.has(key)) used.set(key, []);
used.get(key).push(rel);
}
}
}
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);
});