Files
krow_talent_app/scripts/i18n-extract.mjs

308 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Lift hard-coded English out of components and into the catalogue.
*
* node scripts/i18n-extract.mjs --dry src/components/krow
* node scripts/i18n-extract.mjs src/components/krow/SomeCard.tsx
*
* An AST codemod rather than a regex pass, because the hard part is not finding
* the string — it is knowing which component encloses it, so `const { t } =
* useTranslation()` lands in the right function and not in a module-level
* constant where hooks cannot run.
*
* WHAT IT WILL TOUCH
* - JSX text children that are entirely static words
* - string literals on a short list of text-bearing attributes
*
* WHAT IT LEAVES ALONE, and why each one would be a bug:
* - anything inside a module-level object or array; those are built once at
* import, so a t() there freezes the first language the app loaded
* - strings with no letters, single characters, and pure punctuation
* - `className`, `id`, `key`, `type`, `name`, routes, urls, test ids
* - text inside <code>, <pre>, <script> and <style>
* - a component it cannot find a function body for
*
* Keys are `<namespace>.<slug>`, the namespace from the file name, the slug
* from the words themselves — so a key reads as what it says, and moving a
* heading between files does not rename it.
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, basename, extname } from 'node:path';
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
import _generate from '@babel/generator';
import * as t from '@babel/types';
const traverse = _traverse.default || _traverse;
const generate = _generate.default || _generate;
const DRY = process.argv.includes('--dry');
const TARGETS = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const TEXT_ATTRS = new Set([
'title', 'label', 'description', 'placeholder', 'subtitle', 'meta',
'aria-label', 'alt', 'emptyLabel', 'heading', 'hint', 'caption', 'tooltip',
'confirmLabel', 'cancelLabel', 'actionLabel', 'emptyTitle', 'emptyDescription',
]);
const SKIP_ELEMENTS = new Set(['code', 'pre', 'script', 'style']);
/* A string worth translating has letters, more than one of them, and is not a
token that only looks like prose (a route, a url, a css class, an enum). */
const translatable = (s) => {
const v = s.trim();
if (v.length < 2) return false;
if (!/[A-Za-z]{2}/.test(v)) return false;
if (/^[a-z0-9_-]+$/.test(v)) return false; // enum / slug / class
if (/^(https?:|\/|#|data:|mailto:)/.test(v)) return false;
if (/^[A-Z0-9_]+$/.test(v)) return false; // CONSTANT_CASE
return true;
};
const slug = (s) =>
s.trim().toLowerCase()
.replace(/[’']/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.split(' ')
.slice(0, 6)
.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1)))
.join('') || 'text';
const namespaceFor = (file) => {
const base = basename(file, extname(file));
if (base === 'nodes' || base === 'index') {
const parent = basename(join(file, '..'));
return parent.replace(/[^A-Za-z0-9]+/g, '');
}
return base.charAt(0).toLowerCase() + base.slice(1);
};
/** The nearest enclosing function that is a component (capitalised name). */
function enclosingComponent(path) {
let p = path;
while (p) {
if (p.isFunctionDeclaration() || p.isFunctionExpression() || p.isArrowFunctionExpression()) {
const id = p.node.id?.name
|| (p.parentPath?.isVariableDeclarator() && p.parentPath.node.id?.name);
/* Only a component with a BLOCK body can take the hook. An arrow whose
body is the JSX itself (`const X = () => <p>Text</p>`) would need
rewriting into a block first, and a codemod that reshapes a component
to translate one word is doing more than it was asked. Those are left
alone and reported, so they can be done by hand. */
if (id && /^[A-Z]/.test(id)) {
return t.isBlockStatement(p.node.body) ? p : null;
}
/* An inline render callback — keep walking out to the component that
owns it, so one hook serves both. */
}
p = p.parentPath;
}
return null;
}
/** True when the node sits in a module-level object/array literal. */
function atModuleScope(path) {
let p = path;
while (p) {
if (p.isFunction()) return false;
if (p.isProgram()) return true;
p = p.parentPath;
}
return true;
}
function processFile(file, catalogue) {
const src = readFileSync(file, 'utf8');
if (!/<[A-Za-z]/.test(src)) return null;
let ast;
try {
ast = parse(src, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'classProperties', 'decorators-legacy'],
});
} catch {
return { file, skipped: 'could not parse' };
}
/* A file that already binds `t` to something of its own — a theme token, a
translate helper, a loop variable — cannot also take the hook's `t`
without shadowing it. Skipped whole rather than half converted: the hook
would compile and then silently resolve to the wrong thing. */
if (/\b(?:const|let|var)\s+t\b\s*=/.test(src) || /\(\s*t\s*[,)]/.test(src)) {
return { file, skipped: 'already binds `t`' };
}
const ns = namespaceFor(file);
const needHook = new Set();
const found = [];
/* Character ranges to splice into the ORIGINAL text. Babel's generator would
reprint the whole file — restyling commas, quotes and JSX that nobody
asked it to touch — and a 52-file diff of incidental reformatting is one
nobody can review. */
const edits = [];
const keyFor = (text) => {
const key = `${ns}.${slug(text)}`;
const existing = catalogue.get(key);
if (existing && existing !== text) {
/* Same words, different file, different sentence: disambiguate rather
than let one overwrite the other. */
let i = 2;
while (catalogue.has(`${key}${i}`) && catalogue.get(`${key}${i}`) !== text) i += 1;
return `${key}${i}`;
}
return key;
};
traverse(ast, {
JSXText(path) {
const raw = path.node.value;
const text = raw.trim();
if (!translatable(text)) return;
const parent = path.parent;
if (t.isJSXElement(parent)) {
const name = parent.openingElement.name;
if (t.isJSXIdentifier(name) && SKIP_ELEMENTS.has(name.name)) return;
}
if (atModuleScope(path)) return;
const comp = enclosingComponent(path);
if (!comp) return;
const key = keyFor(text);
catalogue.set(key, text);
produced.add(key);
found.push({ key, text });
needHook.add(comp);
/* Only the trimmed span is replaced, so the surrounding newlines and
indentation survive exactly as written. */
const lead = raw.length - raw.trimStart().length;
edits.push({
start: path.node.start + lead,
end: path.node.start + lead + text.length,
text: `{t('${key}')}`,
});
},
JSXAttribute(path) {
const name = path.node.name;
const attr = t.isJSXIdentifier(name) ? name.name
: t.isJSXNamespacedName(name) ? `${name.namespace.name}:${name.name.name}` : null;
if (!attr || !TEXT_ATTRS.has(attr)) return;
const value = path.node.value;
if (!t.isStringLiteral(value)) return;
const text = value.value.trim();
if (!translatable(text)) return;
if (atModuleScope(path)) return;
const comp = enclosingComponent(path);
if (!comp) return;
const key = keyFor(text);
catalogue.set(key, text);
produced.add(key);
found.push({ key, text });
needHook.add(comp);
edits.push({ start: value.start, end: value.end, text: `{t('${key}')}` });
},
});
if (!found.length) return null;
/* One hook per component that now calls t(), on its own line just inside the
body. A component that already destructures t is left alone. */
for (const comp of needHook) {
const body = comp.node.body;
if (!t.isBlockStatement(body)) continue;
const already = body.body.some(
(st) => t.isVariableDeclaration(st)
&& st.declarations.some((d) => t.isObjectPattern(d.id)
&& d.id.properties.some((pr) => t.isObjectProperty(pr) && pr.key?.name === 't'))
);
if (already) continue;
/* Indentation copied from the first statement, so the inserted line sits
where a person would have typed it. */
const first = body.body[0];
const lineStart = first ? src.lastIndexOf('\n', first.start) + 1 : -1;
const indent = first ? src.slice(lineStart, first.start).match(/^[ \t]*/)[0] : ' ';
edits.push({
start: body.start + 1,
end: body.start + 1,
text: `\n${indent}const { t } = useTranslation();`,
});
}
/* Applied last-first so earlier offsets stay valid. */
let out = src;
edits.sort((a, b) => b.start - a.start);
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
if (!/from ['"]react-i18next['"]/.test(out)) {
const lines = out.split('\n');
let last = -1;
for (let i = 0; i < Math.min(lines.length, 60); i += 1) {
if (/^import .*;\s*$/.test(lines[i])) last = i;
}
lines.splice(last + 1, 0, "import { useTranslation } from 'react-i18next';");
out = lines.join('\n');
}
if (!DRY) writeFileSync(file, out);
return { file, found };
}
function walk(target, acc = []) {
const st = statSync(target);
if (st.isFile()) {
if (/\.(tsx|jsx)$/.test(target)) acc.push(target);
return acc;
}
for (const entry of readdirSync(target)) {
if (entry === 'node_modules' || entry.startsWith('.')) continue;
walk(join(target, entry), acc);
}
return acc;
}
/* Seeded from the catalogue already on disk, so a key taken by a DIFFERENT
string in an earlier run is seen as taken. Without this each run starts
blank, two runs mint the same key for two different sentences, and the
merge silently keeps one of them — which is how "Search positions" became
"Search positions...". */
const produced = new Set();
const catalogue = new Map();
try {
const existing = JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8'));
for (const [ns, items] of Object.entries(existing)) {
if (items && typeof items === 'object') {
for (const [leaf, val] of Object.entries(items)) catalogue.set(`${ns}.${leaf}`, val);
}
}
} catch { /* first run, nothing to seed from */ }
const files = TARGETS.flatMap((tg) => walk(tg));
let changed = 0;
let strings = 0;
for (const file of files) {
const res = processFile(file, catalogue);
if (!res) continue;
if (res.skipped) { console.log(` skip ${file} — ${res.skipped}`); continue; }
changed += 1;
strings += res.found.length;
console.log(` ${DRY ? 'would do' : 'done'} ${file.replace('src/', '')} — ${res.found.length}`);
}
console.log(`\n ${changed} files, ${strings} strings${DRY ? ' (dry run, nothing written)' : ''}`);
/* ONLY the keys this run created. The catalogue is seeded from disk so
collisions are visible, and writing it back out whole would re-emit every
existing entry — including the nested ones this format does not model. */
const additions = {};
for (const [key, text] of catalogue) {
if (!produced.has(key)) continue;
const [ns, leaf] = [key.slice(0, key.indexOf('.')), key.slice(key.indexOf('.') + 1)];
(additions[ns] ||= {})[leaf] = text;
}
writeFileSync('scripts/__i18n-additions.json', `${JSON.stringify(additions, null, 2)}\n`);
console.log(' catalogue written to scripts/__i18n-additions.json');