🌾 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:
Claude
2026-08-05 10:03:16 +00:00
parent 0938300477
commit c828f5cfe9
18 changed files with 1970 additions and 272 deletions
+9
View File
@@ -41,3 +41,12 @@ jobs:
node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');" node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');"
- name: Run Demo - name: Run Demo
run: npm run demo run: npm run demo
- name: Published types stand alone
run: npm run check:types
- name: Landing page loads nothing from the network
run: npm run check:docs
- name: Landing page bundle is in sync with src/
run: |
npm run build:docs
git diff --exit-code -- docs/ \
|| (echo "docs/ is stale — run 'npm run build:docs' and commit the result" && exit 1)
+28
View File
@@ -71,6 +71,34 @@ came free with the bookkeeping.
of them. of them.
- The plugin's TypeScript path emitted a `//# sourceMappingURL=` comment pointing at a file - The plugin's TypeScript path emitted a `//# sourceMappingURL=` comment pointing at a file
nobody wrote, which Vite followed and failed to read on every transformed module. nobody wrote, which Vite followed and failed to read on every transformed module.
- `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 — an error inside a dependency, in code the consumer may never call,
that they could not fix from the outside. It now takes a structural `JsonBody`
(`{ json(): Promise<any> }`), which a `Request` still satisfies. The library's own type
tests had been hiding this by enabling both `DOM` and `skipLibCheck`; `npm run check:types`
now compiles a consumer against `dist/` with neither.
### The landing page
`docs/index.html` was rebuilt. Its playground had been dead for some time and said nothing
about it: the page 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. The copy was still selling
the 0.1.0 pitch ("Spring-like"), listed about half the decorators, 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, which
`npm run check:docs` now enforces in CI. The playground runs the real bundled library across
six examples; the reference lists 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
snippet with the real `tsc` and writes the verbatim diagnostic into `docs/diagnostics.js`,
failing the build if a snippet the page calls a compile error ever compiles. A third snippet
that must compile guards against the harness passing vacuously.
### Positioning ### Positioning
+4
View File
@@ -25,6 +25,10 @@ const user = fromJsonSync(User, body); // a real User
user.greet(); // your methods are still there user.greet(); // your methods are still there
``` ```
`docs/index.html` is a self-contained page with an interactive playground that runs this
library in the browser. Build its assets with `npm run build:docs` and open the file — it
loads nothing from the network.
## Where it fits ## Where it fits
The stack Cereale replaces is **class-validator + class-transformer**: The stack Cereale replaces is **class-validator + class-transformer**:
+2 -2
View File
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
// Generated by scripts/build-docs.mjs from real tsc output — do not edit.
window.CEREALE_DIAGNOSTICS = {
"hero": [
{
"code": 1240,
"messages": [
"Unable to resolve signature of property decorator when called as an expression.",
"Argument of type 'ClassFieldDecoratorContext<Order, Date> & { name: \"placedAt\"; private: false; static: false; }' is not assignable to parameter of type 'ClassFieldDecoratorContext<Order, string | null | undefined>'.",
"The types returned by 'access.get(...)' are incompatible between these types.",
"Type 'Date' is not assignable to type 'string'."
],
"line": 12
}
],
"compare": [
{
"code": 1240,
"messages": [
"Unable to resolve signature of property decorator when called as an expression.",
"Argument of type 'ClassFieldDecoratorContext<User, number> & { name: \"age\"; private: false; static: false; }' is not assignable to parameter of type 'ClassFieldDecoratorContext<User, string | null | undefined>'.",
"The types returned by 'access.get(...)' are incompatible between these types.",
"Type 'number' is not assignable to type 'string'."
],
"line": 4
}
],
"instances": [],
"correct": []
};
+819 -263
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
// Generated by scripts/build-docs.mjs — do not edit.
window.CEREALE_META = {
"version": "0.3.0",
"node": ">=20.0.0"
};
+554
View File
@@ -0,0 +1,554 @@
/* cereale landing page. No dependencies, no network. */
(function () {
'use strict';
var meta = window.CEREALE_META || {};
/* ------------------------------------------------------------- theme */
var root = document.documentElement;
var stored = null;
try { stored = localStorage.getItem('cereale-theme'); } catch (e) { /* private mode */ }
if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
var toggle = document.getElementById('theme-toggle');
if (toggle) {
toggle.addEventListener('click', function () {
var current = root.getAttribute('data-theme');
if (!current) {
current = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
var next = current === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
try { localStorage.setItem('cereale-theme', next); } catch (e) { /* ignore */ }
});
}
/* ------------------------------------------------------- facts on tap */
// Read from the bundle rather than written into the page, so they cannot drift.
var exportNames = Object.keys(window.Cereale || {}).filter(function (name) {
return /^[A-Za-z_$][\w$]*$/.test(name);
});
var decoratorCount = exportNames.filter(function (name) {
return /^[A-Z]/.test(name) && typeof window.Cereale[name] === 'function' &&
!/^Json(Mapping|Validation)Error$|^JsonMapper$/.test(name);
}).length;
var countEl = document.getElementById('decorator-count');
if (countEl && decoratorCount) countEl.textContent = String(decoratorCount);
var versionEl = document.getElementById('version-badge');
if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version;
var nodeEl = document.getElementById('node-req');
if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', '');
/* ------------------------------------------------------- highlighting */
var TOKENS = [
['comment', /\/\/[^\n]*|\/\*[\s\S]*?\*\//],
['string', /'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`|\/(?:[^\/\\\n\[]|\\.|\[(?:[^\]\\]|\\.)*\])+\/[gimsuy]*/],
['decorator', /@[A-Za-z_$][\w$]*/],
['keyword', /\b(?:class|extends|implements|interface|const|let|var|function|return|new|await|async|import|export|from|type|enum|if|else|for|of|in|try|catch|throw|instanceof|typeof|null|undefined|true|false|this|readonly|private|public|static|default)\b/],
['type', /\b(?:string|number|boolean|bigint|symbol|Date|any|unknown|void|never|Promise|Array|Map|Set|Error|TypeError|Record|Partial)\b/],
['number', /\b\d[\d_]*(?:\.\d+)?n?\b/]
];
var TOKEN_RE = new RegExp(TOKENS.map(function (t) {
return '(?<' + t[0] + '>' + t[1].source + ')';
}).join('|'), 'g');
function esc(text) {
return text.replace(/[&<>]/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;';
});
}
function highlight(code) {
var out = '', last = 0, match;
TOKEN_RE.lastIndex = 0;
while ((match = TOKEN_RE.exec(code)) !== null) {
out += esc(code.slice(last, match.index));
var kind = '';
for (var key in match.groups) {
if (match.groups[key] !== undefined) { kind = key; break; }
}
out += '<span class="t-' + kind + '">' + esc(match[0]) + '</span>';
last = match.index + match[0].length;
}
return out + esc(code.slice(last));
}
// Wraps each line so a single one can be marked as the line the compiler refuses.
function renderCode(block) {
var source = block.textContent.replace(/\n$/, '');
var errorLine = parseInt(block.getAttribute('data-error-line') || '0', 10);
var plain = block.getAttribute('data-lang') === 'text';
var lines = (plain ? esc(source) : highlight(source)).split('\n');
block.innerHTML = lines.map(function (line, index) {
var cls = index + 1 === errorLine ? 'ln ln--error' : 'ln';
return '<span class="' + cls + '">' + (line || '&nbsp;') + '</span>';
}).join('');
}
Array.prototype.forEach.call(document.querySelectorAll('pre code[data-lang]'), renderCode);
/* ------------------------------------------------- compiler diagnostics */
// Written by scripts/build-docs.mjs from a real `tsc` run over the same snippet, so the
// page cannot quote an error the compiler did not produce. Falls back to the markup.
Array.prototype.forEach.call(document.querySelectorAll('[data-case]'), function (el) {
var found = (window.CEREALE_DIAGNOSTICS || {})[el.getAttribute('data-case')];
if (!found || !found.length) return;
var diagnostic = found[0];
var headline = diagnostic.messages[0];
var leaf = diagnostic.messages[diagnostic.messages.length - 1];
var body = '<strong>ts(' + diagnostic.code + ')</strong> ' + esc(headline);
if (leaf !== headline) body += '<br>&nbsp;&nbsp;…&nbsp;&nbsp;' + esc(leaf);
el.innerHTML = '<span class="mark" aria-hidden="true">✖</span><span>' + body + '</span>';
});
/* ---------------------------------------------------------- reference */
var REFERENCE = [
['Mapping', 'How a field is named and shaped on the JSON side.', [
["@JsonProperty(name)", 'maps this field to a different name in JSON, both directions'],
["@JsonAlias(...names)", 'extra names accepted on input only'],
["@JsonType(() => Class)", 'declares the class a nested field maps to'],
["@JsonPolymorphic<Base>(key, subTypes, options?)", 'picks the concrete subclass from a discriminator'],
["@JsonSerialize(Serializer)", 'custom serializer for this field'],
["@JsonDeserialize(Deserializer)", 'custom deserializer for this field']
]],
['Access control', 'Which direction a field is allowed to travel.', [
["@JsonIgnore()", 'excluded from mapping in both directions'],
["@JsonReadOnly()", 'written to JSON, never populated from it — server-owned ids'],
["@JsonWriteOnly()", 'populated from JSON, never written back — passwords']
]],
['Control flow', 'When the rules on a field apply at all.', [
["@IsOptional()", 'skips the other rules when the value is null or undefined'],
["@ValidateIf(fn)", 'skips every rule when the predicate returns false'],
["@ValidateNested(options?)", 'recursively validates the value, or each element'],
["@Allow()", 'declares a field that carries no rules of its own']
]],
['Type rules', 'What kind of value the field holds.', [
["@IsString()", 'must be a string'],
["@IsNumber()", 'must be a number'],
["@IsInt()", 'must be an integer'],
["@IsBoolean()", 'must be a boolean'],
["@IsBigInt()", 'must be a bigint'],
["@IsDate()", 'must be a valid Date object'],
["@IsObject()", 'must be an object'],
["@IsDefined()", 'must not be null or undefined'],
["@IsNotEmpty()", 'must not be empty'],
["@IsEmpty()", 'must be empty']
]],
['Numbers', 'Constraints on number fields.', [
["@Min(n)", 'must be at least n'],
["@Max(n)", 'must be at most n'],
["@Positive()", 'must be positive'],
["@Negative()", 'must be negative'],
["@IsDivisibleBy(n)", 'must be divisible by n'],
["@IsPort()", 'must be a valid port number — attaches to a number or a numeric string'],
["@IsLatitude()", 'must be a latitude between −90 and 90'],
["@IsLongitude()", 'must be a longitude between −180 and 180']
]],
['Strings', 'Constraints on string fields.', [
["@MinLength(n)", 'must be longer than or equal to n characters'],
["@MaxLength(n)", 'must be shorter than or equal to n characters'],
["@Length(min, max?)", 'must be between min and max characters, or at least min if max is omitted'],
["@Email()", 'must be a valid email'],
["@IsUrl()", 'must be a valid URL'],
["@IsUUID(version?)", 'must be a valid UUID'],
["@IsIP(version?)", 'must be a valid IP address'],
["@Matches(regex)", 'must match the regular expression'],
["@IsAlpha()", 'must contain only letters'],
["@IsAlphanumeric()", 'must contain only letters and numbers'],
["@IsLowercase()", 'must be lowercase'],
["@IsUppercase()", 'must be uppercase'],
["@IsSemVer()", 'must be a valid semantic version'],
["@IsHexColor()", 'must be a hex color'],
["@IsNumberString()", 'must be a number string'],
["@IsDateString()", 'must be a valid ISO 8601 date string'],
["@IsJSON()", 'must be a JSON string'],
["@Contains(text)", 'must contain the substring'],
["@NotContains(text)", 'must not contain the substring'],
["@StartsWith(text)", 'must start with the prefix'],
["@EndsWith(text)", 'must end with the suffix']
]],
['Equality and membership', 'Pinning a field to specific values. These narrow the field’s type too.', [
["@Equals(value)", 'must equal the value'],
["@NotEquals(value)", 'must not equal the value'],
["@IsIn(values)", 'must be one of the listed values'],
["@IsNotIn(values)", 'must not be one of the listed values'],
["@IsEnum(Enum)", 'must be a member of the enum'],
["@IsInstance(Class)", 'must be an instance of the class']
]],
['Arrays', 'Constraints on array fields.', [
["@IsArray()", 'must be an array'],
["@ArrayNotEmpty()", 'must not be empty'],
["@ArrayMinSize(n)", 'must contain at least n elements'],
["@ArrayMaxSize(n)", 'must contain at most n elements'],
["@ArrayUnique(by?)", 'must not contain duplicate values'],
["@ArrayContains(values)", 'must contain all the listed values'],
["@ArrayNotContains(values)", 'must not contain any of the listed values']
]],
['Dates', 'Both take a thunk, so a moving boundary is evaluated per validation.', [
["@MinDate(() => date)", 'must not be earlier than the date'],
["@MaxDate(() => date)", 'must not be later than the date']
]],
['Custom rules', 'When the built-ins run out.', [
["@Validate(constraint, options?)", 'applies a custom validator class or function'],
["defineRule(Class, field, rule)", 'registers a rule from outside a decorator']
]],
['Reading JSON', 'Every one of these has a …Sync twin that needs no await.', [
["toInstance(Class, plain, options?)", 'plain object → validated instance'],
["fromJson(Class, json, options?)", 'JSON string → validated instance'],
["toInstanceArray(Class, plain, options?)", 'array of plain objects → instances'],
["fromJsonArray(Class, json, options?)", 'JSON array string → instances'],
["fromRequest(Class, request, options?)", 'reads and maps a Request body — async only']
]],
['Writing JSON', 'Also available as toPlainSync and toJsonSync.', [
["toPlain(instance, options?)", 'instance → plain object'],
["toJson(instance, options?)", 'instance → JSON string']
]],
['Validating', 'Also available as validateSync and validateOrRejectSync.', [
["validate(instance, options?)", 'returns ValidationError[]'],
["validateOrReject(instance, options?)", 'throws JsonValidationError on failure']
]],
['Errors', 'Turning a ValidationError tree into something you can show.', [
["flattenErrors(errors)", "nested errors → { 'path.to.field': messages }"],
["formatErrors(errors)", 'errors → readable multi-line text'],
["collectErrorMessages(errors)", 'every message as a flat array of strings'],
["JsonValidationError", 'thrown when validation fails'],
["JsonMappingError", 'thrown when a value cannot be mapped at all']
]],
['Configuration', 'Per call, or once via configure().', [
["validate: boolean", 'validate while mapping — default true'],
["namingStrategy: strategy", 'identity (default), camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, or your own function'],
["unknownKeys: policy", 'allow (default), strip, or error'],
["maxDepth: number", 'nesting limit before a JsonMappingError — default 64'],
["configure(options)", 'sets the library-wide defaults']
]],
['Build', 'Only needed on toolchains that transform with oxc.', [
["standardDecorators(options?)", "the Vite and Vitest plugin, from 'cereale/vite'"]
]]
];
var groupsEl = document.getElementById('ref-groups');
var filterEl = document.getElementById('ref-filter');
var refCountEl = document.getElementById('ref-count');
if (groupsEl) {
groupsEl.innerHTML = REFERENCE.map(function (group) {
var items = group[2].map(function (item) {
return '<li data-search="' + esc((item[0] + ' ' + item[1]).toLowerCase()) + '">' +
'<code>' + esc(item[0]) + '</code>' +
'<span class="sum">' + esc(item[1]) + '</span></li>';
}).join('');
return '<div class="ref-group" data-group>' +
'<h3>' + esc(group[0]) + '</h3>' +
'<p class="blurb">' + esc(group[1]) + '</p>' +
'<ul>' + items + '</ul></div>';
}).join('');
var allItems = groupsEl.querySelectorAll('li[data-search]');
var allGroups = groupsEl.querySelectorAll('[data-group]');
var totalEntries = allItems.length;
var applyFilter = function () {
var term = (filterEl ? filterEl.value : '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(allGroups, function (group) {
var visibleInGroup = 0;
Array.prototype.forEach.call(group.querySelectorAll('li[data-search]'), function (li) {
var hit = !term || li.getAttribute('data-search').indexOf(term) !== -1;
li.hidden = !hit;
if (hit) { visibleInGroup++; shown++; }
});
group.hidden = visibleInGroup === 0;
});
if (refCountEl) {
refCountEl.textContent = term
? shown + ' of ' + totalEntries + ' shown'
: totalEntries + ' entries';
}
};
applyFilter();
if (filterEl) filterEl.addEventListener('input', applyFilter);
}
/* --------------------------------------------------------- playground */
var EXAMPLES = [
{
label: 'Mapping',
code: [
"// Rename a field, keep a secret out of the response,",
"// and get a real instance back — methods and all.",
"class User {",
" @JsonProperty('display_name')",
" @IsString() @MinLength(3)",
" displayName!: string;",
"",
" @IsInt() @Min(18)",
" age!: number;",
"",
" // accepted on input, never written back out",
" @JsonWriteOnly() @IsString()",
" password!: string;",
"",
" greet() { return 'Hi ' + this.displayName; }",
"}",
"",
"const body = '{\"display_name\":\"Ada\",\"age\":36,' +",
" '\"password\":\"hunter2\"}';",
"const user = fromJsonSync(User, body);",
"",
"console.log('a real User:', user instanceof User);",
"console.log('its methods survived:', user.greet());",
"console.log('back out again:', toJsonSync(user));"
].join('\n')
},
{
label: 'Validation errors',
code: [
"class Signup {",
" @IsString() @MinLength(3) name!: string;",
" @IsInt() @Min(18) age!: number;",
" @Email() email!: string;",
"}",
"",
"// Map without validating so we can inspect the damage ourselves.",
"const payload = { name: 'Bo', age: 15, email: 'nope' };",
"const bad = toInstanceSync(Signup, payload, { validate: false });",
"",
"console.log(formatErrors(validateSync(bad)));",
"console.log('');",
"console.log('as a map for a form:', flattenErrors(validateSync(bad)));",
"",
"// Or let it throw, which is the default.",
"try {",
" fromJsonSync(Signup, JSON.stringify(payload));",
"} catch (error) {",
" console.log('');",
" console.log('threw:', error.name);",
"}"
].join('\n')
},
{
label: 'Nested',
code: [
"class Line {",
" @IsString() sku!: string;",
" @IsInt() @Min(1) qty!: number;",
"}",
"",
"class Order {",
" @IsString() ref!: string;",
"",
" @ValidateNested({ each: true })",
" @JsonType(() => Line)",
" lines!: Line[];",
"}",
"",
"const order = toInstanceSync(Order,",
" { ref: 'A-1', lines: [",
" { sku: 'grain', qty: 2 },",
" { sku: 'oat', qty: 0 }, // ← the one that fails",
" ] },",
" { validate: false });",
"",
"console.log('nested items are real:', order.lines[0] instanceof Line);",
"console.log('errors keep their path:', flattenErrors(validateSync(order)));"
].join('\n')
},
{
label: 'Polymorphism',
code: [
"class Media { @IsString() title!: string; }",
"",
"class Movie extends Media {",
" @IsInt() @Min(1) duration!: number;",
" hours() { return (this.duration / 60).toFixed(2); }",
"}",
"class Song extends Media { @IsString() artist!: string; }",
"",
"class Playlist {",
" @JsonPolymorphic('type', [",
" { value: Movie, name: 'movie' },",
" { value: Song, name: 'song' },",
" ])",
" @ValidateNested({ each: true })",
" items!: Media[];",
"}",
"",
"const list = toInstanceSync(Playlist, { items: [",
" { type: 'movie', title: 'Inception', duration: 148 },",
" { type: 'song', title: 'Reckoner', artist: 'Radiohead' },",
"] });",
"",
"console.log('first is a Movie:', list.items[0] instanceof Movie);",
"console.log('and it has behaviour:', list.items[0].hours() + ' hours');",
"console.log('second is a Song:', list.items[1].constructor.name);"
].join('\n')
},
{
label: 'Naming',
code: [
"// One setting instead of a @JsonProperty on every field.",
"class Account {",
" @IsString() firstName!: string;",
" @IsString() lastName!: string;",
" @IsString() emailAddress!: string;",
"}",
"",
"const options = { namingStrategy: 'snake_case' };",
"",
"const account = toInstanceSync(Account,",
" { first_name: 'Ada', last_name: 'Lovelace',",
" email_address: 'ada@example.com' },",
" options);",
"",
"console.log('read:', account.firstName, account.lastName);",
"console.log('written:', toPlainSync(account, options));"
].join('\n')
},
{
label: 'Nothing fails quietly',
code: [
"// Each of these used to succeed and lose your data, or fail somewhere",
"// unrelated. Run it and read what comes back instead.",
"",
"class Basket { items: any; }",
"",
"const basket = new Basket();",
"basket.items = new Map([['grain', 2]]);",
"",
"try { toPlainSync(basket, { validate: false }); }",
"catch (error) { console.log(error.name + ': ' + error.message); }",
"",
"console.log('');",
"",
"class Node { name = 'root'; child: any = null; parent: any = null; }",
"const root = new Node(), child = new Node();",
"child.name = 'child'; child.parent = root; root.child = child;",
"",
"try { toPlainSync(root, { validate: false }); }",
"catch (error) { console.log(error.name + ': ' + error.message); }",
"",
"console.log('');",
"",
"class Strict { @IsString() a!: string; }",
"try { toInstanceSync(Strict, { a: 'x', b: 'y' }, { unknownKeys: 'error' }); }",
"catch (error) { console.log(error.name + ': ' + error.message); }"
].join('\n')
}
];
var editor = document.getElementById('editor');
var output = document.getElementById('output');
var runBtn = document.getElementById('run-btn');
var tabsEl = document.getElementById('tabs');
var statusEl = document.getElementById('pg-status');
if (editor && output && runBtn && tabsEl) {
tabsEl.innerHTML = EXAMPLES.map(function (example, index) {
return '<button class="tab" type="button" data-example="' + index + '"' +
' aria-pressed="' + (index === 0 ? 'true' : 'false') + '">' + esc(example.label) + '</button>';
}).join('');
var selectExample = function (index) {
Array.prototype.forEach.call(tabsEl.querySelectorAll('[data-example]'), function (tab) {
tab.setAttribute('aria-pressed', tab.getAttribute('data-example') === String(index) ? 'true' : 'false');
});
editor.value = EXAMPLES[index].code;
// The compiler is only fetched on the first run, so the pane starts empty; say why
// rather than showing a blank box.
output.innerHTML = '<span class="out-dim">Press Run (or ' +
(/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl') +
'+Enter) to compile this and execute it\nagainst the bundled library.</span>';
if (statusEl) statusEl.textContent = '';
};
tabsEl.addEventListener('click', function (event) {
var tab = event.target.closest('[data-example]');
if (tab) selectExample(parseInt(tab.getAttribute('data-example'), 10));
});
// Tab indents rather than escaping the editor; Escape then Tab still moves focus out.
var tabEscapes = false;
editor.addEventListener('keydown', function (event) {
if (event.key === 'Escape') { tabEscapes = true; return; }
if (event.key !== 'Tab' || tabEscapes) { tabEscapes = false; return; }
event.preventDefault();
var start = editor.selectionStart, end = editor.selectionEnd;
editor.value = editor.value.slice(0, start) + ' ' + editor.value.slice(end);
editor.selectionStart = editor.selectionEnd = start + 2;
});
var write = function (text, cls) {
var line = document.createElement('span');
if (cls) line.className = cls;
line.textContent = text + '\n';
output.appendChild(line);
};
var show = function (value) {
if (typeof value === 'string') return value;
if (value instanceof Error) return value.name + ': ' + value.message;
try { return JSON.stringify(value, null, 2); } catch (e) { return String(value); }
};
// The compiler is ~540KB gzipped, so it is fetched on the first run rather than
// charged to everyone who scrolls past.
var compiler = null;
var loadCompiler = function () {
return compiler || (compiler = new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'vendor/babel.min.js';
script.onload = function () { resolve(window.Babel); };
script.onerror = function () { reject(new Error('Could not load the compiler (vendor/babel.min.js).')); };
document.head.appendChild(script);
}));
};
var running = false;
var run = function () {
if (running) return;
running = true;
runBtn.disabled = true;
output.textContent = '';
if (statusEl) statusEl.textContent = window.Babel ? 'running…' : 'loading compiler…';
loadCompiler().then(function (Babel) {
if (statusEl) statusEl.textContent = 'running…';
// TypeScript is stripped first, then decorators are lowered: the other order
// leaves the decorator transform's initialisers on a `field!: T` declaration,
// which the TypeScript plugin then rejects.
var compiled = Babel.transform(editor.value, {
filename: 'playground.ts',
plugins: [['transform-typescript', {}], ['proposal-decorators', { version: '2023-11' }]]
}).code;
var sandboxConsole = {
log: function () {
write(Array.prototype.map.call(arguments, show).join(' '));
}
};
var body = 'return (async () => {\n' + compiled + '\n})();';
var fn = Function.apply(null, ['console'].concat(exportNames, [body]));
return fn.apply(null, [sandboxConsole].concat(exportNames.map(function (name) {
return window.Cereale[name];
})));
}).then(function () {
if (!output.textContent) write('(the code ran, but logged nothing)', 'out-dim');
}).catch(function (error) {
write((error && error.name === 'SyntaxError' ? '' : '') + show(error), 'out-err');
}).then(function () {
running = false;
runBtn.disabled = false;
if (statusEl) statusEl.textContent = '';
});
};
runBtn.addEventListener('click', run);
editor.addEventListener('keydown', function (event) {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); run(); }
});
selectExample(0);
}
})();
+5
View File
@@ -0,0 +1,5 @@
# Vendored assets
Generated by `npm run build:docs`. Do not edit by hand.
- `babel.min.js` — @babel/standalone 8.0.4, 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.
+4
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -9,6 +9,7 @@
"version": "0.3.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/standalone": "^8.0.4",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@swc/core": "^1.15.47", "@swc/core": "^1.15.47",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
@@ -61,6 +62,16 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/standalone": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-8.0.4.tgz",
"integrity": "sha512-z8WgyJCfEl7qGAfJJksICOL5zQPpfwa6Yew+91Txf1k3xuDtlDdDe5GX2QdIhgx0HD7oYOCLoF8Y6CwNdawJwQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.8", "version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+7 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "cereale", "name": "cereale",
"version": "0.3.0", "version": "0.3.0",
"description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.", "description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
"type": "module", "type": "module",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js", "module": "./dist/esm/index.js",
@@ -27,7 +27,7 @@
], ],
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", "build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
"build:docs": "esbuild src/index.ts --bundle --format=iife --global-name=Cereale --minify --tsconfig=tsconfig.json --outfile=docs/cereale.js", "build:docs": "node scripts/build-docs.mjs",
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts", "demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -35,8 +35,10 @@
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"verify": "npm run type-check && npm run lint && npm run test && npm run build", "verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs",
"prepublishOnly": "npm run verify" "prepublishOnly": "npm run verify",
"check:docs": "node scripts/check-docs.mjs",
"check:types": "node scripts/check-types.mjs"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -64,6 +66,7 @@
}, },
"homepage": "https://github.com/Avalon-Vanguard/cereale#readme", "homepage": "https://github.com/Avalon-Vanguard/cereale#readme",
"devDependencies": { "devDependencies": {
"@babel/standalone": "^8.0.4",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@swc/core": "^1.15.47", "@swc/core": "^1.15.47",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
+81
View File
@@ -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'))}`);
+78
View File
@@ -0,0 +1,78 @@
/**
* Guards the two properties the landing page silently lost before.
*
* 1. It must load nothing from the network. The previous page pulled Tailwind, CodeMirror and
* Babel from three CDNs, and its playground died without a sound the day the unpinned
* `@babel/standalone` URL started serving Babel 8, whose plugin list no longer had the
* plugin the page asked for. Nobody noticed, because nothing on the page said so.
* 2. The bundle the playground runs must match the library source. It is built from `src/`,
* so a change there leaves the page demonstrating a version that no longer exists.
*
* Ordinary <a href> links out are fine — a link is not a subresource.
*/
import { readFile, readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const docs = path.join(root, 'docs');
const failures = [];
/** Subresource references — the things a browser fetches without being clicked. */
const SUBRESOURCES = [
[/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'script src'],
[/<link\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi, 'link href'],
[/<(?:img|iframe|video|audio|source|embed)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'media src'],
[/@import\s+(?:url\()?["']([^"']+)["']/gi, 'css @import'],
[/url\(\s*["']?(https?:\/\/[^)"']+)/gi, 'css url()'],
];
const isRemote = (url) => /^(?:https?:)?\/\//i.test(url);
const html = (await readdir(docs)).filter((name) => name.endsWith('.html'));
if (html.length === 0) failures.push('docs/ contains no HTML page');
for (const name of html) {
const source = await readFile(path.join(docs, name), 'utf8');
for (const [pattern, kind] of SUBRESOURCES) {
for (const match of source.matchAll(pattern)) {
if (isRemote(match[1])) failures.push(`docs/${name}: remote ${kind} — ${match[1]}`);
}
}
// A fetch to a CDN would not be caught by the markup scan.
for (const match of source.matchAll(/\b(?:fetch|importScripts)\(\s*["'`](https?:\/\/[^"'`]+)/gi)) {
failures.push(`docs/${name}: remote fetch — ${match[1]}`);
}
}
for (const name of (await readdir(docs)).filter((f) => f.endsWith('.js'))) {
const source = await readFile(path.join(docs, name), 'utf8');
for (const match of source.matchAll(/\.src\s*=\s*["'`](https?:\/\/[^"'`]+)/gi)) {
failures.push(`docs/${name}: loads a remote script — ${match[1]}`);
}
for (const match of source.matchAll(/\bfetch\(\s*["'`](https?:\/\/[^"'`]+)/gi)) {
failures.push(`docs/${name}: remote fetch — ${match[1]}`);
}
}
// The playground compiles against whatever is in the bundle, so a stale bundle means the
// page demonstrates a library that no longer exists.
const bundle = await readFile(path.join(docs, 'cereale.js'), 'utf8').catch(() => null);
if (bundle === null) {
failures.push('docs/cereale.js is missing — run `npm run build:docs`');
} else {
// Spot-check that the exports the page relies on actually made it into the bundle.
for (const name of ['toInstanceSync', 'toPlainSync', 'flattenErrors', 'JsonMappingError', 'IsString']) {
if (!bundle.includes(name)) failures.push(`docs/cereale.js does not export ${name} — rebuild it`);
}
}
const babel = path.join(docs, 'vendor/babel.min.js');
await readFile(babel).catch(() => failures.push('docs/vendor/babel.min.js is missing — run `npm run build:docs`'));
if (failures.length > 0) {
console.error('docs check failed:\n' + failures.map((f) => ` - ${f}`).join('\n'));
process.exit(1);
}
console.log(`docs check passed — ${html.length} page(s), no network dependencies.`);
+108
View File
@@ -0,0 +1,108 @@
/**
* Compiles a minimal consumer against the built type declarations, in the least forgiving
* configuration a real project might have: no `skipLibCheck`, no `DOM` lib, no `types`.
*
* A zero-dependency library's public types have to stand on their own. `fromRequest` used to
* be declared as taking the global `Request`, so cereale's own `.d.ts` raised
* `Cannot find name 'Request'` in any project whose `lib` and `types` did not happen to
* supply it — an error inside a dependency, in code the consumer may never call, that they
* cannot fix from the outside. The library's own test suite hid it by enabling both.
*
* Run after `npm run build`, since it checks what is actually published.
*/
import ts from 'typescript';
import { mkdtemp, rm, writeFile, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const types = path.join(root, 'dist/esm/index.d.ts');
try {
await access(types);
} catch {
console.error('dist/esm/index.d.ts is missing — run `npm run build` first.');
process.exit(1);
}
const CONSUMER = `
import {
IsString, MinLength, IsInt, Min, IsDate, JsonProperty, JsonWriteOnly,
ValidateNested, JsonType, fromJsonSync, toPlainSync, validateSync, fromRequest,
} from ${JSON.stringify(types.replace(/\.d\.ts$/, '.js'))};
class Address {
@IsString() city!: string;
}
export class User {
@JsonProperty('display_name')
@IsString() @MinLength(2)
displayName!: string;
@IsInt() @Min(0)
age!: number;
@IsDate()
joinedAt!: Date;
@JsonWriteOnly() @IsString()
password!: string;
@ValidateNested() @JsonType(() => Address)
address!: Address;
greet(): string { return 'Hi ' + this.displayName; }
}
export function use(body: string) {
const user = fromJsonSync(User, body);
return [user.greet(), toPlainSync(user), validateSync(user)];
}
// Declared structurally, so this must type-check without the DOM or Node globals.
export function fromAnythingWithJson(source: { json(): Promise<unknown> }) {
return fromRequest(User, source);
}
`;
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-consumer-'));
try {
const file = path.join(dir, 'consumer.ts');
await writeFile(file, CONSUMER);
const program = ts.createProgram([file], {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
// Deliberately bare: no DOM, no node, and lib checking left on.
lib: ['lib.esnext.d.ts', 'lib.esnext.decorators.d.ts'],
types: [],
strict: true,
strictPropertyInitialization: false,
skipLibCheck: false,
noEmit: true,
});
const diagnostics = [
...program.getSemanticDiagnostics(),
...program.getSyntacticDiagnostics(),
...program.getGlobalDiagnostics(),
];
if (diagnostics.length > 0) {
console.error(
'cereale\'s published types do not stand alone. A consumer without DOM lib or @types/node sees:\n' +
diagnostics.slice(0, 12).map((d) => {
const where = d.file ? `${path.basename(d.file.fileName)}:${d.file.getLineAndCharacterOfPosition(d.start ?? 0).line + 1} ` : '';
return ` - ${where}TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`;
}).join('\n')
);
process.exit(1);
}
console.log('type check passed — published types resolve with no DOM lib and no @types/node.');
} finally {
await rm(dir, { recursive: true, force: true });
}
+210
View File
@@ -0,0 +1,210 @@
/**
* Runs the real TypeScript compiler over the snippets the landing page quotes, and writes
* the verbatim diagnostics into docs/diagnostics.js.
*
* The page's central claim is that a rule which does not fit its field does not compile. The
* honest way to show that is not to type a plausible-looking error into the HTML — it is to
* compile the snippet and print whatever the compiler said. If a snippet marked `rejected`
* ever starts compiling, or a snippet marked `compiles` stops, the build fails here rather
* than the page quietly going on claiming something that is no longer true.
*/
import ts from 'typescript';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
/** Each case is compiled against the real library, not a stub. */
export const CASES = [
{
id: 'hero',
expect: 'rejected',
// Kept character-for-character in step with the hero block in docs/index.html.
source: `import { JsonProperty, IsString, Matches, IsInt, Min, fromJsonSync } from '../src/index.js';
declare const body: string;
class Order {
@JsonProperty('order_ref')
@IsString() @Matches(/^[A-Z]-\\d+$/)
ref!: string;
@IsInt() @Min(1)
quantity!: number;
@IsString()
placedAt!: Date;
total(): number { return this.quantity * 9.99; }
}
const order = fromJsonSync(Order, body);
order.total();
`,
},
{
id: 'compare',
expect: 'rejected',
source: `import { IsString } from '../src/index.js';
export class User {
@IsString()
age!: number;
}
`,
},
{
id: 'instances',
expect: 'compiles',
// The "What you get back" section. It is here because an earlier draft showed
// `list.items[0].hours()` on a `Media[]`, which tsc rejects with TS2339 — TypeScript that
// the compiler refuses, on a page whose whole argument is that the compiler is the authority.
source: `import { IsString, IsInt, Min, JsonPolymorphic, ValidateNested, fromJsonSync } from '../src/index.js';
declare const body: string;
class Media {
@IsString() title!: string;
}
class Movie extends Media {
@IsInt() @Min(1) duration!: number;
hours() { return this.duration / 60; }
}
class Song extends Media {
@IsString() artist!: string;
}
class Playlist {
@JsonPolymorphic<Media>('type', [
{ value: Movie, name: 'movie' },
{ value: Song, name: 'song' },
])
@ValidateNested({ each: true })
items!: Media[];
}
const list = fromJsonSync(Playlist, body);
const first = list.items[0];
first instanceof Movie;
if (first instanceof Movie) {
first.hours();
}
`,
},
{
id: 'correct',
expect: 'compiles',
// The same class with the rule that actually fits, so a broken harness cannot make the
// two cases above "pass" by failing everything.
source: `import { JsonProperty, IsString, Matches, IsInt, Min, IsDate, fromJsonSync } from '../src/index.js';
declare const body: string;
class Order {
@JsonProperty('order_ref')
@IsString() @Matches(/^[A-Z]-\\d+$/)
ref!: string;
@IsInt() @Min(1)
quantity!: number;
@IsDate()
placedAt!: Date;
total(): number { return this.quantity * 9.99; }
}
const order = fromJsonSync(Order, body);
order.total();
`,
},
];
/** Compiles every case in one program and returns its diagnostics, keyed by case id. */
export async function collectDiagnostics(root) {
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-diagnostics-'));
try {
const files = new Map();
for (const testCase of CASES) {
const file = path.join(dir, `${testCase.id}.ts`);
// The snippets import '../src/index.js' relative to a sibling of src/, so they are
// written one directory below the repo root.
const target = path.join(root, '.diagnostics', `${testCase.id}.ts`);
files.set(testCase.id, target);
await writeFile(file, testCase.source);
}
// Write into the repo so that '../src/index.js' resolves the way it does for a consumer.
const scratch = path.join(root, '.diagnostics');
await rm(scratch, { recursive: true, force: true });
const { mkdir } = await import('node:fs/promises');
await mkdir(scratch, { recursive: true });
for (const testCase of CASES) {
await writeFile(files.get(testCase.id), testCase.source);
}
const configPath = path.join(root, 'tsconfig.json');
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, root);
const program = ts.createProgram([...files.values()], {
...parsed.options,
noEmit: true,
rootDir: root,
outDir: undefined,
declaration: false,
declarationMap: false,
sourceMap: false,
});
const all = [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()];
const byCase = {};
const problems = [];
for (const testCase of CASES) {
const file = files.get(testCase.id);
const mine = all.filter((d) => d.file && path.resolve(d.file.fileName) === path.resolve(file));
if (testCase.expect === 'rejected' && mine.length === 0) {
problems.push(`case "${testCase.id}" was expected to be rejected by tsc, but it compiled. ` +
'The landing page claims this is a compile error — either the claim or the library is wrong.');
}
if (testCase.expect === 'compiles' && mine.length > 0) {
problems.push(`case "${testCase.id}" was expected to compile, but tsc reported: ` +
ts.flattenDiagnosticMessageText(mine[0].messageText, ' '));
}
byCase[testCase.id] = mine.map((diagnostic) => {
const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
return {
code: diagnostic.code,
// The chain, flattened one message per level, so the page can show the headline
// and the "Type X is not assignable to type Y" leaf without inventing either.
messages: flattenChain(diagnostic.messageText),
line: line + 1,
};
});
}
// Diagnostics anywhere else mean the harness itself is broken.
const stray = all.filter((d) => !d.file || ![...files.values()].some((f) => path.resolve(d.file.fileName) === path.resolve(f)));
if (stray.length > 0) {
problems.push(`the diagnostics harness produced ${stray.length} error(s) outside the cases, ` +
`starting with: ${ts.flattenDiagnosticMessageText(stray[0].messageText, ' ')}`);
}
await rm(scratch, { recursive: true, force: true });
return { byCase, problems };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function flattenChain(messageText) {
if (typeof messageText === 'string') return [messageText];
const out = [];
let node = messageText;
while (node) {
out.push(node.messageText);
node = node.next && node.next[0];
}
return out;
}
+3 -2
View File
@@ -21,8 +21,9 @@ function typeCheck(body: string): { ok: boolean; output: string } {
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
compilerOptions: { compilerOptions: {
target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext',
// DOM supplies URL/Request, which the library's own signatures reference. A real // These cases compile the library's *source*, whose implementations call `new URL()`.
// consumer has these from either DOM or @types/node. // Nothing in the published signatures needs DOM — scripts/check-types.mjs compiles a
// consumer against dist/ with no DOM lib and no @types/node to keep it that way.
lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true, lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true,
strictPropertyInitialization: false, noEmit: true, skipLibCheck: true, strictPropertyInitialization: false, noEmit: true, skipLibCheck: true,
}, },
+13 -1
View File
@@ -50,6 +50,18 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
*/ */
export const REDACTED = '[redacted]'; export const REDACTED = '[redacted]';
/**
* Anything that can hand back a parsed JSON body — a `Request`, a `Response`, or a test double.
*
* Declared structurally rather than as the global `Request`, which does not exist unless the
* consumer's `lib` includes DOM or their `types` includes node. Naming the global here put a
* `Cannot find name 'Request'` error inside cereale's own published `.d.ts`, in a project that
* may not call `fromRequest` at all and cannot fix it from the outside.
*/
export interface JsonBody {
json(): Promise<any>;
}
// --- Internal Engine --- // --- Internal Engine ---
interface SerializeContext { interface SerializeContext {
@@ -1100,7 +1112,7 @@ function parseJson(json: string): any {
*/ */
export async function fromRequest<T>( export async function fromRequest<T>(
clazz: ClassConstructor<T>, clazz: ClassConstructor<T>,
request: Request, request: JsonBody,
options?: TransformOptions options?: TransformOptions
): Promise<T> { ): Promise<T> {
let plain: any; let plain: any;