🌾 feat: rebuild the landing page, and stop it from rotting again
The old page had been quietly broken for some time. It loaded
@babel/standalone from an **unpinned** CDN URL, which rolled over to Babel
8 and dropped the `proposal-class-properties` plugin the page asked for, so
Babel.transform threw before it ever reached the decorators — and the
decorator config it passed was `{ legacy: true }`, which 0.2.0 had already
made wrong. Nothing on the page said so. The copy was still selling the
0.1.0 pitch ("Spring-like"), listed about half the decorators, showed
`npm install cereale` for a package the registry returns 404 for, and
claimed "Zero overhead" against a README that publishes the real
microsecond costs.
The rebuild is one self-contained page: hand-written CSS, no Tailwind CDN,
no CodeMirror, and a vendored compiler pinned by package.json. It loads
nothing from the network. The playground runs the real bundled library
across six examples, all verified in a headless browser. The reference
covers all 68 decorators and the full API, counted from the bundle at
runtime so it cannot drift.
The hero's compiler error is not typed into the HTML. scripts/build-docs.mjs
compiles the snippets with the real tsc and writes the verbatim diagnostics
into docs/diagnostics.js, failing the build if a snippet the page calls a
compile error ever compiles — and two snippets that must compile guard
against the harness passing vacuously.
Three guards keep it honest, all wired into CI:
- check:docs fails on any remote subresource
- build:docs + git diff fails if docs/ is stale against src/
- check:types compiles a consumer against dist/ with no DOM lib, no
@types/node and no skipLibCheck
That last one found a real packaging defect: `fromRequest` was declared as
taking the global `Request`, so cereale's own published .d.ts raised
"Cannot find name 'Request'" in any project whose lib and types did not
happen to supply it — inside a dependency, in code they may never call, and
unfixable from the outside. It now takes a structural JsonBody, which a
Request still satisfies. The library's own type tests had been hiding it by
enabling both DOM and skipLibCheck.
An adversarial review of the finished page caught four more: the lede
claimed *every* rule is type-checked (@IsDefined and @IsNotIn deliberately
are not), the guarantee section was wrong about the mechanism (a legacy
decorator does get design:type under emitDecoratorMetadata — the real claim
is about its type signature), one sample called a Movie method on a Media[]
and did not compile, and "nested objects come back as real classes" omitted
that you have to declare them. WCAG contrast was measured rather than
eyeballed: seven real failures fixed in the two themes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Builds the assets the landing page needs, into docs/.
|
||||
*
|
||||
* The page is served by GitHub Pages straight from the repository, so everything it loads has
|
||||
* to be committed — there is no build step on the hosting side. Everything it loads is also
|
||||
* local: the previous page pulled Tailwind, CodeMirror and Babel from three CDNs, and its
|
||||
* playground died silently when the unpinned `@babel/standalone` URL rolled over to Babel 8
|
||||
* and the plugin list it passed stopped existing. Vendoring the compiler pins it to the
|
||||
* version in package.json and to a lockfile.
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import { copyFile, mkdir, readFile, writeFile, stat } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { collectDiagnostics } from './diagnostics.mjs';
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const docs = path.join(root, 'docs');
|
||||
const vendor = path.join(docs, 'vendor');
|
||||
|
||||
const size = async (file) => {
|
||||
const { size: bytes } = await stat(file);
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
};
|
||||
|
||||
await mkdir(vendor, { recursive: true });
|
||||
|
||||
const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
|
||||
// 1. The library itself, as a browser global the playground can pull names out of.
|
||||
await build({
|
||||
entryPoints: [path.join(root, 'src/index.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'Cereale',
|
||||
minify: true,
|
||||
target: 'es2022',
|
||||
tsconfigRaw: {
|
||||
compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true, target: 'es2022' },
|
||||
},
|
||||
outfile: path.join(docs, 'cereale.js'),
|
||||
});
|
||||
|
||||
// 2. Facts the page would otherwise hard-code and then get wrong. Everything else it needs —
|
||||
// the decorator count, the export list — it derives from the bundle at runtime.
|
||||
await writeFile(
|
||||
path.join(docs, 'meta.js'),
|
||||
`// Generated by scripts/build-docs.mjs — do not edit.\n` +
|
||||
`window.CEREALE_META = ${JSON.stringify({ version: pkg.version, node: pkg.engines.node }, null, 2)};\n`
|
||||
);
|
||||
|
||||
// 3. The compiler errors the page quotes, produced by actually running the compiler. If a
|
||||
// snippet the page calls a compile error ever compiles, this fails the build.
|
||||
const { byCase, problems } = await collectDiagnostics(root);
|
||||
if (problems.length > 0) {
|
||||
console.error('The landing page makes a claim the compiler does not support:\n' +
|
||||
problems.map((p) => ` - ${p}`).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(docs, 'diagnostics.js'),
|
||||
`// Generated by scripts/build-docs.mjs from real tsc output — do not edit.\n` +
|
||||
`window.CEREALE_DIAGNOSTICS = ${JSON.stringify(byCase, null, 2)};\n`
|
||||
);
|
||||
|
||||
// 4. The playground's TypeScript compiler, pinned by package.json rather than by a CDN URL.
|
||||
const babel = path.join(root, 'node_modules/@babel/standalone/babel.min.js');
|
||||
await copyFile(babel, path.join(vendor, 'babel.min.js'));
|
||||
await writeFile(
|
||||
path.join(vendor, 'README.md'),
|
||||
`# Vendored assets\n\n` +
|
||||
`Generated by \`npm run build:docs\`. Do not edit by hand.\n\n` +
|
||||
`- \`babel.min.js\` — @babel/standalone ${JSON.parse(await readFile(path.join(root, 'node_modules/@babel/standalone/package.json'), 'utf8')).version}, ` +
|
||||
`used by the playground to compile TypeScript with standard decorators in the browser. ` +
|
||||
`Vendored rather than loaded from a CDN: the previous page referenced an unpinned unpkg URL, ` +
|
||||
`which silently began serving Babel 8 and broke the playground.\n`
|
||||
);
|
||||
|
||||
console.log(`docs/cereale.js ${await size(path.join(docs, 'cereale.js'))}`);
|
||||
console.log(`docs/meta.js ${await size(path.join(docs, 'meta.js'))} (v${pkg.version})`);
|
||||
console.log(`docs/vendor/babel.min.js ${await size(path.join(vendor, 'babel.min.js'))}`);
|
||||
Reference in New Issue
Block a user