Update Owliver language and suggestions
This commit is contained in:
307
scripts/i18n-extract.mjs
Normal file
307
scripts/i18n-extract.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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');
|
||||
Reference in New Issue
Block a user