/** * Extension-agnostic SSR module loading, for the duration of the TypeScript * migration. * * Every script in here addresses modules by literal path — `ssrLoadModule( * '/src/lib/skills/registry.js')` — about a hundred and seventy times across * `skill-check.mjs`, `owliver-capture.mjs`, `render-page.mjs` and * `seed-fixture.mjs`. That is fine while every source file is JavaScript and * fatal the moment one is not: renaming `registry.js` to `registry.ts` turns * the check suite's very first load into a failure, and the suite is the only * evidence the Owliver flow still behaves the way it did. * * Rewriting all those call sites would be a large, noisy, error-prone diff * against the file that guards the migration — exactly the wrong thing to * disturb. So the loader is wrapped once instead and the call sites keep the * paths they already have, which stay readable as the names of real files. * * Resolution is by existence on disk, not by catching a failed load. A load * that fails for a real reason — a syntax error, a bad import inside the module * — must surface as itself; retrying under another extension would bury it * behind a second, more confusing error about a file that was never there. * * The path as written is always tried first, so while a module is still * JavaScript this changes nothing at all. * * This file is temporary. When `src` holds no `.js` or `.jsx` any more, the * call sites can be renamed in one pass and this wrapper deleted. */ import { existsSync } from 'node:fs'; import { join } from 'node:path'; /** * The candidate paths for one module specifier, in the order they are tried. * * Only `.js` and `.jsx` are rewritten. Anything else — a bare specifier, a * `.mjs` file, something under `/node_modules` — is returned untouched, because * nothing in this migration renames it. * * `.js` is allowed to become `.tsx` as well as `.ts`. Not because any `.js` * file here contains JSX today — none of the 93 does, checked with a parser * rather than a guess — but because one may be renamed that way: the only * `.js` under `src/pages` is `admin/positions/nodes.js`, sitting among seven * sibling `nodes.jsx` files, and whoever converts that directory will * reasonably want all eight to end in `.tsx`. The extra candidate costs one * `existsSync` that answers no. */ export function candidatesFor(path) { const specifier = String(path); if (!/\.jsx?$/.test(specifier)) return [specifier]; const stem = specifier.replace(/\.jsx?$/, ''); return [...new Set([specifier, `${stem}.ts`, `${stem}.tsx`])]; } /** * Wraps `server.ssrLoadModule` so it finds a module whichever of the four * extensions it currently carries. * * Mutates and returns the server, so it reads as one line after `createServer` * and every later call — including the dynamically-built paths, which is why * this is done here rather than at the call sites — goes through it. * * `root` is where the leading-slash paths are rooted; it defaults to the * process's working directory, which is what every caller here uses. */ export function withSourceResolution(server, root = process.cwd()) { const load = server.ssrLoadModule.bind(server); server.ssrLoadModule = (path, options) => { for (const candidate of candidatesFor(path)) { if (existsSync(join(root, candidate.replace(/^\//, '')))) { return load(candidate, options); } } /* Nothing on disk under any extension. Load the path as written so the error names what the caller actually asked for. */ return load(path, options); }; return server; } /** * The same resolution, for source read as TEXT rather than loaded as a module. * * `skill-check.mjs` asserts structural facts by reading source files and * matching against their contents — "the panel imports no local suggestion * ranker", "no runtime path writes a definition". Those reads go through * `readFileSync`, not `ssrLoadModule`, so `withSourceResolution` above never * sees them: it wraps the loader, and this is a second, entirely separate * channel. Renaming `base44Client.js` to `.ts` is what surfaced the difference, * as an ENOENT in the middle of a suite that had been passing. * * Takes and returns a path RELATIVE to the project root, so the call site keeps * its `join(ROOT, …)` exactly as it was: * * readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8') * * Unresolvable paths come back unchanged, so the resulting error still names * the file the caller asked for rather than a candidate it invented. That also * keeps the deliberately absent ones honest: a check asserting a file is GONE * gets the path it asked about, and `existsSync` still answers false. */ export function resolveSourcePath(path, root = process.cwd()) { for (const candidate of candidatesFor(path)) { if (existsSync(join(root, candidate))) return candidate; } return path; }