The library is called cereale and the page was generic dark-developer-docs
with an emoji in the brand mark. Three directions were drafted and scored by
separate judges on identity, legibility and craft; this is the disciplined
one with the two grafts that fixed its own weaknesses.
Palette. Indigo on off-white becomes rye-brown on oat paper, warm in both
themes. The accent is deliberately NOT wheat-gold: gold is both the cliche
and a collision with --warn, which already owns amber, and two amber families
would make "our brand" and "needs your attention" the same colour.
One ornament, used three times. A 2px mark repeated every 9px: as a rule
above each section head, stood on end as the stalk beside each note, and in
place of the three fake macOS traffic lights on every code panel — which were
the most generic pixels on the page. The brand emoji and the emoji favicon
both go, replaced by the same figure drawn as a five-rect SVG: an ear of grain
reduced to its skeleton, which is the section rule stood upright. An emoji is
a different picture on every OS, so it is the one brand element you do not
control.
Grafted from the two directions that lost:
- Ruled links. On a palette this warm, --accent-text and --text-muted sit
1.14:1 apart, so colour alone loses a link in prose. Links now keep body
colour and carry a 2px accent rule, which satisfies 1.4.1 outright.
- --accent-on-code. #editor:focus drew its ring in --accent against the
always-dark slab: 2.86:1, a live 1.4.11 failure on the shipped page. The
new single-valued token measures 7.03:1.
- --border-ui, for icon-only and outlined controls, which were on
--border-strong at 1.60:1. Now 3.54:1.
- --err-line/--err-text, replacing three loose literals.
Two bugs found by rendering rather than by reading. The header overflowed the
viewport by 63px across the whole 641-767px band, hidden rather than fixed by
body{overflow-x:hidden}; the Size link added in the previous commit widened
that to 109px. The nav-link breakpoint moves 640px to 860px, which is where
they actually fit.
Verified: 72 foreground/background pairs computed in both themes, none below
4.5:1 for text or 3:1 for non-text; no horizontal overflow at any of 20
widths from 320 to 1920; no page errors and no external requests in either
theme; the playground still compiles and runs; markup parses with nothing
unclosed; check:docs passes and the generated files stay in sync.
Not taken: recolouring --t-decorator to the brand. It is the best identity
idea in the set — the decorators are what the library is — but the six --t-*
tokens are a shared vocabulary, page.js emits their class names, and the
playground highlights code the reader pastes in. One token, easy to revisit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
1028 lines
50 KiB
HTML
1028 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 ALL FOUR places:
|
|
this block, the prefers-color-scheme block, [data-theme="light"] and
|
|
[data-theme="dark"]. Miss one and the toggle silently serves the light value. */
|
|
: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 {
|
|
--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);
|
|
}
|
|
}
|
|
|
|
/* The toggle wins over the media query in both directions. */
|
|
:root[data-theme="light"] {
|
|
--bg: #faf7f0; --bg-raised: #fffdf7; --bg-sunken: #f2ede1;
|
|
--text: #1a1712; --text-muted: #5c5346; --text-faint: #6e6353;
|
|
--border: #e6dfd1; --border-strong: #cfc5b2; --border-ui: #8e8269;
|
|
--accent: #8a6238; --accent-text: #7a5530; --accent-soft: #f1e9dc;
|
|
--accent-solid: #6b4a28; --on-accent: #fffdf7;
|
|
--bad: #a82820; --bad-soft: #faebe7; --ok: #256b3d; --ok-soft: #e8f2e9;
|
|
--warn: #8a5a05; --warn-soft: #fbf1dc;
|
|
--shadow: 0 1px 2px rgba(40, 30, 14, .05), 0 8px 24px -12px rgba(40, 30, 14, .20);
|
|
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; }
|
|
.nav-links a, .foot-grid a, .brand { text-decoration: none; }
|
|
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; }
|
|
.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); }
|
|
.btn-primary:hover { filter: brightness(1.08); }
|
|
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); }
|
|
.btn-secondary:hover { border-color: var(--text-faint); }
|
|
.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: rgba(255, 106, 126, .09);
|
|
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: rgba(255, 106, 126, .08); 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; }
|
|
#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: #ff8095; }
|
|
#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-strong); 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-bad">not on npm yet</span> installing it today</p>
|
|
<div class="code">
|
|
<div class="code-head"><span class="name">shell</span></div>
|
|
<pre><code data-lang="text">git clone https://github.com/avalon-vanguard/cereale
|
|
cd cereale
|
|
npm install && npm run build
|
|
npm pack # → cereale-0.4.0.tgz
|
|
|
|
# then, from your own project
|
|
npm install ../cereale/cereale-0.4.0.tgz</code></pre>
|
|
</div>
|
|
<p class="pg-note">
|
|
<code class="inline-code">npm install cereale</code> does <strong>not</strong> resolve to
|
|
this library — the name is unclaimed on the registry. Installing straight from GitHub
|
|
will not work either: the build output is not committed, so the package would arrive
|
|
without its <code class="inline-code">dist/</code>.
|
|
</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, do not use it.</strong> It is the whole library in one
|
|
file, so nothing can be dropped from it. The default entry point tree-shakes; this one
|
|
cannot, because there is nothing left to shake.
|
|
</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">
|
|
Sixty-eight 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 not on npm yet</h3>
|
|
<p>0.4.0 lives in the repository. <code class="inline-code">npm install cereale</code> does not
|
|
resolve to this library — build it from source until it is published.</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>
|