0.4.0 published, so the three places that said it had not stopped being true: the README install section, the landing page's install panel, and the "what cereale is not" item. All three now say `npm install cereale`, and the last becomes a limitation that is actually still true — it is 0.x, where a minor bump is allowed to break you. An npm version badge joins the row, tinted the same brand brown as the rest. It was held back deliberately while the package did not exist, because a badge that renders "not found" is worse than no badge. page.js loses the install-shell substitution: it rewrote the tarball filename in a code block that no longer exists. --- release.yml: trusted publishing --- npm exchanges the workflow's short-lived GitHub identity token for a publish token scoped to this package, so no long-lived npm token has to exist. The header documents exactly what to enter on npmjs.com, including the two fields npm checks against the OIDC claims and refuses on mismatch: the workflow filename must match this file, and Environment must stay blank while the job declares none. The find that matters: Node 22 bundles npm 10.x, which has no OIDC code at all. Verified by unpacking the CLI — lib/utils/oidc.js is absent in 11.4.2 and present in 11.5.0. Since npm's OIDC step is deliberately non-throwing, an old CLI would have skipped trusted publishing in silence and fallen back to token auth while appearing to work. So the workflow raises npm and then asserts the version, rather than assuming it. NPM_TOKEN stays as a fallback for the same non-throwing reason: this can land before the registry side is configured, and nothing breaks. Delete the secret once a real run shows OIDC working. --provenance stays explicit. Under OIDC npm enables it itself for a public repo, but only when the flag is left at its default (config.isDefault check in oidc.js), so passing it just skips that auto-enable and lands in the same place — while remaining the only thing that produces an attestation on the token path. actionlint clean; the version guard tested against 10.9.7, 11.4.2, 11.5.0 and 12.0.2; the page re-rendered with no errors and no "not on npm" text left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
1024 lines
50 KiB
HTML
1024 lines
50 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>cereale — validated domain objects, not validated data</title>
|
|
<meta name="description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks the validation rules against the fields they are attached to, at compile time.">
|
|
<meta name="color-scheme" content="light dark">
|
|
<link rel="canonical" href="https://avalon-vanguard.github.io/cereale/">
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 18'><rect x='4' width='2' height='18' fill='%23a0784a' opacity='.35'/><rect y='2' width='10' height='2' fill='%23a0784a'/><rect y='6' width='10' height='2' fill='%23a0784a'/><rect y='10' width='10' height='2' fill='%23a0784a'/><rect y='14' width='10' height='2' fill='%23a0784a'/></svg>">
|
|
|
|
<!-- Link previews. No og:image: a preview card with a broken image is worse than one
|
|
without, and there is no artwork to point at yet. -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://avalon-vanguard.github.io/cereale/">
|
|
<meta property="og:site_name" content="cereale">
|
|
<meta property="og:title" content="cereale — validated domain objects, not validated data">
|
|
<meta property="og:description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks every rule against the field it is attached to, at compile time.">
|
|
<meta name="twitter:card" content="summary">
|
|
<meta name="twitter:title" content="cereale — validated domain objects, not validated data">
|
|
<meta name="twitter:description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Rules are checked against fields at compile time.">
|
|
|
|
<style>
|
|
/* ---------------------------------------------------------------- tokens */
|
|
/* Chaff: one paper, one ink, one repeated 2px mark. The accent is rye-brown and
|
|
deliberately NOT wheat-gold — gold is both the cliche and a collision with --warn,
|
|
which already owns amber. Two amber families would make "our brand" and "needs
|
|
your attention" the same colour.
|
|
|
|
Every token below that differs between themes must be written in THREE places: this
|
|
block (the light palette), the prefers-color-scheme block, and [data-theme="dark"].
|
|
The dark media block is guarded with :not([data-theme="light"]), so an explicit light
|
|
toggle falls straight through to these values — there is no light copy to keep in sync. */
|
|
:root {
|
|
--bg: #faf7f0;
|
|
--bg-raised: #fffdf7;
|
|
--bg-sunken: #f2ede1;
|
|
--text: #1a1712;
|
|
--text-muted: #5c5346;
|
|
--text-faint: #6e6353;
|
|
--border: #e6dfd1;
|
|
--border-strong: #cfc5b2;
|
|
/* Icon-only controls need 3:1 against the page (WCAG 1.4.11); --border-strong is
|
|
decorative and sits well below it. .icon-btn and .btn-secondary use this instead. */
|
|
--border-ui: #8e8269;
|
|
--accent: #8a6238;
|
|
--accent-text: #7a5530;
|
|
--accent-soft: #f1e9dc;
|
|
/* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent
|
|
for legibility against the page, which then leaves white text on it below AA. */
|
|
--accent-solid: #6b4a28;
|
|
--on-accent: #fffdf7;
|
|
--bad: #a82820;
|
|
--bad-soft: #faebe7;
|
|
--ok: #256b3d;
|
|
--ok-soft: #e8f2e9;
|
|
--warn: #8a5a05;
|
|
--warn-soft: #fbf1dc;
|
|
|
|
/* Code surfaces stay dark in both themes: one syntax palette, always legible.
|
|
Warmed off the blue-grey axis so the slab does not read cold against oat paper. */
|
|
--code-bg: #14120c;
|
|
--code-bg-raised: #1c190f;
|
|
--code-border: #2e2818;
|
|
--code-text: #e2dccb;
|
|
--code-faint: #9a9280;
|
|
/* The accent is unreadable on the dark slab in light mode, so focus rings drawn on a
|
|
code surface get their own token. Without it #editor:focus measures 2.86:1 — a live
|
|
1.4.11 failure on the shipped page. This measures 6.96:1. */
|
|
--accent-on-code: #c9962f;
|
|
/* The error line is the page's one visual device; these were three loose literals. */
|
|
--err-line: #ff6a7e;
|
|
--err-text: #ffb3c0;
|
|
--t-comment: #8a93b8;
|
|
--t-string: #b8e08a;
|
|
--t-keyword: #c792ea;
|
|
--t-decorator: #82aaff;
|
|
--t-type: #ffcb6b;
|
|
--t-number: #f78c6c;
|
|
|
|
/* The whole ornament: a 2px mark every 9px. var() resolves against the winning
|
|
cascaded value, so --accent changing with the theme retints these automatically —
|
|
they belong on this block only, never in the three below. */
|
|
--grain-mark: 2px;
|
|
--grain-pitch: 9px;
|
|
--rule-x: repeating-linear-gradient(90deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
|
--rule-y: repeating-linear-gradient(180deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
|
--rule-code: repeating-linear-gradient(90deg, var(--code-faint) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
|
|
|
--radius: 10px;
|
|
--radius-lg: 16px;
|
|
--shadow: 0 1px 2px rgba(40, 30, 14, .05), 0 8px 24px -12px rgba(40, 30, 14, .20);
|
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
--measure: 64ch;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--bg: #12100b;
|
|
--bg-raised: #1a1710;
|
|
--bg-sunken: #16130d;
|
|
--text: #ede7da;
|
|
--text-muted: #aba292;
|
|
--text-faint: #9b9280;
|
|
--border: #29241a;
|
|
--border-strong: #3b3427;
|
|
--border-ui: #736a59;
|
|
--accent: #c4a97c;
|
|
--accent-text: #d8be94;
|
|
--accent-soft: #2a2115;
|
|
--accent-solid: #d8be94;
|
|
--on-accent: #17120a;
|
|
--bad: #f2867e;
|
|
--bad-soft: #2b1512;
|
|
--ok: #6fc98c;
|
|
--ok-soft: #12241a;
|
|
--warn: #e9b45a;
|
|
--warn-soft: #261d0c;
|
|
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
|
}
|
|
}
|
|
|
|
/* An explicit light choice: the guarded media block above no longer matches, so the
|
|
bare :root palette wins on its own. Only the UA hint needs stating. */
|
|
:root[data-theme="light"] {
|
|
color-scheme: light;
|
|
}
|
|
:root[data-theme="dark"] {
|
|
--bg: #12100b; --bg-raised: #1a1710; --bg-sunken: #16130d;
|
|
--text: #ede7da; --text-muted: #aba292; --text-faint: #9b9280;
|
|
--border: #29241a; --border-strong: #3b3427; --border-ui: #736a59;
|
|
--accent: #c4a97c; --accent-text: #d8be94; --accent-soft: #2a2115;
|
|
--accent-solid: #d8be94; --on-accent: #17120a;
|
|
--bad: #f2867e; --bad-soft: #2b1512; --ok: #6fc98c; --ok-soft: #12241a;
|
|
--warn: #e9b45a; --warn-soft: #261d0c;
|
|
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
|
color-scheme: dark;
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- base */
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; scroll-padding-top: 5rem; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
html { scroll-behavior: auto; }
|
|
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
|
|
}
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--sans);
|
|
font-size: 16px;
|
|
line-height: 1.65;
|
|
-webkit-font-smoothing: antialiased;
|
|
overflow-x: hidden;
|
|
}
|
|
/* 680 and 640 are fiction on a non-variable system stack — they round to 700. And -.035em
|
|
is generic-landing-page tracking; it is most of what made this look like every other
|
|
dev-tool site. */
|
|
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 700; letter-spacing: -.012em; }
|
|
h1 { font-size: clamp(2rem, 1.35rem + 2.8vw, 3rem); line-height: 1.14; letter-spacing: -.018em; }
|
|
h2 { font-size: clamp(1.4rem, 1.1rem + 1.3vw, 1.85rem); letter-spacing: -.012em; }
|
|
h3 { font-size: 1.0625rem; letter-spacing: -.008em; }
|
|
p { margin: 0 0 1rem; }
|
|
/* Links keep body colour and are marked by a rule instead. On a palette this warm,
|
|
--accent-text and --text-muted sit 1.14:1 apart — colour alone would lose them. */
|
|
a { color: var(--text); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: .16em; }
|
|
a:hover { color: var(--accent-text); text-decoration-color: currentColor; }
|
|
code, kbd, pre { font-family: var(--mono); }
|
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
|
|
|
.wrap { width: min(1120px, 100% - 2.5rem); margin-inline: auto; }
|
|
/* Grid and flex children default to min-width:auto, so one wide code block stretches its
|
|
whole column past the viewport and takes the prose next to it along. */
|
|
.hero-grid > *, .grid > *, .compare > *, .pg-grid > *, .ref-groups > *, .foot-grid > * { min-width: 0; }
|
|
section { padding-block: clamp(3rem, 6vw, 5.5rem); }
|
|
.lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); }
|
|
.eyebrow {
|
|
font-family: var(--mono);
|
|
font-size: .6875rem; font-weight: 500; letter-spacing: .14em; text-transform: uppercase;
|
|
color: var(--accent-text); margin: 0 0 .7rem;
|
|
}
|
|
.section-head { margin-bottom: 2.25rem; }
|
|
/* The ornament, use 1 of 3: a section begins. The hero is the page beginning, not a
|
|
section, so it deliberately has no .section-head and no mark. */
|
|
.section-head::before {
|
|
content: ""; display: block; width: 4.5rem; height: 3px;
|
|
margin-bottom: .95rem; background-image: var(--rule-x);
|
|
}
|
|
.skip {
|
|
position: absolute; left: -9999px; top: 0; z-index: 100;
|
|
background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
|
|
}
|
|
.skip:focus { left: 0; }
|
|
.skip:hover { color: var(--on-accent); }
|
|
.sr-only {
|
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ nav */
|
|
header.nav {
|
|
position: sticky; top: 0; z-index: 50;
|
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
backdrop-filter: saturate(150%) blur(10px);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; }
|
|
.brand { display: flex; align-items: center; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; }
|
|
.brand .mark { color: var(--accent); flex: none; }
|
|
.badge {
|
|
font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px;
|
|
background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
|
}
|
|
.nav-links { margin-left: auto; display: flex; align-items: center; gap: .35rem; }
|
|
.nav-links a {
|
|
color: var(--text-muted); text-decoration: none; padding: .4rem .6rem; border-radius: var(--radius); font-size: .9375rem; font-weight: 500;
|
|
}
|
|
.nav-links a:hover { color: var(--text); background: var(--bg-sunken); }
|
|
.nav-links a.ghost { border: 1px solid var(--border-strong); }
|
|
/* The section links need ~810px before they stop pushing the header past the viewport.
|
|
At 640px they already did not fit: the page overflowed by 63px through the whole
|
|
641-767px band, which body{overflow-x:hidden} hid rather than fixed. */
|
|
@media (max-width: 860px) { .nav-hide { display: none; } }
|
|
.icon-btn {
|
|
display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem;
|
|
border: 1px solid var(--border-ui); border-radius: var(--radius);
|
|
background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0;
|
|
}
|
|
.icon-btn:hover { color: var(--text); }
|
|
|
|
/* ---------------------------------------------------------------- hero */
|
|
.hero { padding-block: clamp(3rem, 7vw, 6rem) clamp(2rem, 4vw, 3.5rem); }
|
|
.hero-grid { display: grid; gap: clamp(2rem, 4vw, 3.5rem); align-items: start; }
|
|
@media (min-width: 900px) { .hero-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); } }
|
|
.hero h1 { margin-bottom: 1.1rem; }
|
|
.hero h1 .accent { color: var(--accent-text); }
|
|
.hero p.lede { font-size: 1.125rem; margin-bottom: 1.75rem; }
|
|
.cta-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
|
|
.btn {
|
|
display: inline-flex; align-items: center; gap: .5rem; font: 600 .9375rem/1 var(--sans);
|
|
padding: .75rem 1.15rem; border-radius: var(--radius); text-decoration: none; cursor: pointer; border: 1px solid transparent;
|
|
}
|
|
.btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
|
|
/* Re-assert label colours on hover: the base a:hover (0,1,1) outranks these classes
|
|
(0,1,0), and in dark theme --accent-text equals --accent-solid — a hovered label
|
|
painted in its own background. */
|
|
.btn-primary:hover { filter: brightness(1.08); color: var(--on-accent); }
|
|
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); }
|
|
.btn-secondary:hover { border-color: var(--text-faint); color: var(--text); }
|
|
.fact-row {
|
|
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
|
|
font-size: .8125rem; color: var(--text-muted);
|
|
}
|
|
.fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; }
|
|
.fact b { color: var(--text); font-weight: 600; }
|
|
|
|
/* ---------------------------------------------------------------- code */
|
|
.code {
|
|
position: relative; background: var(--code-bg); border: 1px solid var(--code-border);
|
|
border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow);
|
|
}
|
|
.code-head {
|
|
display: flex; align-items: center; gap: .5rem; padding: .55rem .9rem;
|
|
border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised);
|
|
font: 500 .75rem/1 var(--mono); color: var(--code-faint);
|
|
}
|
|
/* Use 3 of 3, and a deletion: the fake traffic lights are gone. */
|
|
.code-head::before {
|
|
content: ""; flex: none; width: 1.05rem; height: 2px;
|
|
background-image: var(--rule-code);
|
|
}
|
|
.code-head .right { margin-left: auto; }
|
|
.code pre {
|
|
margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto;
|
|
color: var(--code-text); font-size: .84375rem; line-height: 1.75; tab-size: 2;
|
|
}
|
|
.code pre code { display: block; min-width: max-content; }
|
|
.ln { display: block; padding-inline: .35rem; margin-inline: -.35rem; border-radius: 3px; }
|
|
.t-comment { color: var(--t-comment); font-style: italic; }
|
|
.t-string { color: var(--t-string); }
|
|
.t-keyword { color: var(--t-keyword); }
|
|
.t-decorator { color: var(--t-decorator); }
|
|
.t-type { color: var(--t-type); }
|
|
.t-number { color: var(--t-number); }
|
|
|
|
/* The page's one visual device: the line the compiler refuses. */
|
|
.ln--error {
|
|
background: color-mix(in srgb, var(--err-line) 9%, transparent);
|
|
text-decoration: underline wavy var(--err-line);
|
|
text-decoration-skip-ink: none;
|
|
text-underline-offset: .32em;
|
|
}
|
|
.tsc-error {
|
|
display: flex; gap: .6rem; align-items: flex-start;
|
|
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
|
|
background: color-mix(in srgb, var(--err-line) 8%, transparent); color: var(--err-text);
|
|
font: 500 .78125rem/1.5 var(--mono);
|
|
}
|
|
.tsc-error .mark { color: var(--err-line); flex-shrink: 0; }
|
|
|
|
/* --------------------------------------------------------------- panels */
|
|
.panel {
|
|
background: var(--bg-raised); border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg); padding: 1.35rem;
|
|
}
|
|
.grid { display: grid; gap: 1.1rem; }
|
|
@media (min-width: 720px) { .grid-2 { grid-template-columns: 1fr 1fr; } }
|
|
@media (min-width: 900px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
|
|
.panel h3 { margin-bottom: .5rem; display: flex; align-items: center; gap: .5rem; }
|
|
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
|
.tick { color: var(--ok); font-weight: 700; }
|
|
.cross { color: var(--bad); font-weight: 700; }
|
|
.warn-mark { color: var(--warn); font-weight: 700; }
|
|
|
|
.compare { display: grid; gap: 1rem; }
|
|
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
|
|
.compare-label {
|
|
display: flex; align-items: center; gap: .5rem; margin: 0 0 .6rem;
|
|
font: 600 .8125rem/1 var(--sans); color: var(--text-muted);
|
|
}
|
|
.pill { font: 600 .6875rem/1 var(--sans); padding: .28rem .5rem; border-radius: 999px; }
|
|
.pill-bad { background: var(--bad-soft); color: var(--bad); }
|
|
.pill-ok { background: var(--ok-soft); color: var(--ok); }
|
|
|
|
/* ----------------------------------------------------------- playground */
|
|
.pg { background: var(--bg-sunken); border-block: 1px solid var(--border); }
|
|
.pg-bar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: 1rem; }
|
|
.tabs { display: flex; flex-wrap: wrap; gap: .35rem; }
|
|
.tab {
|
|
font: 500 .8125rem/1 var(--sans); padding: .45rem .7rem; border-radius: 999px; cursor: pointer;
|
|
background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border);
|
|
}
|
|
.tab:hover { color: var(--text); }
|
|
.tab[aria-pressed="true"] { background: var(--accent-solid); border-color: var(--accent-solid); color: var(--on-accent); }
|
|
.pg-grid { display: grid; gap: 1rem; }
|
|
@media (min-width: 960px) { .pg-grid { grid-template-columns: 1fr 1fr; } }
|
|
.pg-pane { display: flex; flex-direction: column; min-width: 0; }
|
|
#editor {
|
|
flex: 1; width: 100%; min-height: 400px; resize: vertical;
|
|
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
|
|
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
|
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre;
|
|
overflow: auto;
|
|
}
|
|
#editor:focus { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
|
|
/* Scrollable code samples are keyboard-focusable in Chromium; the page-level ring is
|
|
unreadable on the dark slab and its +2px offset would be clipped by .code overflow. */
|
|
.code :focus-visible, #output:focus-visible { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
|
|
#output {
|
|
flex: 1; min-height: 400px; margin: 0; overflow: auto;
|
|
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
|
|
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
|
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); white-space: pre-wrap; word-break: break-word;
|
|
}
|
|
#output .out-err { color: var(--err-text); }
|
|
#output .out-dim { color: var(--code-faint); }
|
|
.pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; }
|
|
|
|
/* ---------------------------------------------------------------- table */
|
|
.table-scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-raised); }
|
|
table { border-collapse: collapse; width: 100%; font-size: .9375rem; }
|
|
th, td { text-align: left; padding: .7rem .95rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
thead th { font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); font-weight: 700; }
|
|
tbody tr:last-child td { border-bottom: none; }
|
|
td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
|
|
|
|
/* ------------------------------------------------------------ reference */
|
|
.ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; }
|
|
#ref-filter {
|
|
flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem;
|
|
border: 1px solid var(--border-ui); border-radius: var(--radius);
|
|
background: var(--bg-raised); color: var(--text);
|
|
}
|
|
#ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
|
.ref-groups { display: grid; gap: 1.1rem; }
|
|
@media (min-width: 780px) { .ref-groups { grid-template-columns: 1fr 1fr; } }
|
|
@media (min-width: 1080px) { .ref-groups { grid-template-columns: repeat(3, 1fr); } }
|
|
.ref-group { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.1rem 1.2rem; }
|
|
.ref-group h3 { font-size: .9375rem; margin-bottom: .3rem; }
|
|
.ref-group .blurb { font-size: .8125rem; color: var(--text-faint); margin: 0 0 .8rem; }
|
|
.ref-group ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
|
|
.ref-group li { font-size: .8125rem; color: var(--text-muted); }
|
|
.ref-group li code { color: var(--text); background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 5px; padding: .1rem .3rem; font-size: .78125rem; }
|
|
.ref-group li .sum { display: block; margin-top: .15rem; }
|
|
.ref-empty { color: var(--text-faint); font-size: .9375rem; }
|
|
|
|
/* --------------------------------------------------------------- prose */
|
|
.notes { display: grid; gap: .9rem; }
|
|
/* Use 2 of 3: the same rhythm stood on end. */
|
|
.note-item { padding-left: 1rem; background: var(--rule-y) left top / 2px 100% no-repeat; }
|
|
.note-item h3 { font-size: .9375rem; margin-bottom: .25rem; }
|
|
.note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
|
.callout {
|
|
border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent);
|
|
background: var(--warn-soft); border-radius: var(--radius-lg); padding: 1rem 1.15rem;
|
|
font-size: .9375rem; color: var(--text);
|
|
}
|
|
.callout strong { color: var(--warn); }
|
|
.inline-code { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 5px; padding: .08rem .3rem; font-size: .875em; }
|
|
|
|
footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(--text-muted); font-size: .875rem; }
|
|
.foot-grid { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between; align-items: center; }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<a class="skip" href="#main">Skip to content</a>
|
|
|
|
<header class="nav">
|
|
<div class="wrap nav-inner">
|
|
<a class="brand" href="#top"><svg class="mark" width="10" height="18" viewBox="0 0 10 18" aria-hidden="true" focusable="false"><rect x="4" y="0" width="2" height="18" fill="currentColor" opacity=".3"/><rect x="0" y="2" width="10" height="2" fill="currentColor"/><rect x="0" y="6" width="10" height="2" fill="currentColor"/><rect x="0" y="10" width="10" height="2" fill="currentColor"/><rect x="0" y="14" width="10" height="2" fill="currentColor"/></svg>cereale <span class="badge" id="version-badge">v0.4.0</span></a>
|
|
<nav class="nav-links" aria-label="Primary">
|
|
<a class="nav-hide" href="#guarantee">Guarantee</a>
|
|
<a class="nav-hide" href="#playground">Playground</a>
|
|
<a class="nav-hide" href="#errors">Errors</a>
|
|
<a class="nav-hide" href="#install">Install</a>
|
|
<a class="nav-hide" href="#size">Size</a>
|
|
<a class="nav-hide" href="#reference">Reference</a>
|
|
<a class="ghost" href="https://github.com/avalon-vanguard/cereale">GitHub</a>
|
|
<button class="icon-btn" id="theme-toggle" type="button" aria-label="Switch between light and dark theme">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
|
|
</svg>
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="main">
|
|
|
|
<!-- ============================================================= hero -->
|
|
<section class="hero" id="top">
|
|
<div class="wrap hero-grid">
|
|
<div>
|
|
<h1>Validated <span class="accent">domain objects</span>, not validated data.</h1>
|
|
<p class="lede">
|
|
cereale maps JSON onto your own classes and gives you back real instances — with your
|
|
methods, your inheritance, your <code class="inline-code">instanceof</code> checks. And every
|
|
rule that implies a type is checked against the field it is attached to, at compile time.
|
|
</p>
|
|
<div class="cta-row">
|
|
<a class="btn btn-primary" href="#playground">
|
|
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6V4z"/></svg>
|
|
Run it in the browser
|
|
</a>
|
|
<a class="btn btn-secondary" href="https://github.com/avalon-vanguard/cereale">Read the source</a>
|
|
</div>
|
|
<div class="fact-row">
|
|
<span class="fact"><b>0</b> runtime dependencies</span>
|
|
<span class="fact"><b id="decorator-count">68</b> decorators</span>
|
|
<span class="fact">TC39 <b>standard decorators</b></span>
|
|
<span class="fact">ESM + CJS + single-file</span>
|
|
<span class="fact">Node <b id="node-req">≥20</b></span>
|
|
<span class="fact"><b>1.8 KB</b> for one decorator</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="code">
|
|
<div class="code-head">
|
|
<span class="name">order.ts</span>
|
|
</div>
|
|
<pre><code data-lang="ts" data-error-line="9">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); // a real Order
|
|
order.total(); // methods intact</code></pre>
|
|
<!-- Replaced at load with the verbatim diagnostic that scripts/build-docs.mjs
|
|
captured from tsc. The static text is the fallback, and the build fails if
|
|
this snippet ever stops being a compile error. -->
|
|
<p class="tsc-error" id="hero-diagnostic" data-case="hero"><span class="mark" aria-hidden="true">✖</span><span><strong>ts(1240)</strong> Unable to resolve signature of property decorator when called as an expression.<br> … Type 'Date' is not assignable to type 'string'.</span></p>
|
|
</div>
|
|
<p class="pg-note">
|
|
That is the compiler, not cereale — and it is the real thing: the snippet above is
|
|
compiled by <code class="inline-code">tsc</code> when this page is built, and the build
|
|
fails if it ever stops being an error.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ======================================================== guarantee -->
|
|
<section id="guarantee">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">The guarantee</p>
|
|
<h2>Your rules and your types cannot disagree</h2>
|
|
<p class="lede">
|
|
A legacy decorator is typed <code class="inline-code">(prototype, propertyName) => void</code>.
|
|
Its signature carries nothing about the field's declared type, so a rule that does not fit
|
|
the field still compiles. A standard decorator is handed a
|
|
<code class="inline-code">ClassFieldDecoratorContext<This, Value></code>, which does
|
|
carry it. cereale uses it.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="compare">
|
|
<div>
|
|
<p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p>
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">with legacy decorators</span></div>
|
|
<pre><code data-lang="ts">class User {
|
|
@IsString()
|
|
age: number; // accepted by the compiler
|
|
}
|
|
|
|
// ...discovered in production, on a payload
|
|
// that happened to carry the wrong shape.</code></pre>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p>
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">with standard decorators</span></div>
|
|
<pre><code data-lang="ts" data-error-line="2">class User {
|
|
@IsString()
|
|
age!: number; // Type 'number' is not
|
|
} // assignable to 'string'
|
|
|
|
// ...caught by your editor, by tsc, and by
|
|
// CI. It never gets as far as a payload.</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-3" style="margin-top:1.75rem">
|
|
<div class="panel">
|
|
<h3>Scalars against scalars</h3>
|
|
<p><code class="inline-code">@Min(1)</code> on a <code class="inline-code">string</code> is a compile error, as is <code class="inline-code">@MinLength(2)</code> on a <code class="inline-code">number</code>.</p>
|
|
</div>
|
|
<div class="panel">
|
|
<h3><code class="inline-code">each</code> against arrays</h3>
|
|
<p><code class="inline-code">@IsString({ each: true })</code> demands a <code class="inline-code">string[]</code>; a bare <code class="inline-code">@IsString()</code> on one is rejected.</p>
|
|
</div>
|
|
<div class="panel">
|
|
<h3>Classes against classes</h3>
|
|
<p><code class="inline-code">@JsonType(() => Address)</code>, <code class="inline-code">@JsonSerialize</code> and <code class="inline-code">@IsEnum(E)</code> are all checked against the field.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========================================================= instances -->
|
|
<section id="instances" style="background:var(--bg-sunken);border-block:1px solid var(--border)">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">What you get back</p>
|
|
<h2>An instance of your class, not a shape that resembles it</h2>
|
|
<p class="lede">
|
|
Nested objects and arrays you declare with <code class="inline-code">@JsonType</code>, and
|
|
subtypes you declare with <code class="inline-code">@JsonPolymorphic</code>, come back as real
|
|
classes — so the behaviour you attached to your model survives the trip through JSON. What
|
|
you do not declare, cereale leaves alone; it infers nothing.
|
|
</p>
|
|
</div>
|
|
<div class="compare">
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">catalogue.ts</span></div>
|
|
<pre><code data-lang="ts">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[];
|
|
}</code></pre>
|
|
</div>
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">what comes out</span></div>
|
|
<pre><code data-lang="ts">const list = fromJsonSync(Playlist, body);
|
|
const first = list.items[0];
|
|
|
|
first instanceof Movie // true
|
|
|
|
if (first instanceof Movie) {
|
|
first.hours(); // 2.46… — narrowing
|
|
} // works, because it
|
|
// really is one.
|
|
|
|
// Base-class rules reach subclasses, and a
|
|
// subclass adds to them rather than
|
|
// replacing them.</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ======================================================== playground -->
|
|
<section class="pg" id="playground">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">Playground</p>
|
|
<h2>The real library, running here</h2>
|
|
<p class="lede">
|
|
This page bundles cereale itself and compiles what you type with standard decorators.
|
|
Edit anything and run it.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="pg-bar">
|
|
<!-- Deliberately not role="tablist": these load code into a single editor rather than
|
|
switching panels, and a half-implemented tab widget (no tabpanel, no arrow-key
|
|
navigation) misleads a screen reader more than plain toggle buttons do. -->
|
|
<div class="tabs" role="group" aria-label="Playground examples" id="tabs"></div>
|
|
<button class="btn btn-primary" id="run-btn" type="button" style="margin-left:auto">
|
|
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6V4z"/></svg>
|
|
Run
|
|
</button>
|
|
</div>
|
|
|
|
<div class="pg-grid">
|
|
<div class="pg-pane">
|
|
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
|
<span class="name">playground.ts</span>
|
|
</div>
|
|
<textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
|
aria-label="TypeScript source to run"></textarea>
|
|
</div>
|
|
<div class="pg-pane">
|
|
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
|
<span class="name">output</span>
|
|
<span class="right" id="pg-status"></span>
|
|
</div>
|
|
<pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="pg-note">
|
|
The compiler here strips types and lowers decorators; it does not type-check. The compile-time
|
|
guarantee above is what your editor and <code class="inline-code">tsc</code> give you — a browser
|
|
cannot demonstrate an error that stops a build.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================ errors -->
|
|
<section id="errors">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">Diagnosability</p>
|
|
<h2>Nothing fails quietly</h2>
|
|
<p class="lede">
|
|
The 0.3.0 release existed because of this. A mapping layer that loses your data and reports
|
|
success is worse than one that stops, so every silent failure found in the engine was turned
|
|
into an error that names the cause and the way out.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-3">
|
|
<div class="panel">
|
|
<h3>A <code class="inline-code">Map</code> used to become <code class="inline-code">{}</code></h3>
|
|
<p>Along with <code class="inline-code">Set</code>, <code class="inline-code">RegExp</code>, <code class="inline-code">Error</code>, typed arrays, <code class="inline-code">bigint</code>, symbols and functions — each with its own version of the same silence.</p>
|
|
</div>
|
|
<div class="panel">
|
|
<h3>A misconfigured compiler used to throw from inside</h3>
|
|
<p><code class="inline-code">TypeError: Cannot convert undefined or null to object</code>, which names neither the cause nor the one-line fix.</p>
|
|
</div>
|
|
<div class="panel">
|
|
<h3>A build used to succeed while emitting nothing that runs</h3>
|
|
<p>Vite 8 passes decorator syntax straight through. The bundle builds; the first import throws.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:1.5rem" class="code">
|
|
<div class="code-head"><span class="name">what you get instead</span></div>
|
|
<pre><code data-lang="text">JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON.
|
|
Give the property a @JsonSerialize() serializer that converts it, or drop it from
|
|
the output with @JsonIgnore().
|
|
|
|
TypeError: cereale needs TC39 standard decorators, but the compiler emitted legacy
|
|
ones. Set "experimentalDecorators": false in tsconfig.json (and drop
|
|
"emitDecoratorMetadata").
|
|
|
|
JsonMappingError: Circular reference detected during serialization at child.parent.
|
|
Break the cycle with @JsonIgnore() on the back-reference, or supply a
|
|
@JsonSerialize() serializer for that property.</code></pre>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========================================================= toolchain -->
|
|
<section id="toolchain" style="background:var(--bg-sunken);border-block:1px solid var(--border)">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">Before you install</p>
|
|
<h2>The toolchain cost, stated plainly</h2>
|
|
<p class="lede">
|
|
cereale reads the metadata that only a <strong>standard</strong> decorator transform emits, so
|
|
which compiler you use decides whether it works at all. The three ✓ rows in the first table are
|
|
executed by a
|
|
test on every CI run rather than asserted here — each one compiles a decorated class with
|
|
that tool and checks the metadata arrived. The ✗ row cannot be: oxc ships inside a native
|
|
binary with no standalone transform API, so it was established by hand, and the plugin below
|
|
is what came of it.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="table-scroll">
|
|
<table>
|
|
<caption class="sr-only">Transformer support for TC39 standard decorators</caption>
|
|
<thead>
|
|
<tr><th scope="col">Transformer</th><th scope="col">Works</th><th scope="col">Setting</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td><code>tsc</code> 5.2+</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code>, <code>target: ES2022</code>+</td></tr>
|
|
<tr><td>esbuild</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code> via <code>tsconfigRaw</code>, <em>plus</em> esbuild's own top-level <code>target: es2022</code> — its default <code>esnext</code> leaves the decorators in place</td></tr>
|
|
<tr><td>swc</td><td><span class="tick">✓</span></td><td class="note"><code>jsc.transform.decoratorVersion: "2022-03"</code></td></tr>
|
|
<tr><td>oxc</td><td><span class="cross">✗</span></td><td class="note">used by <strong>Vite 8</strong> and <strong>Vitest 4</strong> — see below</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="callout" style="margin-top:1.25rem">
|
|
<strong>On Vite 8 or Vitest 4?</strong> oxc leaves decorator syntax in the output and reports
|
|
nothing: <code class="inline-code">vitest</code> prints <em>0 test</em> next to a bare
|
|
<code class="inline-code">SyntaxError</code>, and <code class="inline-code">vite build</code>
|
|
reports success while emitting a bundle that throws on first import. cereale ships the plugin
|
|
that fixes it.
|
|
</div>
|
|
|
|
<div class="table-scroll" style="margin-top:1.5rem">
|
|
<table>
|
|
<caption class="sr-only">Framework support</caption>
|
|
<thead><tr><th scope="col">Framework</th><th scope="col">Works</th><th scope="col">What it takes</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>Angular 21</td><td><span class="tick">✓</span></td><td class="note">Flip the scaffolded <code>experimentalDecorators</code> to <code>false</code> — Angular does not need it</td></tr>
|
|
<tr><td>React, Vue, Svelte…</td><td><span class="tick">✓</span></td><td class="note">Any Vite 8 app — add the plugin below</td></tr>
|
|
<tr><td>Bun 1.3</td><td><span class="tick">✓</span></td><td class="note">Nothing</td></tr>
|
|
<tr><td>Node + <code>tsc</code></td><td><span class="tick">✓</span></td><td class="note">Just the flag</td></tr>
|
|
<tr><td>Next.js 16</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — keep models in a package compiled by <code>tsc</code></td></tr>
|
|
<tr><td>NestJS 11.1</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — its DI needs <code>emitDecoratorMetadata</code>; same precompiled route</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="pg-note" style="max-width:var(--measure)">
|
|
Each of these was set up and run before it was written down, on 2026-08-05, against the
|
|
versions listed.
|
|
<a href="https://github.com/avalon-vanguard/cereale/blob/main/FRAMEWORKS.md">FRAMEWORKS.md</a>
|
|
has the full recipe for every one, including the two that need the precompiled route.
|
|
</p>
|
|
|
|
<div class="code" style="margin-top:1.25rem;max-width:640px">
|
|
<div class="code-head"><span class="name">vite.config.ts</span></div>
|
|
<pre><code data-lang="ts">import { defineConfig } from 'vite';
|
|
import { standardDecorators } from 'cereale/vite';
|
|
|
|
export default defineConfig({
|
|
plugins: [standardDecorators()],
|
|
});</code></pre>
|
|
</div>
|
|
<p class="pg-note" style="max-width:var(--measure)">
|
|
It transforms <code class="inline-code">.ts</code>, <code class="inline-code">.mts</code> and
|
|
<code class="inline-code">.cts</code> outside <code class="inline-code">node_modules</code> with
|
|
esbuild, falling back to the TypeScript compiler — cereale depends on neither. Decorated
|
|
classes in <code class="inline-code">.tsx</code> need an <code class="inline-code">include</code>
|
|
of your own; they are excluded by default because lowering them means also deciding what
|
|
happens to the JSX. Nothing in the plugin is specific to cereale; it can be deleted once oxc
|
|
implements the transform.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- =========================================================== install -->
|
|
<section id="install">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">Getting started</p>
|
|
<h2>Two settings, one caveat, and two ways in</h2>
|
|
</div>
|
|
|
|
<div class="compare">
|
|
<div>
|
|
<p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p>
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">tsconfig.json</span></div>
|
|
<pre><code data-lang="text">{
|
|
"compilerOptions": {
|
|
"target": "ES2022",
|
|
"lib": ["ESNext", "ESNext.Decorators"],
|
|
"experimentalDecorators": false
|
|
}
|
|
}</code></pre>
|
|
</div>
|
|
<p class="pg-note">
|
|
No <code class="inline-code">reflect-metadata</code>, and
|
|
<code class="inline-code">emitDecoratorMetadata</code> is never read. If
|
|
<code class="inline-code">experimentalDecorators</code> is on, cereale says so by name
|
|
instead of failing somewhere inside itself.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="compare-label"><span class="pill pill-ok">on npm</span> installing it</p>
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">shell</span></div>
|
|
<pre><code data-lang="text">npm install cereale</code></pre>
|
|
</div>
|
|
<p class="pg-note">
|
|
Published from CI with <strong>provenance</strong>, so the registry carries a verified
|
|
attestation linking the tarball to the commit it was built from — visible on the
|
|
<a href="https://www.npmjs.com/package/cereale">package page</a>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel" style="margin-top:1.75rem">
|
|
<h3>No bundler at all</h3>
|
|
<p>
|
|
<code class="inline-code">cereale/min</code> is the whole library flattened into one
|
|
minified ES module — <strong>33.9 KB</strong>, 9.6 KB gzipped — for import maps,
|
|
a bare <code class="inline-code"><script type="module"></code>, Deno and Workers.
|
|
</p>
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">index.html</span></div>
|
|
<pre><code data-lang="text"><script type="importmap">
|
|
{ "imports": { "cereale": "./node_modules/cereale/dist/cereale.min.js" } }
|
|
</script>
|
|
<script type="module">
|
|
import { IsString, toInstanceSync } from 'cereale';
|
|
</script></code></pre>
|
|
</div>
|
|
<p class="pg-note">
|
|
<strong>If you are using a bundler, prefer the default entry.</strong> The flat file keeps
|
|
its purity annotations, so a bundler can still drop the rules you did not import — that
|
|
is pinned by a test — but the per-module build shakes slightly leaner (1,837 bytes
|
|
against 1,996 for one decorator) and is the canonical route.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========================================================= size -->
|
|
<section id="size" style="background:var(--bg-sunken);border-block:1px solid var(--border)">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">Bundle size</p>
|
|
<h2>You pay for the decorators you name</h2>
|
|
<p class="lede">
|
|
<b class="js-dec-count">68</b> decorators is a lot to ship to a browser, so none of the ones you did not
|
|
import are shipped. Minified bytes, measured through all three bundlers.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="table-scroll">
|
|
<table>
|
|
<caption class="sr-only">Minified bytes by import, across three bundlers</caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">What you import</th>
|
|
<th scope="col">esbuild</th><th scope="col">rollup</th><th scope="col">webpack</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td><code>flattenErrors</code></td><td>394</td><td>367</td><td>394</td></tr>
|
|
<tr><td>one decorator</td><td>1,837</td><td>1,823</td><td>1,818</td></tr>
|
|
<tr><td><code>validateSync</code></td><td>3,722</td><td>3,554</td><td>3,738</td></tr>
|
|
<tr><td><code>toPlainSync</code></td><td>7,744</td><td>7,769</td><td>7,771</td></tr>
|
|
<tr><td><code>toInstanceSync</code></td><td>7,900</td><td>7,942</td><td>7,944</td></tr>
|
|
<tr><td>a typical DTO</td><td>10,395</td><td>10,402</td><td>10,360</td></tr>
|
|
<tr><td>the whole library</td><td>26,266</td><td>25,671</td><td>26,879</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="notes" style="margin-top:1.5rem">
|
|
<div class="note-item">
|
|
<h3>Reading and writing drop independently</h3>
|
|
<p>
|
|
Import <code class="inline-code">toInstanceSync</code> and you do not pay for the
|
|
serializer. The validator stays in both, because
|
|
<code class="inline-code">validate</code> defaults to
|
|
<code class="inline-code">true</code> — a real reference, not a missed optimisation.
|
|
</p>
|
|
</div>
|
|
<div class="note-item">
|
|
<h3>It did not come for free</h3>
|
|
<p>
|
|
Every rule is a top-level call. rollup proves such a call side-effect-free by reading
|
|
the factory; esbuild and webpack will not. Without a
|
|
<code class="inline-code">/*#__PURE__*/</code> on each, one decorator cost
|
|
<strong>4,909 bytes instead of 1,837</strong>. Nothing failed — the library was just
|
|
three times heavier in every bundle, and the only way to find out was to measure.
|
|
</p>
|
|
</div>
|
|
<div class="note-item">
|
|
<h3>rollup alone would have shown nothing</h3>
|
|
<p>
|
|
It was already producing 1,823 bytes and hid the problem. That is the argument for
|
|
measuring through more than one bundler, and it is why
|
|
<a href="https://github.com/avalon-vanguard/cereale/blob/main/src/treeshake.test.ts">a test</a>
|
|
now pins the property rather than the prose.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========================================================= reference -->
|
|
<section id="reference">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">Reference</p>
|
|
<h2>Everything cereale exports</h2>
|
|
<p class="lede">
|
|
Every rule that implies a type is checked against the field it decorates — a few
|
|
deliberately do not, because they fit any field
|
|
(<code class="inline-code">@IsDefined</code>) or because the constraint is negative and
|
|
narrowing it would be backwards (<code class="inline-code">@IsNotIn</code>). All of them take
|
|
<code class="inline-code">{ each: true }</code> to run per element of an array, and a
|
|
<code class="inline-code">message</code>, reported verbatim.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="ref-bar">
|
|
<label for="ref-filter" class="sr-only">Filter the reference</label>
|
|
<input id="ref-filter" type="search" placeholder="Filter — try “uuid”, “array”, “naming”…" autocomplete="off">
|
|
<span id="ref-count"></span>
|
|
</div>
|
|
|
|
<div class="ref-groups" id="ref-groups"></div>
|
|
<noscript>
|
|
<p class="ref-empty">The reference is rendered with JavaScript. The full list is in the
|
|
<a href="https://github.com/avalon-vanguard/cereale#api-reference">README</a>.</p>
|
|
</noscript>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================ limits -->
|
|
<section id="limits" style="background:var(--bg-sunken);border-top:1px solid var(--border)">
|
|
<div class="wrap">
|
|
<div class="section-head">
|
|
<p class="eyebrow">Honest limits</p>
|
|
<h2>What cereale is not</h2>
|
|
</div>
|
|
|
|
<div class="grid grid-2">
|
|
<div class="notes">
|
|
<div class="note-item">
|
|
<h3>It does not infer types from a schema</h3>
|
|
<p>You write the field type <em>and</em> the rule; cereale guarantees they agree. If you
|
|
want the type derived from a schema, that is Zod's design, and Zod is the right tool for it.</p>
|
|
</div>
|
|
<div class="note-item">
|
|
<h3>It cannot coexist with legacy decorators</h3>
|
|
<p>The two decorator systems are a whole-program setting. A project that still needs
|
|
<code class="inline-code">experimentalDecorators</code> for another library cannot use cereale yet.</p>
|
|
</div>
|
|
<div class="note-item">
|
|
<h3>Rules live on the class, not on the data</h3>
|
|
<p><code class="inline-code">validate()</code> on a plain object reports nothing. Validate the
|
|
instance you get back from <code class="inline-code">toInstance</code>.</p>
|
|
</div>
|
|
</div>
|
|
<div class="notes">
|
|
<div class="note-item">
|
|
<h3>Renaming is not backwards-compatible by itself</h3>
|
|
<p>Once a property carries <code class="inline-code">@JsonProperty</code>, its original name
|
|
no longer reaches it — it is refused rather than copied onto the instance behind the
|
|
rename's back. Add <code class="inline-code">@JsonAlias</code> to keep older clients
|
|
working. Under <code class="inline-code">unknownKeys: 'error'</code> the stale name is
|
|
reported by name, along with what the property is called now.</p>
|
|
</div>
|
|
<div class="note-item">
|
|
<h3><code class="inline-code">abstract</code> and <code class="inline-code">accessor</code> fields cannot be decorated</h3>
|
|
<p>Standard decorators do not apply to abstract members, and an
|
|
<code class="inline-code">accessor</code> field keeps its value in a private slot that mapping
|
|
cannot reach. Both are errors rather than silent no-ops.</p>
|
|
</div>
|
|
<div class="note-item">
|
|
<h3>It is still 0.x</h3>
|
|
<p><span class="js-version">0.4.0</span> is published, and under semver a 0.x minor bump
|
|
is allowed to break you. Pin the version until 1.0; the
|
|
<a href="https://github.com/avalon-vanguard/cereale/blob/main/CHANGELOG.md">changelog</a>
|
|
says what moved and why.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<footer>
|
|
<div class="wrap foot-grid">
|
|
<p style="margin:0">cereale — MIT licensed. Built by Avalon Vanguard.</p>
|
|
<p style="margin:0">
|
|
<a href="https://github.com/avalon-vanguard/cereale">Source</a> ·
|
|
<a href="https://github.com/avalon-vanguard/cereale/blob/main/CHANGELOG.md">Changelog</a> ·
|
|
<a href="https://github.com/avalon-vanguard/cereale/issues">Issues</a>
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
|
|
<script src="meta.js"></script>
|
|
<script src="diagnostics.js"></script>
|
|
<script src="cereale.js"></script>
|
|
<script src="page.js"></script>
|
|
</body>
|
|
</html>
|