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