127 lines
5.2 KiB
JavaScript
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();
|