Files
krow_talent_app/scripts/i18n-coverage.mjs

127 lines
5.2 KiB
JavaScript

/**
* 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();