Phases 0-3 of the JS/JSX -> TS/TSX migration. No runtime behaviour changes:
every converted file emits byte-identical JavaScript, verified file by file.
Phase 0 - harness hardening, before any rename:
- scripts/ssr-resolve.mjs wraps `ssrLoadModule` so the ~170 literal module
paths in the check scripts resolve .js/.jsx/.ts/.tsx. Without it the first
rename would have silently destroyed the 1642-check suite that guards the
Owliver flow.
- eslint.config.js gains a TypeScript block. Its `files` globs listed only
{js,mjs,cjs,jsx}, so a renamed file would have dropped out of the run while
`eslint .` went on exiting 0 - the quietest failure mode available.
- MIGRATION_BASELINE.md records the measured starting point, including the
pre-existing seed-fixture failure and the already-broken standalone
owliver-baseline.mjs, so neither is later mistaken for migration damage.
Phase 1 - tsconfig.json succeeds jsconfig.json, carrying every option across at
its old value. `types` moves from [] to ["vite/client"], which fixes the eight
import.meta errors; @types/node is deliberately excluded so setTimeout stays a
number in browser code. allowJs and checkJs stay on, strict stays off.
Phase 2 - src/types/{api,entities,user}.ts. Transport envelope, error shape,
the entity-name union (the same 18 names are written down twice today, in
httpClient and base44Client, with nothing checking they agree), and the user
record. Every field transcribed from the API contract, the migrations and the
/me projection in me.go - not inferred. Entity record shapes are deliberately
absent: derivable, but nothing consumes them yet.
Phase 3 - nine leaf utilities renamed to .ts with annotations added only where
they could be established from existing usage. Entity records are typed `any`
with a comment naming what they are, rather than a guessed interface.
Verified: tsc 64 errors (65 before; one pre-existing TS2559 genuinely fixed,
none introduced), lint unchanged at 0 errors, skill-check 1641/1642 with only
the known failure, Owliver baseline section 59/59 green, production build
succeeds with the API origin inlined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
120 lines
5.1 KiB
JavaScript
120 lines
5.1 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|