/** * Render an Admin page to static markup, outside a browser. * * The migration to the UI node tree has to be provable rather than asserted: * a page is rendered before it is touched, rendered again afterwards, and the * two are compared. This is the thing that renders it, used both to capture a * baseline and, from the check script, to compare against one. * * node scripts/render-page.mjs src/pages/admin/HiredHistory.jsx out.html * * The page is loaded through a real Vite server, so `@/` aliases, Markdown * imports and `import.meta.glob` behave exactly as they do in the app. Queries * are disabled rather than mocked: every page then renders its empty state, * deterministically, which is all a structural comparison needs. */ import { createServer } from 'vite'; import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { withSourceResolution } from './ssr-resolve.mjs'; /** * The design system reads `window` when its modules evaluate, which is a * pre-existing SSR limitation and not what any of this is testing. The shim is * the same one the citation tests use. */ export function shimWindow() { const had = 'window' in globalThis; const hadSvg = 'SVGElement' in globalThis; if (!had) { globalThis.window = { matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), addEventListener() {}, removeEventListener() {}, }; } /* Recharts tests `instanceof SVGElement` while measuring, which is a browser global with no Node equivalent. A bare class is enough: nothing is ever an instance of it, which is the correct answer outside a browser. */ if (!hadSvg) globalThis.SVGElement = class SVGElement {}; return () => { if (!had) delete globalThis.window; if (!hadSvg) delete globalThis.SVGElement; }; } /** * Render one page. * * `providers` are supplied by the caller rather than assumed here, because the * editing session belongs to the layout and a baseline captured before a * migration must be rendered without it. */ export async function renderPage(server, modulePath, { route = '/', wrap = null } = {}) { /* Imported through Node rather than the Vite graph so the instance matches the one the page itself resolves — loading them as SSR modules creates a second copy, and a second QueryClientProvider provides nothing. */ const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query'); const { MemoryRouter } = await import('react-router-dom'); const Page = (await server.ssrLoadModule(modulePath)).default; const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }); const inner = wrap ? wrap(React.createElement(Page)) : React.createElement(Page); return renderToStaticMarkup( React.createElement(MemoryRouter, { initialEntries: [route] }, React.createElement(QueryClientProvider, { client }, inner)) ); } /** * Two renderings are the same page when they paint the same styled boxes, in * the same order, around the same words. * * Compared this way rather than byte-for-byte because a migration legitimately * adds `data-ui-*` identity attributes, and for components that cannot forward * unknown props a wrapper element carrying nothing else. Both are invisible. * A changed utility class, a reordered section or altered text moves one of * these and fails. */ export const classSignature = (html) => (html.match(/class="[^"]*"/g) || []).join('\n'); export const wordSignature = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); /** Tag counts, so an added element is visible and can be characterised. */ export const tagCounts = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || []) .map((t) => t.toLowerCase()) .reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {}); /* Run directly: capture a baseline. */ if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) { const [modulePath, out, route] = process.argv.slice(2); const restore = shimWindow(); const server = await createServer({ root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error', resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } }, }); /* The page path arrives on the command line with whatever extension it has today; during the TypeScript migration that may be .jsx or .tsx. */ withSourceResolution(server, process.cwd()); try { const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' }); writeFileSync(out, html); console.log(`rendered ${html.length} chars -> ${out}`); } catch (error) { console.error('RENDER FAILED:', error.message); process.exitCode = 1; } finally { await server.close(); restore(); /* `server.close()` leaves a handle open often enough that the process hangs without this, and a capture script that never exits is a capture script nobody runs. */ process.exit(process.exitCode || 0); } }