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.
128 lines
4.4 KiB
JavaScript
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);
|
|
});
|