Update Owliver language and suggestions
This commit is contained in:
58
scripts/i18n-audit.mjs
Normal file
58
scripts/i18n-audit.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Every t('key') in the source, checked against the catalogues.
|
||||
*
|
||||
* A key the code calls and the catalogue lacks renders as humanised English at
|
||||
* best, so this is the check that says the extraction is actually complete.
|
||||
* The reverse — a catalogue entry nothing calls — is only waste, and reported
|
||||
* separately.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const walk = (dir, acc = []) => {
|
||||
for (const e of readdirSync(dir)) {
|
||||
if (e === 'node_modules' || e.startsWith('.')) continue;
|
||||
const p = join(dir, e);
|
||||
if (statSync(p).isDirectory()) walk(p, acc);
|
||||
else if (/\.(tsx|ts|jsx|js)$/.test(p)) acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
|
||||
const flat = (obj, prefix = '', out = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object') flat(v, key, out);
|
||||
else out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const en = flat(JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8')));
|
||||
const es = flat(JSON.parse(readFileSync('src/lib/i18n/locales/es.json', 'utf8')));
|
||||
|
||||
const used = new Set();
|
||||
const dynamic = [];
|
||||
for (const file of walk('src')) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
for (const m of src.matchAll(/\bt\(\s*'([^']+)'/g)) used.add(m[1]);
|
||||
for (const m of src.matchAll(/\bt\(\s*`([^`]+)`/g)) dynamic.push(`${file}: ${m[1]}`);
|
||||
}
|
||||
|
||||
/* A plural key is stored as name_one / name_other, so the base name is never
|
||||
itself in the catalogue and is not missing. */
|
||||
const present = (k) => k in en || `${k}_other` in en || `${k}_one` in en;
|
||||
|
||||
const missingEn = [...used].filter((k) => !present(k)).sort();
|
||||
const missingEs = [...used].filter((k) => present(k) && !(k in es || `${k}_other` in es)).sort();
|
||||
const unused = Object.keys(en).filter((k) => !used.has(k) && !used.has(k.replace(/_(one|other)$/, ''))).sort();
|
||||
|
||||
console.log(`\n t() calls with a literal key : ${used.size}`);
|
||||
console.log(` built from a template : ${dynamic.length} (checked by hand)`);
|
||||
console.log(` MISSING from en.json : ${missingEn.length}`);
|
||||
console.log(` MISSING from es.json : ${missingEs.length}`);
|
||||
console.log(` in en.json, never called : ${unused.length}`);
|
||||
|
||||
for (const k of missingEn.slice(0, 15)) console.log(` missing en: ${k}`);
|
||||
for (const k of missingEs.slice(0, 10)) console.log(` missing es: ${k}`);
|
||||
if (missingEn.length) process.exitCode = 1;
|
||||
126
scripts/i18n-coverage.mjs
Normal file
126
scripts/i18n-coverage.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* How much of each page is actually translated.
|
||||
*
|
||||
* Renders every migrated page twice — once in English, once in Spanish — and
|
||||
* reports the text that did NOT change. That residue is the untranslated part,
|
||||
* and it is the only honest measure of coverage: a catalogue can be full of
|
||||
* Spanish that no component ever calls, and counting keys would score that as
|
||||
* finished.
|
||||
*
|
||||
* Reports rather than asserts. The point is a number per page while the work is
|
||||
* half done; `skill-check.mjs` is where a finished page gets a check that keeps
|
||||
* it finished.
|
||||
*
|
||||
* node scripts/i18n-coverage.mjs
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createServer } from 'vite';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/* The pages reach for `window` on the way up — a media query, a resize
|
||||
listener. The same minimal stub the skill-check uses, installed before any
|
||||
page module is loaded. */
|
||||
if (!('window' in globalThis)) {
|
||||
globalThis.window = {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||
};
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
root: ROOT,
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
logLevel: 'error',
|
||||
resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } },
|
||||
});
|
||||
withSourceResolution(server);
|
||||
|
||||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||
const { MemoryRouter } = await import('react-router-dom');
|
||||
const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx');
|
||||
const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default;
|
||||
|
||||
const PAGES = [
|
||||
['Control Center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
|
||||
['Positions', '/src/pages/admin/Positions.jsx', '/admin/positions', 'positions'],
|
||||
['Candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
|
||||
['Talent Pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
|
||||
['Analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
|
||||
['Hired History', '/src/pages/admin/HiredHistory.jsx', '/admin/hired', 'hired-history'],
|
||||
['Activity', '/src/pages/admin/Activity.jsx', '/admin/activity', 'activity'],
|
||||
];
|
||||
|
||||
/* Text only, split into phrases on the punctuation the interface uses to
|
||||
separate one label from the next, so "17 total" counts separately from the
|
||||
heading above it. Numbers and symbols alone are not evidence either way —
|
||||
"27" is the same in both languages — so anything without a letter is
|
||||
dropped rather than scored as translated. */
|
||||
const phrases = (html) =>
|
||||
html
|
||||
.replace(/<[^>]*>/g, '\n')
|
||||
.split(/[\n·|•]+/)
|
||||
.map((p) => p.replace(/\s+/g, ' ').trim())
|
||||
.filter((p) => p.length > 2 && /[A-Za-z]{3}/.test(p));
|
||||
|
||||
async function render(path, page) {
|
||||
const Page = (await server.ssrLoadModule(path)).default;
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(MemoryRouter, { initialEntries: [page.route] },
|
||||
React.createElement(QueryClientProvider, { client },
|
||||
React.createElement(UiEditingProvider, { page: page.key },
|
||||
React.createElement(Page))))
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n Translation coverage — text that changes when the language does\n');
|
||||
|
||||
let totalSame = 0;
|
||||
let totalAll = 0;
|
||||
|
||||
for (const [name, path, route, key] of PAGES) {
|
||||
let en;
|
||||
let es;
|
||||
try {
|
||||
await i18n.changeLanguage('en');
|
||||
en = phrases(await render(path, { route, key }));
|
||||
await i18n.changeLanguage('es');
|
||||
es = phrases(await render(path, { route, key }));
|
||||
await i18n.changeLanguage('en');
|
||||
} catch (err) {
|
||||
console.log(` ${name.padEnd(16)} could not render — ${String(err.message).slice(0, 60)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* A phrase present in BOTH renders is one the switch did not touch. Proper
|
||||
nouns and figures land here too, which is why the sample below is printed:
|
||||
a human reads it and sees at once whether the residue is "KROW Forge" or a
|
||||
whole untranslated paragraph. */
|
||||
const esSet = new Set(es);
|
||||
const untouched = en.filter((p) => esSet.has(p));
|
||||
|
||||
totalSame += untouched.length;
|
||||
totalAll += en.length;
|
||||
|
||||
const pct = en.length ? Math.round(((en.length - untouched.length) / en.length) * 100) : 0;
|
||||
const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '·');
|
||||
console.log(` ${name.padEnd(16)} ${bar} ${String(pct).padStart(3)}% ${en.length - untouched.length}/${en.length} phrases`);
|
||||
|
||||
if (untouched.length) {
|
||||
const sample = untouched.filter((p) => /[a-z]{4}/.test(p)).slice(0, 4);
|
||||
if (sample.length) console.log(` ${''.padEnd(16)} still English: ${sample.join(' / ').slice(0, 96)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const overall = totalAll ? Math.round(((totalAll - totalSame) / totalAll) * 100) : 0;
|
||||
console.log(`\n Overall: ${overall}% of rendered phrases change with the language.\n`);
|
||||
|
||||
await server.close();
|
||||
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');
|
||||
@@ -1513,6 +1513,14 @@ record('the Owliver skill offers its chip, and draws no card',
|
||||
*/
|
||||
console.log('\n── Owliver suggestions from the API ──');
|
||||
|
||||
/* i18next, before ANY component renders. Every t() in the tree returns its own
|
||||
key until this has run, so a block that forgets it fails its parity check for
|
||||
a reason that has nothing to do with what it is testing. The app gets this
|
||||
from App.tsx; a harness that loads page modules directly has to ask. Loaded
|
||||
once here rather than per block, which is how two blocks came to be missing
|
||||
it while a third had it. */
|
||||
await server.ssrLoadModule('/src/lib/i18n/index.ts');
|
||||
|
||||
const serverSuggestions = await server.ssrLoadModule('/src/lib/skills/serverSuggestions.js');
|
||||
|
||||
/* ── The request ─────────────────────────────────────────────────────────── */
|
||||
@@ -3869,7 +3877,11 @@ record('both agent menus offer removal, and neither offers it unconditionally',
|
||||
`${removeItems.length} remove item(s), each behind (overridden || !shipped)`);
|
||||
|
||||
record('a custom agent is offered Remove, and a built-in one is only offered a revert',
|
||||
removeItems.every((block) => / Remove</.test(block) && /Revert to shipped/.test(block)
|
||||
/* Matched by translation KEY rather than by the English words: the labels
|
||||
now come from the catalogue, and a check that greps for "Remove" would be
|
||||
asserting on which language the file happens to be written in. */
|
||||
removeItems.every((block) => /workspaceAgents\.remove/.test(block)
|
||||
&& /workspaceAgents\.revertToShipped/.test(block)
|
||||
&& /shipped\s*\?/.test(block)),
|
||||
'the label is chosen by `shipped`, not by name');
|
||||
|
||||
@@ -3923,7 +3935,7 @@ record('...while Archived stays reachable as the place removal is undone',
|
||||
the confirmation names "Archived" while no such tab is on screen. */
|
||||
record('...and Archived is offered even when it is empty',
|
||||
!/\{archivedCount > 0 && \(/.test(agentsListSource)
|
||||
&& /Archived <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
|
||||
&& /workspaceAgents\.archived'\)\} <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
|
||||
'the tab is unconditional and shows its count');
|
||||
|
||||
record('...and reverting a built-in agent is not worded as a removal',
|
||||
@@ -4880,7 +4892,7 @@ record('every field the editor had is still editable',
|
||||
record('the header still states version, status and customization',
|
||||
/v\{agent\.version\}/.test(detailSource)
|
||||
&& /\{agent\.status\}/.test(detailSource)
|
||||
&& /isOverridden\(id\) && <Badge variant="info">Customized/.test(detailSource)
|
||||
&& /isOverridden\(id\) && <Badge variant="info">\{t\('agentDetail\.customized'\)\}/.test(detailSource)
|
||||
&& /Publish update/.test(detailSource),
|
||||
'v · status · Customized · Publish update');
|
||||
|
||||
@@ -9082,7 +9094,14 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
}
|
||||
html = html.slice(0, at) + html.slice(i);
|
||||
}
|
||||
return { pass: html === positionsBefore, detail: html === positionsBefore ? '' : `len ${positionsBefore.length}->${html.length}` };
|
||||
/* Compared with runs of whitespace collapsed. Text that used to be
|
||||
written across several lines of JSX now comes from the catalogue as
|
||||
one line, so the markup differs by newlines and indentation that HTML
|
||||
collapses anyway. Everything this check exists to catch — a changed
|
||||
element, class, attribute or word — still fails it. */
|
||||
const ws = (h) => h.replace(/\s+/g, ' ').trim();
|
||||
const same = ws(html) === ws(positionsBefore);
|
||||
return { pass: same, detail: same ? '' : `len ${positionsBefore.length}->${html.length}` };
|
||||
})().pass);
|
||||
}
|
||||
}
|
||||
@@ -9323,6 +9342,106 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return { pass: ids.length > 0, detail: ids.join(', ') };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
/* ── The page actually translates ───────────────────────────────────────
|
||||
*
|
||||
* The parity checks above all run in English, so every one of them would
|
||||
* still pass if `t()` were wired up and the Spanish catalogue were empty.
|
||||
* This renders the same page in Spanish and insists the words CHANGED.
|
||||
*
|
||||
* Asserting on specific strings rather than merely "different": a page
|
||||
* where t() returns raw keys also differs from English, and that is the
|
||||
* failure this is most likely to catch.
|
||||
*/
|
||||
{
|
||||
const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default;
|
||||
const Page = (await server.ssrLoadModule('/src/pages/admin/ControlCenter.jsx')).default;
|
||||
const render = () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||||
return stripControls(renderToStaticMarkup(
|
||||
React.createElement(R, { initialEntries: ['/admin'] },
|
||||
React.createElement(QueryClientProvider, { client },
|
||||
React.createElement(UiEditingProvider, { page: 'control-center' },
|
||||
React.createElement(Page))))
|
||||
));
|
||||
};
|
||||
|
||||
const en = words(render());
|
||||
await i18n.changeLanguage('es');
|
||||
const es = words(render());
|
||||
await i18n.changeLanguage('en');
|
||||
|
||||
record('Control Center renders in Spanish', es !== en);
|
||||
record('Control Center shows Spanish words, not keys', (() => {
|
||||
const want = ['Centro de control', 'Vacantes abiertas', 'Candidatos'];
|
||||
const missing = want.filter((w) => !es.includes(w));
|
||||
const keys = /controlCenter\.title|metrics\.|sections\./.test(es);
|
||||
return {
|
||||
pass: missing.length === 0 && !keys,
|
||||
detail: keys ? 'raw keys rendered' : (missing.length ? `missing ${missing.join(', ')}` : 'translated'),
|
||||
};
|
||||
})().pass);
|
||||
record('switching back restores English', words(render()) === en);
|
||||
|
||||
/* THE CHECK THAT MATTERS, and the one whose absence let a half-translated
|
||||
page be reported as finished. "es !== en" passes when a single word
|
||||
changed, which is exactly the state a reader sees as broken: Spanish
|
||||
headings sitting on top of English captions, legends and stage names.
|
||||
So this names the English the page used to show and insists none of it
|
||||
survives the switch. A key added to the catalogue but never wired into
|
||||
the component fails here, where every other check stays green. */
|
||||
record('no English survives on the Spanish Control Center', (() => {
|
||||
const english = [
|
||||
'Applications, screening, interviews and hires',
|
||||
'entered · conversion and drop-off by stage',
|
||||
'Entry Stage', 'Primary Pipeline Bottleneck',
|
||||
'pass through and', 'Open positions', 'In review', 'AI screened',
|
||||
'Avg KROW score', 'Hiring activity', 'Pipeline intelligence',
|
||||
'Position performance', 'Action center', 'Active positions',
|
||||
'Top talent', 'Recent activity', 'Shortlisted', 'Interview',
|
||||
];
|
||||
const left = english.filter((e) => es.includes(e));
|
||||
return { pass: left.length === 0, detail: left.length ? `still English: ${left.join(' | ')}` : 'fully translated' };
|
||||
})().pass);
|
||||
|
||||
/* The panel writes its own greeting, description and placeholders in the
|
||||
browser — `dynamic.ts` — so a translated DASHBOARD with an English
|
||||
Owliver is the exact half-done state this is here to catch. */
|
||||
const dyn = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.ts');
|
||||
const facts = {
|
||||
today: new Date('2026-10-07T09:00:00'),
|
||||
total: 24, postings: new Array(8),
|
||||
unscoredProfiles: [], activity24h: [],
|
||||
};
|
||||
const esIntro = dyn.buildIntro('admin.controlCenter', facts, 'Aravind', i18n.getFixedT('es'));
|
||||
const enIntro = dyn.buildIntro('admin.controlCenter', facts, 'Aravind', i18n.getFixedT('en'));
|
||||
|
||||
record('the assistant greeting translates', (() => {
|
||||
const ok = esIntro.title.startsWith('Buenos días') && enIntro.title.startsWith('Good morning');
|
||||
return { pass: ok, detail: `${enIntro.title} / ${esIntro.title}` };
|
||||
})().pass);
|
||||
|
||||
record('the assistant greeting agrees in gender and number', (() => {
|
||||
/* "Buenos días" and "Buenas tardes" — the adjective agrees with the
|
||||
noun, so a greeting assembled from "Good " plus a translated word is
|
||||
wrong in one of them. One key per time of day is why this passes. */
|
||||
const afternoon = { ...facts, today: new Date('2026-10-07T14:00:00') };
|
||||
const pm = dyn.buildIntro('admin.controlCenter', afternoon, '', i18n.getFixedT('es'));
|
||||
return { pass: pm.title === 'Buenas tardes', detail: pm.title };
|
||||
})().pass);
|
||||
|
||||
record('the assistant description counts in Spanish', (() => {
|
||||
const ok = esIntro.description.includes('24 solicitudes')
|
||||
&& esIntro.description.includes('8 vacantes');
|
||||
return { pass: ok, detail: esIntro.description };
|
||||
})().pass);
|
||||
|
||||
record('a singular count does not take the plural form', (() => {
|
||||
const one = { ...facts, total: 1, postings: new Array(1) };
|
||||
const d = dyn.buildIntro('admin.controlCenter', one, '', i18n.getFixedT('es')).description;
|
||||
return { pass: d.includes('1 solicitud ') || d.includes('1 solicitud'), detail: d };
|
||||
})().pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user