27 Commits
Author SHA1 Message Date
SenrokaiandClaude Opus 5.5 5f2f3f0af1 Renovate : preset commun avalon-vanguard/renovate-config
CI / verify (20.x) (pull_request) Successful in 2m43s
CI / verify (22.x) (pull_request) Successful in 1m25s
CI / verify (24.x) (pull_request) Successful in 1m17s
node / check (pull_request) Successful in 1m17s
Le dépôt étend la base seulement (pas la couche Angular). Renovate tourne
depuis avalon-vanguard/renovate ; aucun workflow renovate à retirer ici.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:03:33 +02:00
SenrokaiandClaude Opus 5.5 e4b8c0f6aa CI sur Gitea Actions par le workflow commun avalon-vanguard/ci
.gitea/workflows/ci.yml appelle node.yml@v1 sur la même matrice (Node 20.x,
22.x, 24.x) et les mêmes déclencheurs que .github/workflows/ci.yml. Comme
node.yml ne connaît que lint, typecheck, test, build et check :
- « typecheck » renvoie à « type-check » ;
- « check » enchaîne, dans l'ordre de la CI GitHub, les contrôles
  post-build : points d'entrée publiés, démo, check:types, check:docs,
  check:docs-sync.

Les cinq « node -e » de chargement des points d'entrée passent dans
scripts/check-entry-points.mjs, que la CI GitHub appelle aussi : les deux
CI vérifient la même chose.

node.yml lance « npm test » et non « test:coverage » : vitest.config.ts ne
fixe aucun seuil et rien ne lit le rapport lcov, donc ce sont les mêmes
tests qui décident. La CI GitHub garde test:coverage.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:03:33 +02:00
SenrokaiandClaude Opus 5.5 68d5d46ec0 Config partagée : ESLint et tsconfig depuis @avalon-vanguard/config
eslint.config.mjs prend @avalon-vanguard/config/eslint, qui contient les deux
mêmes entrées qu'avant (@eslint/js recommended + typescript-eslint recommended) ;
les règles, globals et ignores propres à cereale restent ici. tsconfig.json
étend tsconfig/library.json, qui porte les huit options retirées. « strict »
reste explicite.

Pas de .editorconfig : sans config Prettier dans le dépôt, Prettier le lirait
et changerait le formatage.

.npmrc : le scope @avalon-vanguard se lit sur le registre npm de Gitea, en
lecture anonyme, sans jeton.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:03:23 +02:00
Senrokai 5f2e310f61 Merge pull request #16 from avalon-vanguard/develop
🔖 0.4.1 — the published README says the package is not on npm
2026-08-20 18:36:07 +02:00
Claude 057162e45b 🔖 fix: sync package-lock, and stamp the version fallbacks from the build
Junie's review on #16 caught the lock, and it was worse than reported:
package-lock.json said 0.3.0, so it had already missed the 0.4.0 release. It
does not ship (npm excludes it from tarballs, and `files` lists only dist, src,
FRAMEWORKS.md and CHANGELOG.md) and `npm ci` never complained because it only
diffs dependencies, not the project's own version — which is exactly why it
drifted two releases without anyone noticing. Regenerated with
`npm install --package-lock-only`.

The other half was the two version strings in docs/index.html: the brand badge
and the "It is still 0.x" line. Both are no-JavaScript fallbacks — page.js
overwrites them from meta.js — so they are right in a browser and stale in a
text reader or a scraper. Bumping them by hand is what failed on 0.4.0 and
again here, and it is the "release facts hand-bumped beside their generator"
finding from the code review.

So build-docs.mjs now stamps them, next to the meta.js it already writes. The
docs-sync gate turns a forgotten bump into a CI failure instead of a review
comment. Both regexes are asserted: renaming the markup fails the build with
the pattern that stopped matching, rather than silently stamping nothing —
verified by renaming the id and watching it exit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-20 16:34:49 +00:00
Claude 46e30909c7 🔖 fix: regenerate docs/meta.js for 0.4.1
The version bump left meta.js at 0.4.0, so CI's docs-sync gate failed on all
three Node versions — the gate doing exactly its job.

Worth naming why it got through locally: `npm run verify` does not chain
check:docs-sync, so a green verify says nothing about docs/ being current. I
briefly added it, then took it back out. check:docs-sync is built on
`git status`, so inside verify it would fail during any in-progress docs edit,
and docs/ does not ship in the tarball — the package `files` list is dist,
src, FRAMEWORKS.md and CHANGELOG.md. Publishing correctness never depended on
it. CI and the Pages workflow already gate the site, which is the only thing
that stale docs affect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-20 16:31:11 +00:00
Claude b2f2de2bd4 🔖 chore: 0.4.1 — the published README says the package is not on npm
The README inside a tarball is what npmjs.com renders, so the cereale package
page currently tells visitors: "npm install cereale does not resolve to this
library — the name is unclaimed on the registry." True when it was written,
nonsense on the page of the thing it describes.

Nothing could have prevented it. 0.4.0 was the first publish, so the docs could
only be corrected after the registry proved the claim wrong; the fix has to
ride a second version.

No code changes. Every emitted file is byte-identical to 0.4.0 except the
banner line of dist/cereale.min.js, which carries the version string — checked
by unpacking the published 0.4.0 tarball and diffing it against a fresh pack,
which is also why the changelog no longer claims dist/ is untouched. It said
that first, and it was wrong.

Patch rather than minor: no API surface moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-20 16:23:09 +00:00
Senrokai 1b9202edf1 Merge pull request #15 from avalon-vanguard/develop
📦 cereale is on npm — docs, badge, and trusted publishing
2026-08-20 18:18:05 +02:00
Claude 730e5902a5 🔎 ci: make the dry run prove trusted publishing engaged
npm calls oidc() before every dryRun branch in publish.js, so `npm publish
--dry-run` performs the real token exchange and the existing step is already a
trusted-publishing smoke test — it just could not be read.

The exchange is non-throwing by design, so at the default log level a working
OIDC exchange and a silent fallback to NPM_TOKEN look exactly the same. Raising
that one step to verbose surfaces `oidc Successfully retrieved and set token`,
which turns "did trusted publishing actually work?" into something a dry run
answers without publishing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-20 16:16:19 +00:00
Claude f56d2811cb 📦 docs: cereale is on npm, and release.yml moves to trusted publishing
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
2026-08-20 16:10:42 +00:00
Senrokai 7a8109a50e Merge pull request #14 from avalon-vanguard/develop
🌾 ci: only deploy Pages when docs/ actually changed
2026-08-20 16:49:00 +02:00
Claude 6adf26964e 🌾 ci: only deploy Pages when docs/ actually changed
Gating the deploy on CI success (#12) cost the paths filter, because
workflow_run cannot carry one: every green CI run on main redeployed the site,
including the README-only merge in #13 that touched no published byte.

The question is now asked in a `changes` job whose answer gates build and
deploy. It compares docs/ against the commit behind the newest *successful*
github-pages deployment — what is actually live — rather than against HEAD^.
That distinction is the whole point:

- a push carrying several commits may hide the docs change in any of them,
  and HEAD^ only sees the last one;
- if the previous deploy failed, the site is a version further behind than
  the previous commit suggests, and HEAD^ would skip the deploy that fixes it.

Deploys anyway, deliberately, when there is no successful deployment to
compare against, when the live commit is missing from history (force push),
and on workflow_dispatch — manual dispatch means "publish now", not "check
whether I need to".

Both checkouts now pin ref to github.event.workflow_run.head_sha. For
workflow_run the default checkout is the branch tip, not the commit CI
validated, so two pushes in quick succession could publish the newer tree
under the older one's green tick. Empty on workflow_dispatch, where the
dispatched ref is already what we want.

Verified by extracting the step's script from the YAML and running it against
this repo's real history with the API stubbed at curl: docs-identical head
skips; failed-newest-deploy deploys; missing/absent/dispatch all deploy; a
genuine docs change deploys and lists the files. actionlint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-20 14:36:58 +00:00
Senrokai 88e5ee23a9 Merge pull request #13 from avalon-vanguard/develop
📛 README badges, and an install section that tells the truth
2026-08-20 16:14:37 +02:00
Claude 41ea03f09b 📛 docs: badges on the README, and an install section that tells the truth
Five badges under the title: CI and Docs are live workflow badges (the Docs
one links to the deployed site), and Node, TypeScript and MIT are static,
tinted the brand brown from the landing page rather than shields' defaults.
No npm badge yet — the package is not on the registry, and a badge that
renders "not found" is worse than none. It gets added with the first publish.

The real fix hiding under the badges: Installation led with `npm install
cereale`, which does not resolve to this library — the name is unclaimed.
The section now says so and gives the clone/pack/install route the landing
page has carried since 0.4.0. Also synced the pins the page got and the
README missed (tsc 5.2+, Bun 1.3, NestJS 11.1), and the line describing the
docs site as "served from docs/ on main" now describes the Actions
deployment that replaced branch serving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-20 14:10:35 +00:00
Senrokai 73f0b88997 Merge pull request #12 from avalon-vanguard/develop
🌾 Landing page to 0.4.0, the grain identity, and Pages through Actions
2026-08-18 13:55:48 +02:00
Claude 4b910f19bf 🔍 fix: apply the code review — two visible regressions, and the gates behind them
Nine review angles, fourteen verified findings, all but the byte-table
generator applied. The two that mattered most were regressions of mine:

- a:hover repainted button-styled anchors, and in dark theme --accent-text
  equals --accent-solid — hovering the hero CTA drew its label in its own
  background colour. Verified invisible before (computed color == computed
  background) and distinct after: 10.39:1 dark, 6.90:1 light. The buttons and
  the skip link now re-assert their label colours on hover.
- Footer links had lost every non-hover affordance: the text-decoration:none
  carve-out plus body-coloured links left a 2.37:1 shade difference as the
  only cue. The carve-out is deleted — .nav-links a and .brand already
  declare none themselves — and the accent rule is back on the footer.

Also on the page: #ref-filter, the one text input, moves to --border-ui (it
still had the 1.68:1 border the token's own comment calls decorative);
focusable code surfaces get the --accent-on-code ring at -2px offset, inside
the .code overflow clip; #output .out-err drops #ff8095, the last surviving
colour of the deleted indigo palette; the two rgba(255,106,126) washes become
color-mix over --err-line so a grep for the token finds them.

The theme machinery loses a whole block: the dark media query is guarded with
:not([data-theme="light"]), so an explicit light toggle falls through to the
bare :root palette and the 21-token hand-copy in [data-theme="light"] is
gone. Verified in all four system/toggle combinations.

The page stops contradicting the repo: it claimed cereale/min "cannot
tree-shake — nothing left to shake" while src/treeshake.test.ts proves the
opposite on every run. Corrected here and in FRAMEWORKS.md, with the measured
figures (1,837 vs 1,996 bytes for one decorator). The release facts the page
was hand-bumping — both tgz names, "0.4.0 lives in the repository", the
sixty-eight — now fill from meta.js/the bundle like the version badge always
has.

The workflows close three holes:

- The docs sync gate was blind to NEW untracked build outputs (git diff does
  not report them; demonstrated). Both workflows now run check:docs-sync, one
  shared script that fails on anything porcelain reports — which also ends
  the copy-paste divergence between them.
- pages.yml deploys on CI succeeding on main (workflow_run) instead of on the
  push itself, so a deploy implies green tests, not just in-sync docs. The
  deploy job refuses refs other than main, closing the workflow_dispatch
  any-branch deploy, and the build job drops pages/id-token — npm postinstall
  scripts no longer run alongside an OIDC grant.
- The Junie action is pinned to the commit behind v1.7.4 rather than the tag,
  which is the immutability the previous comment promised but a mutable ref
  cannot deliver.

Verified: every fix confirmed in a rendered browser in both themes; 72
contrast pairs still pass; no overflow at 20 widths; 268 tests, build,
check:types, check:docs, actionlint all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-17 16:23:46 +00:00
Claude f4c5e214f9 🤖 ci: pin the Junie action to its newest release, v1.7.4
Checked against the action repo itself rather than assumed: git ls-remote
shows v1.7.4 is the newest release tag and the moving v1 tag points at the
same commit (c2ae82f), so this changes which ref is named, not which code
runs — today. What it buys is that the version running stays the version
that was reviewed here, instead of silently following wherever v1 moves.

There was never a v0 reference in this workflow, and there is no api-key
presence check to remove — the action is invoked directly and fails loudly
if JUNIE_API_KEY is absent, which is the intended behaviour.

For the record, since this commit will re-trigger the job on PR #12: the two
failures there today are neither the workflow nor the Junie balance. Both
died in under ten seconds at the action's GitHub permission pre-check with
GitHub's own 503 body ("No server is currently available to service your
request"), before any Junie API call was made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-17 14:29:33 +00:00
Claude 60cb8303ed 🚀 ci: deploy Pages through Actions instead of serving the branch
Pages currently serves main /docs directly, which publishes whatever is
committed with no check between the push and the live site. This builds the
page from src/, asserts the committed bundle matches it, and asserts the page
still loads nothing from the network — then uploads. A stale or
network-dependent page fails the job instead of going live.

Shape is GitHub's own starter pairing: configure-pages@v5,
upload-pages-artifact@v3, deploy-pages@v5, verified to exist at those tags.
Deploy is a separate job with the pages/id-token permissions scoped to it.
`cancel-in-progress: false`, because a half-published site is worse than a
stale one — pushes queue rather than interrupt a deploy already going out.

Triggered on docs/ rather than src/: docs/ carries the generated bundle, so a
src/ change only reaches the site once it has been rebuilt into docs/, which
is what the CI sync check already enforces.

Needs a one-time setting before it can work: Settings → Pages → Source must
be "GitHub Actions" rather than "Deploy from a branch". Until then the deploy
job fails on permissions. That switch needs administration:write, which
GITHUB_TOKEN does not have, so it cannot be made from CI. It is reversible —
setting Source back to a branch restores the current behaviour.

actionlint clean across all four workflows; the build job's steps were run
locally in order and pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-08 12:21:00 +00:00
Claude c13aff9f2b 🌾 docs: give the page the identity its name implies
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
2026-08-07 13:42:46 +00:00
Claude 1720f2274d 📄 docs: bring the landing page up to 0.4.0
The page still described 0.3.0. Two of the stale strings were in a shell
block a visitor is meant to copy — `npm pack # → cereale-0.3.0.tgz` then
`npm install ../cereale/cereale-0.3.0.tgz`, which is an ENOENT for anyone
following the install instructions today.

Corrected: five version strings (the badge fallback, the pack/install pair,
the "lives in the repository" claim, and the diagnosability paragraph, which
becomes past tense rather than 0.4.0); seven GitHub URLs using the
redirect-only capitalised org name, which the framework table added in 0.4.0
had already stopped doing; NestJS pinned to 11.1 and Bun to 1.3, matching
FRAMEWORKS.md, where every other row was already pinned; `tsc` given its 5.2
floor, which the page had never stated anywhere.

Added, all of it absent before:

- A bundle-size section with the measured table across esbuild, rollup and
  webpack, the independence of the serializer and deserializer, and why the
  regression was invisible to a single-bundler measurement.
- `cereale/min` in the install section, with the import-map form and the
  caveat that leads FRAMEWORKS.md — if you have a bundler, do not use it.
- Hero: a third shipped format, and the size fact that the 68-decorator chip
  invites.
- The verification date on the framework claim; "the three ✓ rows" made
  unambiguous now that the section has a second table.

Every figure is quoted from CHANGELOG.md or FRAMEWORKS.md rather than
recalled. check:docs passes, the generated files stay in sync, and the markup
parses with no unclosed or mismatched tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-07 13:35:17 +00:00
Senrokai a2deaffe0b Merge pull request #11 from avalon-vanguard/develop
🌳 Release 0.4.0 — tree-shaking, and `cereale/min`
2026-08-07 14:12:05 +02:00
Claude fb84d82c84 🤖 ci: add a Junie code review workflow
Runs JetBrains' Junie agent on every PR into main or develop, using the
action's built-in `code-review` prompt rather than a free-form instruction.

Details worth keeping:

- `use_single_comment` plus a `concurrency` group keyed on the PR number, so a
  burst of pushes leaves one review of the final state rather than a queue of
  reviews of intermediate ones.
- Skipped explicitly on fork PRs. `pull_request` does not expose secrets to
  them, so the job would otherwise fail on an empty API key — a skipped job
  reads as "not applicable", a failed one as "broken".
- `contents: read`. This workflow reviews; it does not push.
- Not a required check, on purpose. CI gates merges; a review that can block
  one on a judgement call is a review that gets rubber-stamped.

Needs a JUNIE_API_KEY repository secret before it will do anything but fail.
Pinned to @v1, whose action.yml declares each of the three inputs used here.
actionlint clean across all three workflows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-07 11:41:16 +00:00
Claude 5714fab42e 🧹 refactor: cut the duplicated checks and prose from the tree-shaking work
Acting on ponytail-review. The findings were about verification written twice
and comments restating the CHANGELOG, not about the fixes themselves.

- dist/cjs/package.json had two writers: the build script echoed it, then
  build-bundle.mjs rewrote it three lines later with the sideEffects entry.
  One writer now, the one that knows what belongs in it.
- Dropped the banner-version assert. Same process, same `pkg.version` going in
  and coming out — the "stale bundle" it claimed to catch cannot happen.
- Dropped the `includes('toInstanceSync')` text check. ci.yml imports the flat
  bundle and typeof-checks the export, which is the same claim actually tested.
- Dropped the treeshake case asserting annotations survive minification; the
  build already asserts it. Its one non-duplicated assertion was the floor on
  the expected count, and that gap was real: the build compared flat >= esm, so
  if tsc ever stopped emitting annotations both sides would read 0 and the
  assert would pass on nothing. Folded in as an explicit `expected < 30` check,
  verified by stripping the annotations and watching the build fail.
- Removed the `CEREALE` placeholder from treeshake.test.ts. A template language
  for one variable, with two no-op `.replace('CEREALE', 'unused')` calls left
  behind by it. Interpolated directly.
- Removed `.replace(/\.js$/, '.js')`, which was the identity function.
- Trimmed the comment above the `Symbol.metadata` install from 16 lines to 7,
  and the one above `minifySyntax` from 9 to 3, keeping the parts that are not
  written down anywhere else.

Also synced the CHANGELOG's byte table to the README's. The two had already
diverged in the webpack column — which is the duplication the review warned
about, showing up before anyone edited either on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-06 03:47:29 +00:00
Claude 3bd190bde6 🌳 fix: keep the Symbol.metadata install through tree-shaking
`sideEffects` named metadata.js, the module holding the global install, but not the
barrel that re-exports it. A side-effect-free barrel is droppable whole, so every
bundler pruned the `export * from './metadata.js'` edge before metadata.js's own
marking was ever consulted, and the install vanished.

Measured on `import { configure } from 'cereale'`: 145 bytes through esbuild, 143
through webpack, 144 through rollup, with `Symbol.metadata` absent from all three.
It matters because tsc's decorator emit reads the well-known symbol directly —

    const _metadata = typeof Symbol === "function" && Symbol.metadata
      ? Object.create(null) : void 0;

so without it a decorated class is handed `metadata: undefined` and ends up with no
rules at all. Two places in the source promised this survived bundling; it did not.

index.js and index.ts are listed now. The cost is ~100 bytes and lands only on
imports that reach nothing else: the single-decorator, validateSync, toPlainSync,
toInstanceSync and whole-library cases are byte-identical before and after, across
all three bundlers. dist/cjs needs nothing — tsc emits `__exportStar(require(...))`,
an unconditional statement no bundler can drop.

Two new cases in treeshake.test.ts pin it, one on src and one on dist/esm, since
those are separate manifest paths and a typo in either is invisible from the other.
Both were checked by removing the entry and watching them fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-05 17:22:16 +00:00
Claude c48a106a05 🌳 fix: make cereale/min tree-shake too, and correct what I overclaimed
An audit of the last commit found two real problems and several claims of
mine that went further than the evidence.

**cereale/min was not tree-shakable.** scripts/build-bundle.mjs used
esbuild's `minify: true`, whose minifyWhitespace pass strips comments —
/*#__PURE__*/ annotations included. The published entry point therefore
reproduced exactly the bug the previous commit fixed: one decorator came
out at 5,066 bytes with all 26 unrelated rule messages, against 1,837 from
the per-module entry. Every source-level check stayed green, because they
all bundled src/ and the annotations are stripped on the way into dist/.

It is now minified for syntax and identifiers but not whitespace: 33.9 KB
raw and 9.6 KB gzipped against 26.0/8.7, so about a kilobyte over the wire
for a file that behaves correctly however it is used. One decorator via
cereale/min is now 1,996 bytes. The build asserts the annotation count
survives, and treeshake.test.ts now bundles the published artifact as well
as the source — the gap that let this through.

**sideEffects was partly inert.** `./dist/cjs/metadata.js` could never
match: the build writes dist/cjs/package.json, which becomes the nearest
descriptor for everything beneath it, so bundlers read sideEffects from
there. That file now carries its own declaration. `./src/metadata.ts` was
missing while src/ is published, which declared the Symbol.metadata install
droppable in the source tree. Five module-scope caches in utils.ts are
annotated for the same reason as the rules.

I checked the audit's third blocker — that the Symbol.metadata install is
dropped by bundlers — and it is not. It survives every case where it is
load-bearing (a decorator import, toPlainSync, modelOf, and a decorated
model bundled with an app). It is dropped only when importing nothing but
flattenErrors, which needs no metadata, so that is correct.

Corrections to my own wording:
- "all three bundlers land within 20 bytes" held only for the one-decorator
  row; larger imports differ by up to a few hundred bytes
- "measured through three bundlers, and pinned by a test" read as though the
  test covered all three; it covers esbuild, on source and on dist
- "every rule is a top-level call" — 30 of the 68 are
- the docs page "loads nothing from the network" — it fetches its own
  vendored compiler, same-origin, on first Run. It has no third-party
  dependencies, which is the claim I should have made
- cereale/min's size, everywhere it appears

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-05 16:41:32 +00:00
Claude 2a2f7345ad 🌳 perf: make the library actually tree-shakable
Importing one decorator pulled in the message and validator of all 68 —
4,909 bytes instead of 1,837 through esbuild, 4,823 instead of 1,818
through webpack. Nothing failed and nothing warned. The library was simply
about three times heavier than it needed to be in every consumer's bundle.

Every rule is a top-level call: `export const IsString = rule(...)`. rollup
proves such a call side-effect-free by reading the factory, which is why it
was already emitting 1,823 bytes — and why a single-bundler measurement
would have shown no problem at all. esbuild and webpack do not do that
analysis and keep the call. Thirty declarations now carry /*#__PURE__*/,
which tsc preserves into the ESM emit, and all three bundlers now land
within 20 bytes of each other.

Measured, minified, esbuild / rollup / webpack:

  flattenErrors      287 /   292 /   291
  one decorator     1837 /  1823 /  1818
  validateSync      3722 /  3554 /  3823
  toPlainSync       7744 /  7769 /  7832
  toInstanceSync    7900 /  7942 /  7956
  a typical DTO    10395 / 10402 / 10372
  everything       26266 / 25671 / 26879

The serializer and deserializer drop independently — read JSON and you do
not pay for writing it. Both mapping entry points keep the validator,
because `validate` defaults to true and that is a real reference rather
than a missed optimisation.

The annotations are a promise to the bundler, so I checked the three
factories they cover: rule, pattern and affix each return a closure and
touch nothing outside themselves. A false promise here would mean silent
deletion in someone else's production build.

src/treeshake.test.ts pins the property. It asserts content rather than
only bytes — it names the rules that must not appear — and one case asserts
everything IS present when everything is used, so a "shaken" result cannot
come from a bundle that failed to build. That mattered: two earlier passes
at this measurement reported a clean sweep of shaken symbols because rollup
had failed to resolve its entry and grep was reading missing files as
absence. Strip the annotations and the test fails with
`"must be a latitude" should have been shaken out`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-05 16:20:19 +00:00
Claude b4f0657d09 📦 feat: add cereale/min, and a verified framework guide
**cereale/min** — the library flattened into one minified ES module,
25.5 KB / 8.6 KB gzipped, for import maps, <script type="module">, Deno
and Workers. Built from dist/esm/index.js, so the decorator lowering and
the ES2025 target are tsc's; esbuild only flattens and minifies.

It is an addition rather than a replacement, and the measurement is the
reason. Bundled through esbuild the flat and per-module builds land within
2 bytes of each other; through rollup + terser the flat one is 165 bytes
smaller; unused decorators tree-shake out of both. With the size argument a
wash, per-module stays the default import for the one thing it does better
— readable stack traces without source maps. (My first pass at that
measurement reported "shaken" for every symbol because both rollup builds
had failed and grep was reading missing files as absence. The check now
asserts the bundle is non-empty and that a *used* symbol is present, so it
can tell a real result from a broken harness.)

**FRAMEWORKS.md** — a recipe per framework, each one run before it was
written, with the versions and date verified against.

Angular works, which was not obvious: the CLI scaffolds
experimentalDecorators: true, but ngtsc erases @Component and @Injectable
into static properties rather than leaning on TypeScript's decorator emit.
Flip the flag and both systems coexist. Verified with ngc on Angular 21.2
with strictTemplates — templates still type-check and a wrong cereale rule
is still TS1240 inside the Angular build.

Next.js cannot work inline, structurally: it derives both the SWC parser's
decorator support and the transform mode from the one flag, so on gives
legacy emit and off makes @ a syntax error. NestJS cannot either — its DI
needs design:type from emitDecoratorMetadata.

Both have the same answer: keep the cereale classes in a package compiled
by tsc and import the built output. Verified inside a program with BOTH
legacy flags on, alongside @Injectable() — mapping and validation work, and
the compile-time guarantee still holds where the rules are written.

Also verified: Bun 1.3 needs no configuration, and a real Vite 8 build with
the plugin works where the same build without it silently leaves decorator
syntax in the bundle.

Version 0.4.0: cereale/min is a new public entry point, and cutting a minor
keeps the existing v0.3.0 tag meaningful instead of force-moving it onto a
commit it was never cut from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-05 16:00:26 +00:00
26 changed files with 1585 additions and 211 deletions
+25
View File
@@ -0,0 +1,25 @@
# Contrôles de .github/workflows/ci.yml sur Gitea, par le workflow commun avalon-vanguard/ci.
# Ce dossier fait ignorer .github/workflows par Gitea ; GitHub continue d'exécuter les siens
# (CI, Pages, publication npm). Les contrôles post-build (points d'entrée, démo, types publiés,
# page de docs) sont regroupés dans le script « check ».
#
# node.yml lance « npm test », pas « test:coverage » : vitest.config.ts ne fixe aucun seuil de
# couverture et rien ne lit le rapport lcov, donc les mêmes tests décident du résultat.
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
verify:
strategy:
fail-fast: false
matrix:
node: [20.x, 22.x, 24.x]
uses: avalon-vanguard/ci/.gitea/workflows/node.yml@v1
with:
node-version: ${{ matrix.node }}
secrets: inherit
+3 -9
View File
@@ -34,11 +34,7 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Verify published entry points load - name: Verify published entry points load
run: | run: node scripts/check-entry-points.mjs
node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');"
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
node --input-type=module -e "import { standardDecorators } from './dist/esm/vite.js'; if (standardDecorators().enforce !== 'pre') throw new Error('ESM cereale/vite broken');"
node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');"
- name: Run Demo - name: Run Demo
run: npm run demo run: npm run demo
- name: Published types stand alone - name: Published types stand alone
@@ -46,7 +42,5 @@ jobs:
- name: Landing page loads nothing from the network - name: Landing page loads nothing from the network
run: npm run check:docs run: npm run check:docs
- name: Landing page bundle is in sync with src/ - name: Landing page bundle is in sync with src/
run: | # Also fails on NEW untracked files under docs/ — a plain `git diff` does not.
npm run build:docs run: npm run check:docs-sync
git diff --exit-code -- docs/ \
|| (echo "docs/ is stale — run 'npm run build:docs' and commit the result" && exit 1)
+52
View File
@@ -0,0 +1,52 @@
name: Junie Review
# Automated PR review by Junie, JetBrains' coding agent. Advisory only: it posts a
# summary and inline comments, and is deliberately not a required check — CI is what
# gates a merge, and a review that can block one on a judgement call is a review that
# gets rubber-stamped.
#
# Requires a JUNIE_API_KEY repository secret (Settings → Secrets and variables →
# Actions). Without it the action fails at the first step rather than skipping, so
# add the secret before merging this file.
on:
pull_request:
types: [ opened, synchronize, reopened ]
branches: [ main, develop ]
# A PR that gets three pushes in a minute should end up with one review of the final
# state, not three reviews of intermediate ones. Combined with use_single_comment
# below, each PR keeps exactly one review comment, rewritten as the diff changes.
concurrency:
group: junie-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
name: Junie
runs-on: ubuntu-latest
# Secrets are not exposed to `pull_request` runs originating from a fork, so a
# fork PR would fail on an empty API key rather than review anything. Skip those
# explicitly — a skipped job reads as "not applicable", a failed one as "broken".
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read # read the diff; Junie does not push from this workflow
pull-requests: write # post the review summary and inline comments
issues: write # the PR conversation is an issue timeline to the API
steps:
- uses: actions/checkout@v4
- name: Review the pull request
# Pinned to the commit behind release v1.7.4. A tag is a mutable ref the
# publisher can repoint; only the SHA guarantees the version running is the
# version that was reviewed — this workflow handles a repo secret. Bump by
# resolving the new release's commit, not by moving the tag name alone.
uses: JetBrains/junie-github-action@c2ae82fc9fbe0eb81942ceb3d9bd3f89a6b17b95 # v1.7.4
with:
junie_api_key: ${{ secrets.JUNIE_API_KEY }}
# Built-in structured review prompt, as opposed to a free-form instruction.
prompt: "code-review"
# Update one comment across re-runs instead of appending a new one per push.
use_single_comment: "true"
+156
View File
@@ -0,0 +1,156 @@
name: Deploy Pages
# Publishes docs/ to GitHub Pages through Actions rather than by serving the branch
# directly, so the page is rebuilt from src/ and checked before it goes live instead
# of after.
#
# Triggered by CI completing on main rather than by the push itself, so a deploy
# implies the full suite passed — type-check, lint, tests, build, entry points, docs.
# A push that breaks a test turns main red and never reaches Pages. workflow_dispatch
# stays as the manual escape hatch, and the deploy job refuses any ref that is not main.
#
# workflow_run cannot carry a `paths:` filter the way `push:` can, so the docs-changed
# question is asked in a job instead — see the `changes` job below.
#
# REQUIRES A ONE-TIME SETTING. Settings → Pages → Build and deployment → Source must
# be "GitHub Actions", not "Deploy from a branch". Until it is, the first run fails —
# in the build job at configure-pages if Pages was never enabled, or in the deploy
# job with "Resource not accessible by integration" if Pages still serves a branch.
# Either way the workflow is correct; the repository setting is what needs to move.
# The switch cannot be made from here: it needs administration:write, which
# GITHUB_TOKEN is not. It is reversible — setting Source back to a branch restores
# the old behaviour and this workflow simply stops being able to deploy.
on:
workflow_run:
workflows: [ CI ]
types: [ completed ]
branches: [ main ]
workflow_dispatch:
# Never cancel a deploy in flight: a half-published site is worse than a stale one.
# Queue instead, so the last push wins without interrupting the one already going out.
concurrency:
group: pages
cancel-in-progress: false
jobs:
changes:
name: Did docs change?
runs-on: ubuntu-latest
# workflow_run fires on failure too — deploying is the one thing that must not.
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
deployments: read
outputs:
docs: ${{ steps.check.outputs.docs }}
steps:
- uses: actions/checkout@v4
with:
# For workflow_run the default checkout is the branch tip, which is not
# necessarily the commit CI just validated — two pushes in quick succession
# would deploy the newer one under the older one's green tick. Empty on
# workflow_dispatch, where the dispatched ref is what we want.
ref: ${{ github.event.workflow_run.head_sha }}
# Deep enough to reach whatever commit is currently live.
fetch-depth: 0
- id: check
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — deploying whatever the state of docs/ is."
echo "docs=true" >> "$GITHUB_OUTPUT"
exit 0
fi
api() { curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$@"; }
BASE="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
# What is actually live: the commit behind the newest *successful* Pages
# deployment. Comparing against that, rather than against HEAD^, is what keeps
# this correct when a push carries several commits (the change may be in any of
# them) and when the last deploy failed (the site is then a version further
# behind than the previous commit suggests).
DEPLOYMENTS=$(api "$BASE/deployments?environment=github-pages&per_page=10")
LIVE=""
for id in $(echo "$DEPLOYMENTS" | jq -r '.[].id'); do
state=$(api "$BASE/deployments/$id/statuses?per_page=1" | jq -r '.[0].state // empty')
if [ "$state" = "success" ]; then
LIVE=$(echo "$DEPLOYMENTS" | jq -r --argjson id "$id" '.[] | select(.id == $id) | .sha')
break
fi
done
if [ -z "$LIVE" ]; then
echo "No successful github-pages deployment to compare against — deploying."
echo "docs=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "${LIVE}^{commit}" 2>/dev/null; then
echo "Live commit $LIVE is not in this history (force push, or rebuilt branch) — deploying."
echo "docs=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Live on Pages: $LIVE"
echo "This commit: $GITHUB_SHA"
if git diff --quiet "$LIVE" HEAD -- docs/; then
echo "docs/ is byte-identical to what is already published — nothing to deploy."
echo "docs=false" >> "$GITHUB_OUTPUT"
else
echo "docs/ changed:"
git diff --name-only "$LIVE" HEAD -- docs/ | sed 's/^/ /'
echo "docs=true" >> "$GITHUB_OUTPUT"
fi
build:
name: Build and check
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
# This job runs third-party code (npm postinstall scripts, the build toolchain),
# so it gets read-only. The Pages/OIDC grants live on the deploy job alone.
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: The committed page is in sync with src/
# Rebuilds from src/ and fails on any difference, new untracked files included.
# Same script CI runs, so the two workflows cannot drift apart.
run: npm run check:docs-sync
- name: The page loads nothing from the network
run: npm run check:docs
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: ./docs
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
# workflow_dispatch can be pointed at any branch; production only ever serves main.
if: github.ref == 'refs/heads/main'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+54 -5
View File
@@ -4,10 +4,28 @@ name: Publish to npm
# not a decision to make it public and immutable, and npm's 72-hour unpublish window makes the # not a decision to make it public and immutable, and npm's 72-hour unpublish window makes the
# second one hard to take back. Run this workflow from the Actions tab when you mean it. # second one hard to take back. Run this workflow from the Actions tab when you mean it.
# #
# Before the first real run: # Authentication is by **trusted publishing** (OIDC): npm exchanges the workflow's
# 1. Create an npm automation token and add it as the NPM_TOKEN repository secret. # short-lived GitHub identity token for a publish token scoped to this package, so no
# 2. Run once with dry_run left as `true` and read the file list it prints. # long-lived npm token has to exist. To enable it, on npmjs.com → the cereale package →
# 3. Run again with dry_run set to `false`. # Settings → Trusted publisher → GitHub Actions, set:
#
# Organization or user: avalon-vanguard
# Repository: cereale
# Workflow filename: release.yml
# Environment: (leave blank — this workflow does not use one)
#
# The workflow filename must match this file's name, and the Environment field must be
# blank unless a matching `environment:` is added to the publish job; npm checks both
# against the OIDC claims and refuses the exchange if either disagrees.
#
# NPM_TOKEN is kept as a fallback. npm's OIDC step is deliberately non-throwing — if the
# trusted publisher is not configured, or the CLI is too old, it logs and falls through
# to token auth. That makes the switch safe to land before the registry side is set up.
# Once a real run shows OIDC working, the NPM_TOKEN secret can be deleted.
#
# Before a real run:
# 1. Run once with dry_run left as `true` and read the file list it prints.
# 2. Run again with dry_run set to `false`.
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -18,7 +36,7 @@ on:
permissions: permissions:
contents: read contents: read
id-token: write # required for npm provenance id-token: write # the OIDC identity npm exchanges, and provenance
jobs: jobs:
publish: publish:
@@ -37,6 +55,21 @@ jobs:
cache: 'npm' cache: 'npm'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
# Node 22 bundles npm 10.x, which has no OIDC support at all — it would skip
# trusted publishing in silence and fall back to token auth. Trusted publishing
# landed in npm 11.5.0 (lib/utils/oidc.js), so the version is raised and then
# asserted rather than assumed.
- name: Use an npm that can do trusted publishing
run: |
npm install -g npm@latest
V=$(npm --version)
echo "npm $V"
MAJ=${V%%.*}; REST=${V#*.}; MIN=${REST%%.*}
if [ "$MAJ" -lt 11 ] || { [ "$MAJ" -eq 11 ] && [ "$MIN" -lt 5 ]; }; then
echo "::error::npm $V has no OIDC support; trusted publishing needs >= 11.5.0"
exit 1
fi
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -72,12 +105,28 @@ jobs:
run: npm run verify run: npm run verify
- name: Show exactly what would ship - name: Show exactly what would ship
# `npm publish --dry-run` performs the OIDC token exchange before it short-circuits
# (publish.js calls oidc() ahead of every dryRun branch), so this step is also the
# trusted-publishing smoke test — a dry run proves the exchange without publishing.
#
# verbose, because npm's OIDC step is non-throwing: at the default log level a
# successful exchange and a silent fallback to token auth look identical. Success
# prints `oidc Successfully retrieved and set token`; if that line is missing,
# trusted publishing did not engage. (The reasons it skips are logged at silly.)
env:
NPM_CONFIG_LOGLEVEL: verbose
run: npm publish --dry-run run: npm publish --dry-run
- name: Publish - name: Publish
if: ${{ inputs.dry_run == false }} if: ${{ inputs.dry_run == false }}
# --provenance stays explicit. Under OIDC npm would enable it on its own for a
# public repo, but only when the flag was left at its default; asking for it
# directly just skips that auto-enable and reaches the same place. On the token
# fallback path it is the only thing that produces an attestation at all.
run: npm publish --provenance --access public run: npm publish --provenance --access public
env: env:
# Fallback only. Ignored once the trusted publisher is configured, because npm
# sets its own short-lived token before reading credentials.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Summary - name: Summary
+1
View File
@@ -0,0 +1 @@
@avalon-vanguard:registry=https://git.avalonvanguard.com/api/packages/avalon-vanguard/npm/
+127
View File
@@ -5,6 +5,133 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.1] - 2026-08-20
No code changes. Every emitted file is byte-identical to 0.4.0 except the banner line of
`dist/cereale.min.js`, which carries the version string. This exists because the README
inside the 0.4.0 tarball is the one npmjs.com renders, and it said the package was not on
npm: *"`npm install cereale` does not resolve to this library — the name is unclaimed on
the registry."* True when it was written, nonsense on the package page of the thing it
describes.
0.4.0 was the first publish, so nothing could have carried the corrected text: the docs
could only be fixed after the registry proved the claim wrong. The install instructions,
the landing page panel, and the "what cereale is not" entry now say `npm install cereale`,
and the README carries an npm version badge.
## [0.4.0] - 2026-08-05
### `cereale/min` — one file, no bundler
The whole library flattened into a single minified ES module: **33.9 KB, 9.6 KB gzipped**, for
import maps, `<script type="module">`, Deno and Workers. It is built from `dist/esm/index.js`,
so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only
flattens and minifies.
It is an addition, not a replacement. The per-module build stays the default `import`: it keeps
readable stack traces for anyone not loading source maps, and it is what a bundler should be
given.
The flat file is minified for syntax and identifiers but **not** whitespace. Full minification
strips comments — including the `/*#__PURE__*/` annotations below — which silently made
`cereale/min` un-tree-shakable: one decorator came out at 5,066 bytes against 1,837 from the
per-module entry, with all 26 unrelated rule messages back in the output. Keeping the
annotations costs about a kilobyte gzipped and is asserted by the build.
### Tree-shaking
Importing one decorator pulled in the message and validator of all 68. **4,909 bytes instead of
1,837** through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing
warned; the library was simply about three times heavier than it needed to be in every
consumer's bundle.
The cause is that every rule is a top-level call — `export const IsString = rule(…)`. rollup
proves such a call side-effect-free by reading the factory, which is why rollup was already
producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and
webpack will not do that analysis, and keep the call. Thirty declarations now carry
`/*#__PURE__*/`. On the single-decorator import that exposed the problem the three bundlers now
land within 19 bytes of each other; on larger imports they still differ by up to a few hundred,
which is ordinary bundler variation rather than anything left unshaken.
Measured, minified, across esbuild / rollup / webpack:
| What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: |
| `flattenErrors` | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,738 |
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
| a typical DTO | 10,395 | 10,402 | 10,360 |
| everything | 26,266 | 25,671 | 26,879 |
The serializer and deserializer drop independently. The validator is kept by both mapping
entry points because `validate` defaults to `true`, which is a real reference rather than a
missed optimisation.
`src/treeshake.test.ts` pins it. The assertions are mostly about content rather than bytes — it
names the rules that must not appear — and one case asserts that everything IS present when
everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip
the annotations and it fails with `"must be a latitude" should have been shaken out`.
The same pass shook out something that was supposed to stay. Cereale installs `Symbol.metadata`
when the runtime lacks it, and `sideEffects` named the module holding that install — but not the
barrel that re-exports it. A side-effect-free barrel is droppable as a whole, so all three
bundlers pruned the `export * from './metadata.js'` edge before metadata.js's own marking was
ever consulted: `import { configure } from 'cereale'` came out at 145 bytes through esbuild, 143
through webpack and 144 through rollup, with `Symbol.metadata` in none of them. That matters
because `tsc`'s decorator emit reads the well-known symbol directly — `typeof Symbol ===
"function" && Symbol.metadata ? Object.create(null) : void 0` — so without the install a
decorated class gets `metadata: undefined`, which is to say no rules at all.
`index.js` and `index.ts` are now listed too. It costs about 100 bytes, and only on imports that
reach nothing else; every row of the table above except the first was byte-identical before and
after, across all three bundlers. Two more cases in `treeshake.test.ts` pin it, one on the source
and one on `dist/esm`, because those are separate paths in the manifest and a typo in either is
invisible from the other side.
### Frameworks
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
with the versions and date it was verified against.
The finding worth stating first: **Angular works**. The CLI scaffolds
`"experimentalDecorators": true`, but Angular does not need it — `ngtsc` erases `@Component`
and `@Injectable` into static properties rather than relying on TypeScript's decorator emit.
Flip the flag and both systems work in one program. Verified with `ngc` on Angular 21.2 with
`strictTemplates`: templates still type-check, and a wrong cereale rule is still a compile
error inside the Angular build.
**Next.js cannot work inline**, and the reason is structural rather than a missing option. It
derives *both* the SWC parser's decorator support and the transform mode from the single
`experimentalDecorators` flag, so the flag on gives legacy emit that cereale refuses, and the
flag off makes `@` a syntax error. There is no third setting.
**NestJS cannot work inline** either: its dependency injection genuinely needs the
`design:type` metadata only `emitDecoratorMetadata` produces.
Both have the same answer, and it is better than it sounds: put the cereale classes in a
package compiled by `tsc` and import the built output. The decorators run at class-definition
time inside that package, so the app only ever sees plain JavaScript and its own decorator
setting stops mattering. Verified inside a program with **both** legacy flags on, running
alongside `@Injectable()` — mapping and validation work normally, and the compile-time
guarantee still holds where the rules are written.
Also verified: **Bun** 1.3 needs no configuration at all, and a real Vite 8 build with the
`cereale/vite` plugin produces working output where the same build without it silently leaves
decorator syntax in the bundle.
### Packaging
`FRAMEWORKS.md` ships with the package. `sideEffects` now lists the flat bundle, which inlines
the `Symbol.metadata` install.
### Why 0.4.0 and not 0.3.1
`cereale/min` is a new public entry point, which is a minor bump under 0.x. It also keeps the
existing `v0.3.0` tag meaningful instead of force-moving it onto a commit it was never cut
from.
## [0.3.0] - 2026-08-05 ## [0.3.0] - 2026-08-05
Every change here comes from the same question: where does cereale currently fail *quietly*? Every change here comes from the same question: where does cereale currently fail *quietly*?
+344
View File
@@ -0,0 +1,344 @@
# Using cereale with your framework
Every recipe here was run before it was written. Where something does not work, this says so
rather than offering a workaround that has not been tried.
## The only requirement
cereale reads the metadata that a **TC39 standard** decorator transform emits. That means your
compiler must have `experimentalDecorators` **off** — which is the default in TypeScript 5.0
and later, but not what several frameworks scaffold.
There is no partial credit and no per-file override: `experimentalDecorators` is a property of
a TypeScript *program*, so every decorator in one compilation uses the same system. If the flag
is on, `tsc` refuses cereale's decorators outright:
```
error TS1240: Unable to resolve signature of property decorator when called as an expression.
Argument of type 'User' is not assignable to parameter of type 'undefined'.
```
and if you skip type-checking (esbuild, swc and Babel all strip types without checking them),
the emit reaches cereale as a legacy call and it says so by name rather than failing obscurely.
## Two ways to adopt it
**Inline** — write cereale decorators directly in your app. Needs `experimentalDecorators: false`.
This is what you want, and most toolchains allow it.
**A precompiled models package** — when the program is committed to legacy decorators for
something else (NestJS's DI, TypeORM entities, Next's SWC pipeline), put your cereale classes in
a separate package compiled by `tsc`, and import the built output. The decorators run at class
definition time inside that package; your app only ever sees plain JavaScript, so its own
decorator setting is irrelevant. Verified working inside a program with **both**
`experimentalDecorators: true` and `emitDecoratorMetadata: true`.
You keep the compile-time guarantee where it matters — in the package where the rules are
written — and lose nothing at runtime.
## Support
Verified on the versions listed, on 2026-08-05. ✅ inline, ⚠️ via a precompiled package.
Rows marked — were not tested; they are listed so their absence is not mistaken for a verdict.
| Toolchain | | Notes |
| --- | --- | --- |
| `tsc` 5.2+ | ✅ | `experimentalDecorators: false`, `target: ES2022`+ |
| **Angular** 21 | ✅ | flip the scaffolded `experimentalDecorators` to `false` — Angular does not need it |
| **Vite** 8 | ✅ | add `cereale/vite`; covers React, Vue, Svelte, Solid, Qwik, Astro, Nuxt, SvelteKit |
| **Vitest** 4 | ✅ | same plugin |
| **Bun** 1.3 | ✅ | works with no configuration |
| esbuild 0.25 | ✅ | top-level `target: es2022`+ **and** `experimentalDecorators: false` |
| swc 1.15 | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
| **Next.js** 16 | ⚠️ | no inline support — see below |
| **NestJS** 11.1 | ⚠️ | its DI needs `emitDecoratorMetadata` — see below |
| Deno | — | untested |
| webpack + `ts-loader` | — | untested; follows whatever `tsc` is configured to do |
---
## Angular
The surprise is that Angular works. The CLI scaffolds `"experimentalDecorators": true`, but
Angular's compiler does not need it — `ngtsc` erases `@Component` and `@Injectable` into static
properties itself, rather than relying on TypeScript's decorator emit. Turn the flag off and
both work in the same program.
```jsonc
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": false, // was true; Angular does not need it
"target": "ES2022",
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
}
}
```
```ts
// user.dto.ts
import { IsString, MinLength, IsInt, Min, JsonProperty } from 'cereale';
export class UserDto {
@JsonProperty('display_name')
@IsString() @MinLength(3)
displayName!: string;
@IsInt() @Min(18)
age!: number;
}
```
```ts
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs';
import { toInstanceSync } from 'cereale';
import { UserDto } from './user.dto';
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
load(id: string) {
return this.http.get(`/api/users/${id}`).pipe(map(body => toInstanceSync(UserDto, body)));
}
}
```
Verified with `ngc` on Angular 21.2 and `strictTemplates: true`: the component's template is
still type-checked, and a wrong cereale rule is still a compile error inside the Angular build —
`@IsString()` on `age!: number` fails with TS1240 exactly as it does anywhere else.
## Any Vite app — React, Vue, Svelte, Solid, Astro, Nuxt, SvelteKit
Vite 8 transforms with oxc, which does not implement the standard decorator transform **and does
not report that**. `vite build` succeeds and leaves the decorator syntax in the bundle, which
throws the moment anything imports it. cereale ships the plugin that fixes it.
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; // or vue(), svelte(), solid()…
import { standardDecorators } from 'cereale/vite';
export default defineConfig({
plugins: [standardDecorators(), react()],
});
```
```jsonc
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": false,
"target": "ES2022",
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
}
}
```
```tsx
// UserForm.tsx
import { useState } from 'react';
import { toInstanceSync, validateSync, flattenErrors } from 'cereale';
import { UserDto } from './user.dto'; // a .ts file, not .tsx — see below
export function UserForm() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
function onSubmit(form: FormData) {
const draft = toInstanceSync(UserDto, Object.fromEntries(form), { validate: false });
setErrors(flattenErrors(validateSync(draft)));
}
// …
}
```
The plugin transforms `.ts`, `.mts` and `.cts` outside `node_modules`. `.tsx` is excluded by
default, because lowering decorators there means also deciding what happens to the JSX — keep
decorated classes in `.ts` files, or pass an `include` of your own:
```ts
standardDecorators({ include: (id) => /\.[cm]?tsx?$/.test(id) && !id.includes('/node_modules/') })
```
Options: `include`, `target` (default `es2022`), and `transformer` (`'auto'` prefers esbuild and
falls back to the TypeScript compiler; cereale depends on neither).
## Vitest
Same plugin, same reason — Vitest 4 uses the same oxc pipeline, and without it prints `0 test`
next to a bare `SyntaxError`.
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { standardDecorators } from 'cereale/vite';
export default defineConfig({ plugins: [standardDecorators()] });
```
## Node, with tsc
Nothing to configure beyond the flag.
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false,
"strictPropertyInitialization": false // optional; `field!: T` otherwise needs the `!`
}
}
```
```ts
import express from 'express';
import { fromJsonSync, JsonValidationError, flattenErrors } from 'cereale';
import { UserDto } from './user.dto.js';
app.post('/users', (req, res) => {
try {
const user = fromJsonSync(UserDto, JSON.stringify(req.body));
return res.status(201).json(user);
} catch (error) {
if (error instanceof JsonValidationError) {
return res.status(400).json({ errors: flattenErrors(error.errors) });
}
throw error;
}
});
```
## Bun
Works with no configuration — Bun's transpiler emits standard decorators when
`experimentalDecorators` is off, which is its default.
```bash
bun add cereale
bun run app.ts
```
## Next.js
**Not supported inline.** Next derives *both* the SWC parser's decorator support and its
transform mode from the single `experimentalDecorators` flag:
```js
const enableDecorators = Boolean(jsConfig?.compilerOptions?.experimentalDecorators);
// parser: decorators: enableDecorators
// transform: legacyDecorator: enableDecorators
```
So the flag on gives legacy emit that cereale refuses, and the flag off makes `@` a syntax
error. There is no third setting, and no exposed `decoratorVersion` option.
Use a precompiled models package instead:
```
repo/
models/ ← compiled by tsc, experimentalDecorators: false
package.json { "name": "@acme/models", "exports": { ".": "./dist/index.js" } }
tsconfig.json
src/user.dto.ts ← your cereale classes live here
web/ ← the Next app, untouched
package.json { "dependencies": { "@acme/models": "workspace:*" } }
```
```jsonc
// models/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false,
"useDefineForClassFields": true,
"declaration": true,
"outDir": "dist"
}
}
```
Build `models` before `web`. Next only ever sees the compiled JavaScript — no decorator syntax
reaches its parser — and mapping and validation work normally. Verified against Next 16.3's own
SWC configuration.
## NestJS
**Not supported inline.** Nest scaffolds `experimentalDecorators: true` *and*
`emitDecoratorMetadata: true`, and its dependency injection genuinely needs the `design:type`
metadata that only legacy decorators emit. Turning the flag off breaks Nest.
The precompiled models package above works here too, and this is the case that proves the
pattern: a program compiled with **both** legacy flags on can import cereale DTOs from a
precompiled package and validate with them normally, alongside its own `@Injectable()` and
`@Inject()` decorators.
```ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { UserDto } from '@acme/models'; // precompiled
import { toInstanceSync, validateSync, formatErrors } from 'cereale';
@Injectable()
export class UsersService {
create(body: unknown) {
const draft = toInstanceSync(UserDto, body, { validate: false });
const errors = validateSync(draft);
if (errors.length) throw new BadRequestException(formatErrors(errors));
return draft;
}
}
```
If you would rather not split the package, stay on class-validator for now — Nest's own
`ValidationPipe` is built around it, and cereale does not try to replace that integration.
## Other bundlers
**esbuild** needs its own top-level `target`, not one inside `tsconfigRaw`. A `target` in
`tsconfigRaw` sets the `useDefineForClassFields` default and nothing else, so the decorators
are left in the output:
```js
await esbuild.build({
target: 'es2022', // ← required, and top-level
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
});
```
**swc** spells the choice as a proposal date:
```jsonc
// .swcrc
{
"jsc": {
"parser": { "syntax": "typescript", "decorators": true },
"transform": { "decoratorVersion": "2022-03" },
"target": "es2022"
}
}
```
## A single file, no bundler
`cereale/min` is the whole library flattened into one minified ES module (33.9 KB, 9.6 KB
gzipped) for import maps, `<script type="module">`, Deno and Workers.
**If you are using a bundler, prefer the default entry.** The flat file keeps its
`/*#__PURE__*/` annotations, so a bundler can still drop the rules you did not import — pinned
by `src/treeshake.test.ts` — but the per-module build shakes slightly leaner (1,837 bytes
against 1,996 for one decorator through esbuild) and is the canonical route. See
[Bundle size](README.md#bundle-size).
```html
<script type="importmap">
{ "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
</script>
```
+73 -3
View File
@@ -1,5 +1,12 @@
# Cereale # Cereale
[![npm](https://img.shields.io/npm/v/cereale?color=a0784a&label=npm)](https://www.npmjs.com/package/cereale)
[![CI](https://github.com/avalon-vanguard/cereale/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/avalon-vanguard/cereale/actions/workflows/ci.yml)
[![Docs](https://github.com/avalon-vanguard/cereale/actions/workflows/pages.yml/badge.svg)](https://avalon-vanguard.github.io/cereale/)
[![Node ≥20](https://img.shields.io/badge/node-%E2%89%A520-a0784a)](https://github.com/avalon-vanguard/cereale/blob/main/package.json)
[![TypeScript 5.2+](https://img.shields.io/badge/TypeScript-5.2%2B-a0784a)](https://github.com/avalon-vanguard/cereale#installation)
[![License: MIT](https://img.shields.io/badge/license-MIT-a0784a)](LICENSE)
**Validated domain objects, not validated data.** **Validated domain objects, not validated data.**
Cereale maps JSON onto your own classes and gives you back real instances — with your methods, Cereale maps JSON onto your own classes and gives you back real instances — with your methods,
@@ -27,8 +34,10 @@ user.greet(); // your methods are still there
**[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an **[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an
interactive playground that runs this library in your browser, the full decorator reference, interactive playground that runs this library in your browser, the full decorator reference,
and the toolchain matrix. The page is self-contained and loads nothing from the network; it is and the toolchain matrix. The page has no third-party dependencies — no CDN, no analytics, no webfonts; the only thing it
served from `docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally. fetches is its own vendored compiler, and only when you first press Run. It deploys from
`docs/` on `main` through Actions once CI is green, and `npm run build:docs` rebuilds its
assets to open locally.
## Where it fits ## Where it fits
@@ -75,6 +84,9 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d
npm install cereale npm install cereale
``` ```
Published with [provenance](https://www.npmjs.com/package/cereale), so the registry carries a
verified attestation linking the tarball to the commit it was built from.
Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag: Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag:
```json ```json
@@ -103,11 +115,25 @@ inside a native binary with no standalone transform API.
| Transformer | Status | Notes | | Transformer | Status | Notes |
| --- | --- | --- | | --- | --- | --- |
| `tsc` | ✅ | With `experimentalDecorators: false` and `target: ES2022`+ | | `tsc` 5.2+ | ✅ | With `experimentalDecorators: false` and `target: ES2022`+ |
| esbuild | ✅ | `experimentalDecorators: false` via `tsconfigRaw`, **plus** esbuild's own top-level `target: es2022`. Its default `esnext` target leaves decorator syntax in the output | | esbuild | ✅ | `experimentalDecorators: false` via `tsconfigRaw`, **plus** esbuild's own top-level `target: es2022`. Its default `esnext` target leaves decorator syntax in the output |
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` | | swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below | | **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below |
### Frameworks
**[FRAMEWORKS.md](FRAMEWORKS.md)** has a setup recipe for each, every one of them run before it
was written. The short version:
| | | |
| --- | --- | --- |
| **Angular** 21 | ✅ | Flip the scaffolded `experimentalDecorators` to `false`. Angular does not need it — `ngtsc` erases its own decorators itself |
| **React, Vue, Svelte, Solid, Astro, Nuxt** | ✅ | Any Vite 8 app: add the `cereale/vite` plugin below |
| **Bun** 1.3 | ✅ | No configuration |
| **Node** + `tsc` | ✅ | Just the flag |
| **Next.js** 16 | ⚠️ | Not inline: it derives both the SWC parser *and* the transform from one flag, so decorators either compile legacy or fail to parse. Keep your models in a package compiled by `tsc` |
| **NestJS** 11.1 | ⚠️ | Not inline: its DI needs `emitDecoratorMetadata`. Same precompiled-package route — verified working alongside `@Injectable()` in a program with both legacy flags on |
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without **If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build` reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
reports success while emitting a bundle that throws the moment it is imported. Cereale ships reports success while emitting a bundle that throws the moment it is imported. Cereale ships
@@ -480,6 +506,50 @@ costs a few percent on `toPlain`, which is the price of never emitting `{}` wher
used to be; primitives are handled inline and the check is skipped for arrays and dates, so used to be; primitives are handled inline and the check is skipped for arrays and dates, so
it is one `Symbol.toStringTag` read per object. it is one `Symbol.toStringTag` read per object.
## Bundle size
Cereale tree-shakes. Every rule is declared so that a bundler can drop the ones you did not
import, which matters for a library with 68 decorators — you pay for what you name and nothing
else. Minified bytes, measured through esbuild, rollup and webpack. The table was measured by hand;
what is [pinned by a test](src/treeshake.test.ts) is the property behind it — that a given
import drops the parts of the library it does not reach — checked through esbuild on both the
source and the published bundle:
| What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: |
| `flattenErrors` | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,738 |
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
| a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,360 |
| the whole library | 26,266 | 25,671 | 26,879 |
The serializer and the deserializer drop independently: read JSON and you do not pay for
writing it. The validator is kept by both, because `validate` defaults to `true` and the entry
points reference it whatever a given call site passes.
The floor is about a hundred bytes: cereale installs `Symbol.metadata` if the runtime lacks it,
and that install has to survive tree-shaking or a `tsc`-compiled consumer decorates its classes
with no metadata at all. It is why `sideEffects` names `index.js` as well as `metadata.js` —
marking only the latter leaves the barrel itself droppable, so the edge to it is pruned before
its own marking is ever read. That cost lands only on the first row; every import that touches a
model was already carrying it.
This did not come for free. Thirty of the rules are declared as top-level calls —
`export const IsString = rule(…)` — and rollup can prove such a call side-effect-free by reading
the factory, but esbuild and webpack will not. Without a `/*#__PURE__*/` annotation on each of
them, importing one decorator pulled in the message and validator of all 68: **4,909 bytes
instead of 1,837**. Nothing failed; the library was simply three times heavier in every
consumer's bundle, and the only way to find out was to measure. Note what that means for
measuring: rollup alone would have shown nothing wrong.
`cereale/min` tree-shakes too, which took a second fix — esbuild's `minify` strips comments,
annotations included, so the flat bundle was silently reproducing the same bug (5,066 bytes for
one decorator). It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw,
9.6 KB gzipped, about a kilobyte over the wire more than full minification would give. Still,
if you are using a bundler, import from `cereale` rather than `cereale/min`.
## Notes and Limitations ## Notes and Limitations
- **Rules are checked, types are not inferred.** You write both the field type and the rule; - **Rules are checked, types are not inferred.** You write both the field type and the rule;
+2 -2
View File
File diff suppressed because one or more lines are too long
+290 -135
View File
@@ -7,7 +7,7 @@
<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="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"> <meta name="color-scheme" content="light dark">
<link rel="canonical" href="https://avalon-vanguard.github.io/cereale/"> <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 100 100'><text y='.9em' font-size='90'>🌾</text></svg>"> <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 <!-- 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. --> without, and there is no artwork to point at yet. -->
@@ -22,35 +22,55 @@
<style> <style>
/* ---------------------------------------------------------------- tokens */ /* ---------------------------------------------------------------- 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 { :root {
--bg: #fbfbfd; --bg: #faf7f0;
--bg-raised: #ffffff; --bg-raised: #fffdf7;
--bg-sunken: #f3f3f7; --bg-sunken: #f2ede1;
--text: #16161d; --text: #1a1712;
--text-muted: #55555f; --text-muted: #5c5346;
--text-faint: #6b6b78; --text-faint: #6e6353;
--border: #e3e3ea; --border: #e6dfd1;
--border-strong: #cfcfd9; --border-strong: #cfc5b2;
--accent: #4f46e5; /* Icon-only controls need 3:1 against the page (WCAG 1.4.11); --border-strong is
--accent-text: #4338ca; decorative and sits well below it. .icon-btn and .btn-secondary use this instead. */
--accent-soft: #eef2ff; --border-ui: #8e8269;
--accent: #8a6238;
--accent-text: #7a5530;
--accent-soft: #f1e9dc;
/* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent /* 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. */ for legibility against the page, which then leaves white text on it below AA. */
--accent-solid: #4f46e5; --accent-solid: #6b4a28;
--on-accent: #ffffff; --on-accent: #fffdf7;
--bad: #d4183d; --bad: #a82820;
--bad-soft: #fff1f3; --bad-soft: #faebe7;
--ok: #08795a; --ok: #256b3d;
--ok-soft: #eefaf5; --ok-soft: #e8f2e9;
--warn: #9a5b00; --warn: #8a5a05;
--warn-soft: #fff7ea; --warn-soft: #fbf1dc;
/* Code surfaces stay dark in both themes: one syntax palette, always legible. */ /* Code surfaces stay dark in both themes: one syntax palette, always legible.
--code-bg: #16161f; Warmed off the blue-grey axis so the slab does not read cold against oat paper. */
--code-bg-raised: #1e1e29; --code-bg: #14120c;
--code-border: #2b2b3a; --code-bg-raised: #1c190f;
--code-text: #d6deeb; --code-border: #2e2818;
--code-faint: #8a93b8; --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-comment: #8a93b8;
--t-string: #b8e08a; --t-string: #b8e08a;
--t-keyword: #c792ea; --t-keyword: #c792ea;
@@ -58,64 +78,63 @@
--t-type: #ffcb6b; --t-type: #ffcb6b;
--t-number: #f78c6c; --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: 10px;
--radius-lg: 16px; --radius-lg: 16px;
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18); --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; --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; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--measure: 68ch; --measure: 64ch;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme="light"]) {
--bg: #0e0e14; --bg: #12100b;
--bg-raised: #16161f; --bg-raised: #1a1710;
--bg-sunken: #12121a; --bg-sunken: #16130d;
--text: #e8e8f0; --text: #ede7da;
--text-muted: #a3a3b4; --text-muted: #aba292;
--text-faint: #9494ab; --text-faint: #9b9280;
--border: #262633; --border: #29241a;
--border-strong: #363648; --border-strong: #3b3427;
--accent: #8b85ff; --border-ui: #736a59;
--accent-text: #a5a0ff; --accent: #c4a97c;
--accent-soft: #1c1b35; --accent-text: #d8be94;
--accent-solid: #8b85ff; --accent-soft: #2a2115;
--on-accent: #10101a; --accent-solid: #d8be94;
--bad: #ff8095; --on-accent: #17120a;
--bad-soft: #2a1620; --bad: #f2867e;
--ok: #5cd0a8; --bad-soft: #2b1512;
--ok-soft: #10241f; --ok: #6fc98c;
--warn: #e5a54b; --ok-soft: #12241a;
--warn-soft: #251d10; --warn: #e9b45a;
--code-bg: #12121a; --warn-soft: #261d0c;
--code-bg-raised: #191922; --shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
} }
} }
/* The toggle wins over the media query in both directions. */ /* 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"] { :root[data-theme="light"] {
--bg: #fbfbfd; --bg-raised: #ffffff; --bg-sunken: #f3f3f7;
--text: #16161d; --text-muted: #55555f; --text-faint: #6b6b78;
--border: #e3e3ea; --border-strong: #cfcfd9;
--accent: #4f46e5; --accent-text: #4338ca; --accent-soft: #eef2ff;
--accent-solid: #4f46e5; --on-accent: #ffffff;
--bad: #d4183d; --bad-soft: #fff1f3; --ok: #08795a; --ok-soft: #eefaf5;
--warn: #9a5b00; --warn-soft: #fff7ea;
--code-bg: #16161f; --code-bg-raised: #1e1e29;
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18);
color-scheme: light; color-scheme: light;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg: #0e0e14; --bg-raised: #16161f; --bg-sunken: #12121a; --bg: #12100b; --bg-raised: #1a1710; --bg-sunken: #16130d;
--text: #e8e8f0; --text-muted: #a3a3b4; --text-faint: #9494ab; --text: #ede7da; --text-muted: #aba292; --text-faint: #9b9280;
--border: #262633; --border-strong: #363648; --border: #29241a; --border-strong: #3b3427; --border-ui: #736a59;
--accent: #8b85ff; --accent-text: #a5a0ff; --accent-soft: #1c1b35; --accent: #c4a97c; --accent-text: #d8be94; --accent-soft: #2a2115;
--accent-solid: #8b85ff; --on-accent: #10101a; --accent-solid: #d8be94; --on-accent: #17120a;
--bad: #ff8095; --bad-soft: #2a1620; --ok: #5cd0a8; --ok-soft: #10241f; --bad: #f2867e; --bad-soft: #2b1512; --ok: #6fc98c; --ok-soft: #12241a;
--warn: #e5a54b; --warn-soft: #251d10; --warn: #e9b45a; --warn-soft: #261d0c;
--code-bg: #12121a; --code-bg-raised: #191922; --shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
color-scheme: dark; color-scheme: dark;
} }
@@ -132,17 +151,22 @@ body {
color: var(--text); color: var(--text);
font-family: var(--sans); font-family: var(--sans);
font-size: 16px; font-size: 16px;
line-height: 1.6; line-height: 1.65;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-x: hidden; overflow-x: hidden;
} }
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 680; letter-spacing: -.02em; } /* 680 and 640 are fiction on a non-variable system stack — they round to 700. And -.035em
h1 { font-size: clamp(2.1rem, 1.3rem + 3.4vw, 3.5rem); letter-spacing: -.035em; } is generic-landing-page tracking; it is most of what made this look like every other
h2 { font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.1rem); letter-spacing: -.028em; } dev-tool site. */
h3 { font-size: 1.125rem; } 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; } p { margin: 0 0 1rem; }
a { color: var(--accent-text); text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); text-underline-offset: .18em; } /* Links keep body colour and are marked by a rule instead. On a palette this warm,
a:hover { text-decoration-color: currentColor; } --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); } code, kbd, pre { font-family: var(--mono); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
@@ -153,15 +177,23 @@ code, kbd, pre { font-family: var(--mono); }
section { padding-block: clamp(3rem, 6vw, 5.5rem); } section { padding-block: clamp(3rem, 6vw, 5.5rem); }
.lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); } .lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); }
.eyebrow { .eyebrow {
font-size: .75rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; font-family: var(--mono);
color: var(--accent-text); margin: 0 0 .6rem; 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; } .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 { .skip {
position: absolute; left: -9999px; top: 0; z-index: 100; 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; background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
} }
.skip:focus { left: 0; } .skip:focus { left: 0; }
.skip:hover { color: var(--on-accent); }
.sr-only { .sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
@@ -175,8 +207,8 @@ header.nav {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; } .nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; }
.brand { display: flex; align-items: baseline; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; } .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 .grain { font-size: 1rem; } .brand .mark { color: var(--accent); flex: none; }
.badge { .badge {
font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px; 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); background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
@@ -187,10 +219,13 @@ header.nav {
} }
.nav-links a:hover { color: var(--text); background: var(--bg-sunken); } .nav-links a:hover { color: var(--text); background: var(--bg-sunken); }
.nav-links a.ghost { border: 1px solid var(--border-strong); } .nav-links a.ghost { border: 1px solid var(--border-strong); }
@media (max-width: 640px) { .nav-hide { display: none; } } /* 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 { .icon-btn {
display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem; display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem;
border: 1px solid var(--border-strong); border-radius: var(--radius); border: 1px solid var(--border-ui); border-radius: var(--radius);
background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0; background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0;
} }
.icon-btn:hover { color: var(--text); } .icon-btn:hover { color: var(--text); }
@@ -208,15 +243,18 @@ header.nav {
padding: .75rem 1.15rem; border-radius: var(--radius); text-decoration: none; cursor: pointer; border: 1px solid transparent; 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 { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
.btn-primary:hover { filter: brightness(1.08); } /* Re-assert label colours on hover: the base a:hover (0,1,1) outranks these classes
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-strong); } (0,1,0), and in dark theme --accent-text equals --accent-solid — a hovered label
.btn-secondary:hover { border-color: var(--text-faint); } 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 { .fact-row {
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
font-size: .8125rem; color: var(--text-muted); font-size: .8125rem; color: var(--text-muted);
} }
.fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; } .fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; }
.fact b { color: var(--text); font-weight: 640; } .fact b { color: var(--text); font-weight: 600; }
/* ---------------------------------------------------------------- code */ /* ---------------------------------------------------------------- code */
.code { .code {
@@ -228,8 +266,11 @@ header.nav {
border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised); border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised);
font: 500 .75rem/1 var(--mono); color: var(--code-faint); font: 500 .75rem/1 var(--mono); color: var(--code-faint);
} }
.code-head .dot { width: .5rem; height: .5rem; border-radius: 50%; background: #33334a; } /* Use 3 of 3, and a deletion: the fake traffic lights are gone. */
.code-head .name { margin-left: .35rem; } .code-head::before {
content: ""; flex: none; width: 1.05rem; height: 2px;
background-image: var(--rule-code);
}
.code-head .right { margin-left: auto; } .code-head .right { margin-left: auto; }
.code pre { .code pre {
margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto; margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto;
@@ -246,18 +287,18 @@ header.nav {
/* The page's one visual device: the line the compiler refuses. */ /* The page's one visual device: the line the compiler refuses. */
.ln--error { .ln--error {
background: rgba(255, 92, 122, .09); background: color-mix(in srgb, var(--err-line) 9%, transparent);
text-decoration: underline wavy #ff5c7a; text-decoration: underline wavy var(--err-line);
text-decoration-skip-ink: none; text-decoration-skip-ink: none;
text-underline-offset: .32em; text-underline-offset: .32em;
} }
.tsc-error { .tsc-error {
display: flex; gap: .6rem; align-items: flex-start; display: flex; gap: .6rem; align-items: flex-start;
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border); margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
background: rgba(255, 92, 122, .08); color: #ffb3c0; background: color-mix(in srgb, var(--err-line) 8%, transparent); color: var(--err-text);
font: 500 .78125rem/1.5 var(--mono); font: 500 .78125rem/1.5 var(--mono);
} }
.tsc-error .mark { color: #ff5c7a; flex-shrink: 0; } .tsc-error .mark { color: var(--err-line); flex-shrink: 0; }
/* --------------------------------------------------------------- panels */ /* --------------------------------------------------------------- panels */
.panel { .panel {
@@ -271,6 +312,7 @@ header.nav {
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; } .panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
.tick { color: var(--ok); font-weight: 700; } .tick { color: var(--ok); font-weight: 700; }
.cross { color: var(--bad); font-weight: 700; } .cross { color: var(--bad); font-weight: 700; }
.warn-mark { color: var(--warn); font-weight: 700; }
.compare { display: grid; gap: 1rem; } .compare { display: grid; gap: 1rem; }
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } } @media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
@@ -302,14 +344,17 @@ header.nav {
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre; padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre;
overflow: auto; overflow: auto;
} }
#editor:focus { outline: 2px solid var(--accent); outline-offset: -2px; } #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 { #output {
flex: 1; min-height: 400px; margin: 0; overflow: auto; flex: 1; min-height: 400px; margin: 0; overflow: auto;
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border); 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); 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; 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-err { color: var(--err-text); }
#output .out-dim { color: var(--code-faint); } #output .out-dim { color: var(--code-faint); }
.pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; } .pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; }
@@ -325,7 +370,7 @@ td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
.ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; } .ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; }
#ref-filter { #ref-filter {
flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem; flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem;
border: 1px solid var(--border-strong); border-radius: var(--radius); border: 1px solid var(--border-ui); border-radius: var(--radius);
background: var(--bg-raised); color: var(--text); background: var(--bg-raised); color: var(--text);
} }
#ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; } #ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
@@ -343,7 +388,8 @@ td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
/* --------------------------------------------------------------- prose */ /* --------------------------------------------------------------- prose */
.notes { display: grid; gap: .9rem; } .notes { display: grid; gap: .9rem; }
.note-item { border-left: 2px solid var(--border-strong); padding-left: 1rem; } /* 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 h3 { font-size: .9375rem; margin-bottom: .25rem; }
.note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; } .note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
.callout { .callout {
@@ -364,14 +410,15 @@ footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(
<header class="nav"> <header class="nav">
<div class="wrap nav-inner"> <div class="wrap nav-inner">
<a class="brand" href="#top"><span class="grain" aria-hidden="true">🌾</span>cereale <span class="badge" id="version-badge">v0.3.0</span></a> <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.1</span></a>
<nav class="nav-links" aria-label="Primary"> <nav class="nav-links" aria-label="Primary">
<a class="nav-hide" href="#guarantee">Guarantee</a> <a class="nav-hide" href="#guarantee">Guarantee</a>
<a class="nav-hide" href="#playground">Playground</a> <a class="nav-hide" href="#playground">Playground</a>
<a class="nav-hide" href="#errors">Errors</a> <a class="nav-hide" href="#errors">Errors</a>
<a class="nav-hide" href="#install">Install</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="nav-hide" href="#reference">Reference</a>
<a class="ghost" href="https://github.com/Avalon-Vanguard/cereale">GitHub</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"> <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"> <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"/> <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"/>
@@ -398,21 +445,22 @@ footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6V4z"/></svg> <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 Run it in the browser
</a> </a>
<a class="btn btn-secondary" href="https://github.com/Avalon-Vanguard/cereale">Read the source</a> <a class="btn btn-secondary" href="https://github.com/avalon-vanguard/cereale">Read the source</a>
</div> </div>
<div class="fact-row"> <div class="fact-row">
<span class="fact"><b>0</b> runtime dependencies</span> <span class="fact"><b>0</b> runtime dependencies</span>
<span class="fact"><b id="decorator-count">68</b> decorators</span> <span class="fact"><b id="decorator-count">68</b> decorators</span>
<span class="fact">TC39 <b>standard decorators</b></span> <span class="fact">TC39 <b>standard decorators</b></span>
<span class="fact">ESM + CJS</span> <span class="fact">ESM + CJS + single-file</span>
<span class="fact">Node <b id="node-req">&ge;20</b></span> <span class="fact">Node <b id="node-req">&ge;20</b></span>
<span class="fact"><b>1.8 KB</b> for one decorator</span>
</div> </div>
</div> </div>
<div> <div>
<div class="code"> <div class="code">
<div class="code-head"> <div class="code-head">
<span class="dot" aria-hidden="true"></span><span class="name">order.ts</span> <span class="name">order.ts</span>
</div> </div>
<pre><code data-lang="ts" data-error-line="9">class Order { <pre><code data-lang="ts" data-error-line="9">class Order {
@JsonProperty('order_ref') @JsonProperty('order_ref')
@@ -463,7 +511,7 @@ order.total(); // methods intact</code></pre>
<div> <div>
<p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p> <p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">with legacy decorators</span></div> <div class="code-head"><span class="name">with legacy decorators</span></div>
<pre><code data-lang="ts">class User { <pre><code data-lang="ts">class User {
@IsString() @IsString()
age: number; // accepted by the compiler age: number; // accepted by the compiler
@@ -476,7 +524,7 @@ order.total(); // methods intact</code></pre>
<div> <div>
<p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p> <p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">with standard decorators</span></div> <div class="code-head"><span class="name">with standard decorators</span></div>
<pre><code data-lang="ts" data-error-line="2">class User { <pre><code data-lang="ts" data-error-line="2">class User {
@IsString() @IsString()
age!: number; // Type 'number' is not age!: number; // Type 'number' is not
@@ -520,7 +568,7 @@ order.total(); // methods intact</code></pre>
</div> </div>
<div class="compare"> <div class="compare">
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">catalogue.ts</span></div> <div class="code-head"><span class="name">catalogue.ts</span></div>
<pre><code data-lang="ts">class Media { <pre><code data-lang="ts">class Media {
@IsString() title!: string; @IsString() title!: string;
} }
@@ -542,7 +590,7 @@ class Playlist {
}</code></pre> }</code></pre>
</div> </div>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">what comes out</span></div> <div class="code-head"><span class="name">what comes out</span></div>
<pre><code data-lang="ts">const list = fromJsonSync(Playlist, body); <pre><code data-lang="ts">const list = fromJsonSync(Playlist, body);
const first = list.items[0]; const first = list.items[0];
@@ -587,14 +635,14 @@ if (first instanceof Movie) {
<div class="pg-grid"> <div class="pg-grid">
<div class="pg-pane"> <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"> <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="dot" aria-hidden="true"></span><span class="name">playground.ts</span> <span class="name">playground.ts</span>
</div> </div>
<textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" <textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
aria-label="TypeScript source to run"></textarea> aria-label="TypeScript source to run"></textarea>
</div> </div>
<div class="pg-pane"> <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"> <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="dot" aria-hidden="true"></span><span class="name">output</span> <span class="name">output</span>
<span class="right" id="pg-status"></span> <span class="right" id="pg-status"></span>
</div> </div>
<pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre> <pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre>
@@ -616,7 +664,7 @@ if (first instanceof Movie) {
<p class="eyebrow">Diagnosability</p> <p class="eyebrow">Diagnosability</p>
<h2>Nothing fails quietly</h2> <h2>Nothing fails quietly</h2>
<p class="lede"> <p class="lede">
The 0.3.0 release exists because of this. A mapping layer that loses your data and reports 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 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. into an error that names the cause and the way out.
</p> </p>
@@ -638,7 +686,7 @@ if (first instanceof Movie) {
</div> </div>
<div style="margin-top:1.5rem" class="code"> <div style="margin-top:1.5rem" class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">what you get instead</span></div> <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. <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 Give the property a @JsonSerialize() serializer that converts it, or drop it from
the output with @JsonIgnore(). the output with @JsonIgnore().
@@ -662,7 +710,8 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
<h2>The toolchain cost, stated plainly</h2> <h2>The toolchain cost, stated plainly</h2>
<p class="lede"> <p class="lede">
cereale reads the metadata that only a <strong>standard</strong> decorator transform emits, so 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 are executed by a 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 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 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 binary with no standalone transform API, so it was established by hand, and the plugin below
@@ -677,7 +726,7 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
<tr><th scope="col">Transformer</th><th scope="col">Works</th><th scope="col">Setting</th></tr> <tr><th scope="col">Transformer</th><th scope="col">Works</th><th scope="col">Setting</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr><td><code>tsc</code></td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code>, <code>target: ES2022</code>+</td></tr> <tr><td><code>tsc</code>&nbsp;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>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>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&nbsp;8</strong> and <strong>Vitest&nbsp;4</strong> — see below</td></tr> <tr><td>oxc</td><td><span class="cross">✗</span></td><td class="note">used by <strong>Vite&nbsp;8</strong> and <strong>Vitest&nbsp;4</strong> — see below</td></tr>
@@ -693,8 +742,29 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
that fixes it. that fixes it.
</div> </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&nbsp;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&hellip;</td><td><span class="tick">✓</span></td><td class="note">Any Vite&nbsp;8 app — add the plugin below</td></tr>
<tr><td>Bun&nbsp;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&nbsp;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&nbsp;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" style="margin-top:1.25rem;max-width:640px">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">vite.config.ts</span></div> <div class="code-head"><span class="name">vite.config.ts</span></div>
<pre><code data-lang="ts">import { defineConfig } from 'vite'; <pre><code data-lang="ts">import { defineConfig } from 'vite';
import { standardDecorators } from 'cereale/vite'; import { standardDecorators } from 'cereale/vite';
@@ -719,14 +789,14 @@ export default defineConfig({
<div class="wrap"> <div class="wrap">
<div class="section-head"> <div class="section-head">
<p class="eyebrow">Getting started</p> <p class="eyebrow">Getting started</p>
<h2>Two settings, and one caveat</h2> <h2>Two settings, one caveat, and two ways in</h2>
</div> </div>
<div class="compare"> <div class="compare">
<div> <div>
<p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p> <p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">tsconfig.json</span></div> <div class="code-head"><span class="name">tsconfig.json</span></div>
<pre><code data-lang="text">{ <pre><code data-lang="text">{
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
@@ -744,22 +814,105 @@ export default defineConfig({
</div> </div>
<div> <div>
<p class="compare-label"><span class="pill pill-bad">not on npm yet</span> installing it today</p> <p class="compare-label"><span class="pill pill-ok">on npm</span> installing it</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">shell</span></div> <div class="code-head"><span class="name">shell</span></div>
<pre><code data-lang="text">git clone https://github.com/Avalon-Vanguard/cereale <pre><code data-lang="text">npm install cereale</code></pre>
cd cereale
npm install &amp;&amp; npm run build
npm pack # → cereale-0.3.0.tgz
# then, from your own project
npm install ../cereale/cereale-0.3.0.tgz</code></pre>
</div> </div>
<p class="pg-note"> <p class="pg-note">
<code class="inline-code">npm install cereale</code> does <strong>not</strong> resolve to Published from CI with <strong>provenance</strong>, so the registry carries a verified
this library — the name is unclaimed on the registry. Installing straight from GitHub attestation linking the tarball to the commit it was built from — visible on the
will not work either: the build output is not committed, so the package would arrive <a href="https://www.npmjs.com/package/cereale">package page</a>.
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&nbsp;KB</strong>, 9.6&nbsp;KB gzipped — for import maps,
a bare <code class="inline-code">&lt;script type="module"&gt;</code>, Deno and Workers.
</p>
<div class="code">
<div class="code-head"><span class="name">index.html</span></div>
<pre><code data-lang="text">&lt;script type="importmap"&gt;
{ "imports": { "cereale": "./node_modules/cereale/dist/cereale.min.js" } }
&lt;/script&gt;
&lt;script type="module"&gt;
import { IsString, toInstanceSync } from 'cereale';
&lt;/script&gt;</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> </p>
</div> </div>
</div> </div>
@@ -791,7 +944,7 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
<div class="ref-groups" id="ref-groups"></div> <div class="ref-groups" id="ref-groups"></div>
<noscript> <noscript>
<p class="ref-empty">The reference is rendered with JavaScript. The full list is in the <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> <a href="https://github.com/avalon-vanguard/cereale#api-reference">README</a>.</p>
</noscript> </noscript>
</div> </div>
</section> </section>
@@ -838,9 +991,11 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
cannot reach. Both are errors rather than silent no-ops.</p> cannot reach. Both are errors rather than silent no-ops.</p>
</div> </div>
<div class="note-item"> <div class="note-item">
<h3>It is not on npm yet</h3> <h3>It is still 0.x</h3>
<p>0.3.0 lives in the repository. <code class="inline-code">npm install cereale</code> does not <p><span class="js-version">0.4.1</span> is published, and under semver a 0.x minor bump
resolve to this library — build it from source until it is published.</p> 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>
</div> </div>
@@ -853,9 +1008,9 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
<div class="wrap foot-grid"> <div class="wrap foot-grid">
<p style="margin:0">cereale — MIT licensed. Built by Avalon Vanguard.</p> <p style="margin:0">cereale — MIT licensed. Built by Avalon Vanguard.</p>
<p style="margin:0"> <p style="margin:0">
<a href="https://github.com/Avalon-Vanguard/cereale">Source</a> · <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/blob/main/CHANGELOG.md">Changelog</a> ·
<a href="https://github.com/Avalon-Vanguard/cereale/issues">Issues</a> <a href="https://github.com/avalon-vanguard/cereale/issues">Issues</a>
</p> </p>
</div> </div>
</footer> </footer>
+1 -1
View File
@@ -1,5 +1,5 @@
// Generated by scripts/build-docs.mjs — do not edit. // Generated by scripts/build-docs.mjs — do not edit.
window.CEREALE_META = { window.CEREALE_META = {
"version": "0.3.0", "version": "0.4.1",
"node": ">=20.0.0" "node": ">=20.0.0"
}; };
+10
View File
@@ -39,6 +39,16 @@
if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version; if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version;
var nodeEl = document.getElementById('node-req'); var nodeEl = document.getElementById('node-req');
if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', ''); if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', '');
if (meta.version) {
Array.prototype.forEach.call(document.querySelectorAll('.js-version'), function (el) {
el.textContent = meta.version;
});
}
if (decoratorCount) {
Array.prototype.forEach.call(document.querySelectorAll('.js-dec-count'), function (el) {
el.textContent = String(decoratorCount);
});
}
/* ------------------------------------------------------- highlighting */ /* ------------------------------------------------------- highlighting */
var TOKENS = [ var TOKENS = [
+3 -3
View File
@@ -1,4 +1,4 @@
import eslint from '@eslint/js'; import avalonBase from '@avalon-vanguard/config/eslint';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import globals from 'globals'; import globals from 'globals';
@@ -6,8 +6,8 @@ export default tseslint.config(
{ {
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'docs/**'], ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'docs/**'],
}, },
eslint.configs.recommended, // @eslint/js recommended + typescript-eslint recommended, shared across avalon-vanguard
...tseslint.configs.recommended, avalonBase,
{ {
languageOptions: { languageOptions: {
globals: { globals: {
+35 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "cereale", "name": "cereale",
"version": "0.3.0", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cereale", "name": "cereale",
"version": "0.3.0", "version": "0.4.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@avalon-vanguard/config": "^1.0.0",
"@babel/standalone": "^8.0.4", "@babel/standalone": "^8.0.4",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@swc/core": "^1.15.47", "@swc/core": "^1.15.47",
@@ -26,6 +27,38 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@avalon-vanguard/config": {
"version": "1.0.0",
"resolved": "https://git.avalonvanguard.com/api/packages/avalon-vanguard/npm/%40avalon-vanguard%2Fconfig/-/1.0.0/config-1.0.0.tgz",
"integrity": "sha512-ySojd0OONRb9N9KT4CJeyLf5GUrfRP8jxfPTMRv3HUk7gld4VuzZd16lW774SfLkMBMOUYVVf9SKBMO+JATTTA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@eslint/js": "^10.0.0",
"angular-eslint": "^22.0.0",
"eslint": "^10.0.0",
"prettier": "^3.0.0",
"typescript": "^6.0.0",
"typescript-eslint": "^8.0.0"
},
"peerDependenciesMeta": {
"@eslint/js": {
"optional": true
},
"angular-eslint": {
"optional": true
},
"eslint": {
"optional": true
},
"prettier": {
"optional": true
},
"typescript-eslint": {
"optional": true
}
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+16 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "cereale", "name": "cereale",
"version": "0.3.0", "version": "0.4.1",
"description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.", "description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
"type": "module", "type": "module",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
@@ -16,24 +16,33 @@
"types": "./dist/esm/vite.d.ts", "types": "./dist/esm/vite.d.ts",
"import": "./dist/esm/vite.js", "import": "./dist/esm/vite.js",
"require": "./dist/cjs/vite.js" "require": "./dist/cjs/vite.js"
},
"./min": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/cereale.min.js"
} }
}, },
"sideEffects": [ "sideEffects": [
"./dist/esm/index.js",
"./dist/esm/metadata.js", "./dist/esm/metadata.js",
"./dist/cjs/metadata.js" "./dist/cereale.min.js",
"./src/index.ts",
"./src/metadata.ts"
], ],
"files": [ "files": [
"dist", "dist",
"src", "src",
"FRAMEWORKS.md",
"CHANGELOG.md", "CHANGELOG.md",
"!src/**/*.test.ts", "!src/**/*.test.ts",
"!src/example.ts" "!src/example.ts"
], ],
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", "build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node scripts/build-bundle.mjs",
"build:docs": "node scripts/build-docs.mjs", "build:docs": "node scripts/build-docs.mjs",
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts", "demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"typecheck": "npm run type-check",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
@@ -41,8 +50,10 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs", "verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs",
"prepublishOnly": "npm run verify", "prepublishOnly": "npm run verify",
"check": "node scripts/check-entry-points.mjs && npm run demo && npm run check:types && npm run check:docs && npm run check:docs-sync",
"check:docs": "node scripts/check-docs.mjs", "check:docs": "node scripts/check-docs.mjs",
"check:types": "node scripts/check-types.mjs" "check:types": "node scripts/check-types.mjs",
"check:docs-sync": "npm run build:docs && node scripts/check-docs-sync.mjs"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -70,6 +81,7 @@
}, },
"homepage": "https://avalon-vanguard.github.io/cereale/", "homepage": "https://avalon-vanguard.github.io/cereale/",
"devDependencies": { "devDependencies": {
"@avalon-vanguard/config": "^1.0.0",
"@babel/standalone": "^8.0.4", "@babel/standalone": "^8.0.4",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@swc/core": "^1.15.47", "@swc/core": "^1.15.47",
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>avalon-vanguard/renovate-config"]
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Flattens the ESM build into a single minified module.
*
* This is an *addition*, not a replacement. `dist/esm` stays the default `import`: it keeps
* readable stack traces for anyone who does not load source maps, and it is what a bundler
* should be handed.
*
* Where the single file does earn its place is everywhere a bundler is not involved: a
* `<script type="module">` tag, a CDN, an import map, Deno, or a Worker. That is what
* `cereale/min` is for.
*
* It is built from `dist/esm/index.js` rather than from `src/`, so the decorator lowering and
* the ES2025 target are whatever `tsc` produced — esbuild is only flattening and minifying.
* (esbuild has no `es2025` target name; `esnext` means "downlevel nothing", which is what we
* want when the input is already at the target.)
*/
import { build } from 'esbuild';
import { readFile, writeFile, stat } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { gzipSync } from 'node:zlib';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const dist = path.join(root, 'dist');
const outfile = path.join(dist, 'cereale.min.js');
const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
await build({
entryPoints: [path.join(dist, 'esm/index.js')],
bundle: true,
format: 'esm',
target: 'esnext',
// NOT `minify: true`: `minifyWhitespace` strips comments, /*#__PURE__*/ included, and a
// bundler fed the result keeps every unused rule — 5,066 bytes for one decorator against
// 1,837. Asserted below. Costs about a kilobyte gzipped.
minifySyntax: true,
minifyIdentifiers: true,
sourcemap: true,
legalComments: 'none',
banner: { js: `/*! cereale ${pkg.version} | MIT | ${pkg.homepage} */` },
// The input is compiled JavaScript, so the project's tsconfig has no bearing here — and
// reading it only earns a warning, because esbuild does not know the ES2025 target name
// that tsc is perfectly happy with.
tsconfigRaw: {},
outfile,
});
// The whole reason this file is not fully minified. Asserting it here means a future change to
// the minify options fails the build rather than silently tripling what a bundler keeps.
//
// The floor on `expected` is not decoration: comparing the two counts alone passes vacuously if
// tsc ever stops emitting the annotations, since 0 >= 0. It has to be wrong in both directions.
const count = (s) => (s.match(/__PURE__/g) ?? []).length;
const emitted = await readFile(outfile, 'utf8');
const annotations = count(emitted);
const expected = count(await readFile(path.join(dist, 'esm/decorators.js'), 'utf8'));
if (expected < 30) {
throw new Error(
`dist/esm/decorators.js carries only ${expected} /*#__PURE__*/ annotations; src/decorators.ts ` +
'writes 30. The compiler is dropping them, so every consumer keeps all 68 rules.'
);
}
if (annotations < expected) {
throw new Error(
`the flat bundle kept ${annotations} /*#__PURE__*/ annotations but dist/esm/decorators.js has ` +
`${expected}. Minification stripped them, so anything bundling cereale/min would keep every ` +
'unused rule. Do not turn on minifyWhitespace here.'
);
}
// dist/cjs/package.json is the nearest descriptor for every module beneath it, so bundlers
// read `sideEffects` from there rather than from the root manifest. Declaring it only at the
// root leaves the whole CommonJS build undeclared.
await writeFile(
path.join(dist, 'cjs/package.json'),
JSON.stringify({ type: 'commonjs', sideEffects: ['./metadata.js'] }, null, 2) + '\n'
);
const size = (await stat(outfile)).size;
const gzip = gzipSync(emitted).length;
console.log(`dist/cereale.min.js ${(size / 1024).toFixed(1)} KB (${(gzip / 1024).toFixed(1)} KB gzipped)`);
+21
View File
@@ -49,6 +49,27 @@ await writeFile(
`window.CEREALE_META = ${JSON.stringify({ version: pkg.version, node: pkg.engines.node }, null, 2)};\n` `window.CEREALE_META = ${JSON.stringify({ version: pkg.version, node: pkg.engines.node }, null, 2)};\n`
); );
// 2b. The same version, stamped into the two spots in index.html that page.js later
// overwrites from meta.js. Those are the no-JavaScript fallbacks: correct in a browser,
// stale in a text reader or a scraper, and hand-bumped until now — they went stale on
// 0.4.0 and again on 0.4.1. Stamping them here means the docs-sync gate catches the
// drift instead of a reviewer. The regexes are asserted, so if the markup is renamed
// the build fails loudly rather than silently stamping nothing.
const indexPath = path.join(docs, 'index.html');
let index = await readFile(indexPath, 'utf8');
const stamps = [
[/(<span class="badge" id="version-badge">)v[\d.]+(<\/span>)/, `$1v${pkg.version}$2`],
[/(<span class="js-version">)[\d.]+(<\/span>)/g, `$1${pkg.version}$2`],
];
for (const [re, replacement] of stamps) {
if (!re.test(index)) {
console.error(`build-docs: nothing in docs/index.html matched ${re} — the version fallback markup moved.`);
process.exit(1);
}
index = index.replace(re, replacement);
}
await writeFile(indexPath, index);
// 3. The compiler errors the page quotes, produced by actually running the compiler. If a // 3. The compiler errors the page quotes, produced by actually running the compiler. If a
// snippet the page calls a compile error ever compiles, this fails the build. // snippet the page calls a compile error ever compiles, this fails the build.
const { byCase, problems } = await collectDiagnostics(root); const { byCase, problems } = await collectDiagnostics(root);
+21
View File
@@ -0,0 +1,21 @@
/**
* Fails if docs/ differs from what `npm run build:docs` just produced — including NEW
* files. That last part is the reason this exists: `git diff --exit-code -- docs/` only
* reports modifications to tracked files, so a build-script change whose only effect is
* an additional output (a sourcemap, a second vendor asset) passed the old gate silently
* and would have been deployed without ever being committed or reviewed.
*
* Run after build:docs (the check:docs-sync npm script chains them). Shared by ci.yml
* and pages.yml so the two workflows cannot drift into enforcing different notions of
* "in sync" — they already had, before this was extracted.
*/
import { execFileSync } from 'node:child_process';
const out = execFileSync('git', ['status', '--porcelain', '--', 'docs/'], { encoding: 'utf8' }).trim();
if (out) {
console.error(out);
console.error("docs/ is stale — run 'npm run build:docs' and commit the result");
process.exit(1);
}
console.log('docs/ matches src/ — nothing modified, nothing untracked.');
+18
View File
@@ -0,0 +1,18 @@
/**
* Fails if a published entry point does not load from dist/: `.` and `./vite`, each as ESM
* and as CommonJS, and the flat `./min` bundle. Run after `npm run build` (first step of
* `npm run check`). Moved here from inline `node -e` lines in .github/workflows/ci.yml so
* that the GitHub and Gitea workflows run the same check.
*/
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const assert = (ok, what) => {
if (!ok) throw new Error(`${what} broken`);
};
assert(typeof (await import('../dist/esm/index.js')).toInstance === 'function', 'ESM entry point');
assert(typeof require('../dist/cjs/index.js').toInstance === 'function', 'CJS entry point');
assert((await import('../dist/esm/vite.js')).standardDecorators().enforce === 'pre', 'ESM cereale/vite');
assert(require('../dist/cjs/vite.js').standardDecorators().enforce === 'pre', 'CJS cereale/vite');
assert(typeof (await import('../dist/cereale.min.js')).toInstanceSync === 'function', 'flat bundle');
+30 -30
View File
@@ -262,17 +262,17 @@ export function ValidateNested(options?: ValidationOptions): FieldDecorator<obje
// Type rules // Type rules
// ============================================================================ // ============================================================================
export const IsString: Rule<string> = rule('isString', v => typeof v === 'string', p => `${p} must be a string`); export const IsString: Rule<string> = /*#__PURE__*/ rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
export const IsNumber: Rule<number> = rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`); export const IsNumber: Rule<number> = /*#__PURE__*/ rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`);
export const IsInt: Rule<number> = rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`); export const IsInt: Rule<number> = /*#__PURE__*/ rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
export const IsBoolean: Rule<boolean> = rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`); export const IsBoolean: Rule<boolean> = /*#__PURE__*/ rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
export const IsBigInt: Rule<bigint> = rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`); export const IsBigInt: Rule<bigint> = /*#__PURE__*/ rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
export const IsDate: Rule<Date> = rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`); export const IsDate: Rule<Date> = /*#__PURE__*/ rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`);
export const IsObject: Rule<object> = rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`); export const IsObject: Rule<object> = /*#__PURE__*/ rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
export const IsDefined: Rule<unknown> = rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`); export const IsDefined: Rule<unknown> = /*#__PURE__*/ rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`);
export const IsNotEmpty: Rule<unknown> = rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`); export const IsNotEmpty: Rule<unknown> = /*#__PURE__*/ rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
export const IsEmpty: Rule<unknown> = rule('isEmpty', v => { export const IsEmpty: Rule<unknown> = /*#__PURE__*/ rule('isEmpty', v => {
if (v === null || v === undefined || v === '') return true; if (v === null || v === undefined || v === '') return true;
if (Array.isArray(v)) return v.length === 0; if (Array.isArray(v)) return v.length === 0;
if (typeof v === 'object') return Object.keys(v).length === 0; if (typeof v === 'object') return Object.keys(v).length === 0;
@@ -301,8 +301,8 @@ export function Max(max: number, options?: ValidationOptions): unknown {
}), options); }), options);
} }
export const Positive: Rule<number> = rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`); export const Positive: Rule<number> = /*#__PURE__*/ rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
export const Negative: Rule<number> = rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`); export const Negative: Rule<number> = /*#__PURE__*/ rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`);
export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each<number>; export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each<number>;
export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One<number>; export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One<number>;
@@ -315,13 +315,13 @@ export function IsDivisibleBy(divisor: number, options?: ValidationOptions): unk
} }
/** An integer in 0..65535. Accepts a number or a numeric string. */ /** An integer in 0..65535. Accepts a number or a numeric string. */
export const IsPort: Rule<number | string> = rule('isPort', v => { export const IsPort: Rule<number | string> = /*#__PURE__*/ rule('isPort', v => {
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v; const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535; return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
}, p => `${p} must be a valid port number`); }, p => `${p} must be a valid port number`);
export const IsLatitude: Rule<number> = rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`); export const IsLatitude: Rule<number> = /*#__PURE__*/ rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`);
export const IsLongitude: Rule<number> = rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`); export const IsLongitude: Rule<number> = /*#__PURE__*/ rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`);
// ============================================================================ // ============================================================================
// Strings // Strings
@@ -358,22 +358,22 @@ export function Length(min: number, max?: number, options?: ValidationOptions):
}), options); }), options);
} }
export const Email: Rule<string> = pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`); export const Email: Rule<string> = /*#__PURE__*/ pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
export const IsAlpha: Rule<string> = pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`); export const IsAlpha: Rule<string> = /*#__PURE__*/ pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
export const IsAlphanumeric: Rule<string> = pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`); export const IsAlphanumeric: Rule<string> = /*#__PURE__*/ pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`);
export const IsSemVer: Rule<string> = pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`); export const IsSemVer: Rule<string> = /*#__PURE__*/ pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`);
export const IsHexColor: Rule<string> = pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`); export const IsHexColor: Rule<string> = /*#__PURE__*/ pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`);
export const IsLowercase: Rule<string> = rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`); export const IsLowercase: Rule<string> = /*#__PURE__*/ rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`);
export const IsUppercase: Rule<string> = rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`); export const IsUppercase: Rule<string> = /*#__PURE__*/ rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
export const IsNumberString: Rule<string> = rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`); export const IsNumberString: Rule<string> = /*#__PURE__*/ rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`);
export const IsDateString: Rule<string> = rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`); export const IsDateString: Rule<string> = /*#__PURE__*/ rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`);
export const IsJSON: Rule<string> = rule('isJson', v => { export const IsJSON: Rule<string> = /*#__PURE__*/ rule('isJson', v => {
if (typeof v !== 'string') return false; if (typeof v !== 'string') return false;
try { JSON.parse(v); return true; } catch { return false; } try { JSON.parse(v); return true; } catch { return false; }
}, p => `${p} must be a JSON string`); }, p => `${p} must be a JSON string`);
export const IsUrl: Rule<string> = rule('isUrl', v => { export const IsUrl: Rule<string> = /*#__PURE__*/ rule('isUrl', v => {
try { new URL(v as string); return true; } catch { return false; } try { new URL(v as string); return true; } catch { return false; }
}, p => `${p} must be a valid URL`); }, p => `${p} must be a valid URL`);
@@ -449,10 +449,10 @@ function affix(name: string, test: (value: string, seed: string) => boolean, des
return decorator; return decorator;
} }
export const Contains = affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`); export const Contains = /*#__PURE__*/ affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`);
export const NotContains = affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`); export const NotContains = /*#__PURE__*/ affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`);
export const StartsWith = affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`); export const StartsWith = /*#__PURE__*/ affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`);
export const EndsWith = affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`); export const EndsWith = /*#__PURE__*/ affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`);
// ============================================================================ // ============================================================================
// Equality and membership // Equality and membership
+7 -2
View File
@@ -15,8 +15,13 @@ import type { ClassConstructor } from './interfaces.js';
*/ */
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata'); const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata` // Also installed globally: tsc's decorator emit reads `Symbol.metadata` directly rather than
// directly. package.json marks this module as having side effects so it survives bundling. // falling back the way we do — `typeof Symbol === "function" && Symbol.metadata ? … : void 0` —
// so without this a decorated class gets `metadata: undefined` and no rules at all.
//
// `sideEffects` in package.json keeps the statement through bundling, and must name `index.*`
// as well as this module: marking only this one leaves the barrel droppable, so the edge to it
// is pruned before this marking is ever read. Pinned by treeshake.test.ts.
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY; ((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
export interface ValidationArguments { export interface ValidationArguments {
+201
View File
@@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest';
import { build } from 'esbuild';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
/**
* Tree-shakability is a property of the source that nothing else notices when it breaks.
*
* Every rule is declared as a top-level call — `export const IsString = rule(...)` — and rollup
* can prove such a call pure by reading the factory, but esbuild and webpack will not. Without
* the `/*#__PURE__*\/` annotations on those declarations, importing one decorator dragged in the
* message and validator of all 68: 4909 bytes rather than 1837 through esbuild, 4823 rather
* than 1818 through webpack. Nothing failed. The library simply got three times heavier in
* every consumer's bundle, and the only way to notice was to go and measure.
*
* So these assertions are mostly about *content* rather than bytes: a byte ceiling tells you
* something drifted, but naming the thing that should not be there says what.
*/
/** Markers that identify a chunk of the library in minified output. */
const MARKER = {
isString: 'must be a string',
minLength: 'must be longer than or equal to',
isLatitude: 'must be a latitude',
isSemVer: 'must be a valid semantic version',
arraySize: 'must contain at least',
serializer: 'Circular reference',
representable: 'cannot be serialized to JSON',
deserializer: 'Unknown property',
validator: '[redacted]',
naming: 'SCREAMING_SNAKE_CASE',
} as const;
const ENTRY = JSON.stringify(path.resolve('src/index.js'));
async function bundle(source: string): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-shake-'));
try {
const entry = path.join(dir, 'entry.ts');
await writeFile(entry, source);
const result = await build({
entryPoints: [entry],
bundle: true,
format: 'esm',
minify: true,
target: 'es2022',
write: false,
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
});
return result.outputFiles[0]!.text;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/**
* Asserts what a bundle kept and what it dropped.
*
* `keeps` is not decoration. A bundle that failed to build, or that resolved the library as an
* external and inlined none of it, contains none of the markers — so an "everything was shaken"
* result and a broken harness look identical without it.
*/
function expectShaken(code: string, keeps: string[], drops: string[]) {
expect(code.length, 'the bundle is empty — the harness is broken, not the tree-shaking').toBeGreaterThan(200);
for (const marker of keeps) {
expect(code, `expected the bundle to contain ${JSON.stringify(marker)}`).toContain(marker);
}
for (const marker of drops) {
expect(code, `${JSON.stringify(marker)} should have been shaken out`).not.toContain(marker);
}
}
describe('tree-shaking', () => {
it('drops the 67 rules you did not import', async () => {
const code = await bundle(`
import { IsString } from ${ENTRY};
export const d = IsString();
`);
expectShaken(code, [MARKER.isString], [
MARKER.isLatitude, MARKER.isSemVer, MARKER.arraySize, MARKER.minLength,
MARKER.serializer, MARKER.deserializer, MARKER.naming,
]);
// Generous ceiling: the measured figure is ~1.8 KB, and this is here to catch a regression
// of the kind above (which trebled it), not to police every byte.
expect(code.length).toBeLessThan(3000);
});
it('keeps the deserializer and drops the serializer when only reading', async () => {
const code = await bundle(`
import { toInstanceSync } from ${ENTRY};
export const f = (C, p) => toInstanceSync(C, p, { validate: false });
`);
// Validation is kept on purpose: `validate` defaults to true, so the entry point
// references it whatever the call site passes.
expectShaken(code, [MARKER.deserializer, MARKER.validator], [MARKER.serializer, MARKER.representable]);
});
it('keeps the serializer and drops the deserializer when only writing', async () => {
const code = await bundle(`
import { toPlainSync } from ${ENTRY};
export const f = (o) => toPlainSync(o, { validate: false });
`);
expectShaken(code, [MARKER.serializer, MARKER.representable, MARKER.validator], [MARKER.deserializer]);
});
it('drops both engines when only validating', async () => {
const code = await bundle(`
import { validateSync } from ${ENTRY};
export const f = (o) => validateSync(o);
`);
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
});
/**
* The `Symbol.metadata` install at the top of metadata.ts is a bare statement, not an export,
* so it survives only because `sideEffects` names that module — and naming it is one hop short
* of enough. The barrel that re-exports it is side-effect-free too, so a bundler prunes the
* `export * from './metadata.js'` edge before metadata.js's own marking is ever consulted.
*
* Nothing notices, in the usual way. `import { configure } from 'cereale'` came out at 145
* bytes through esbuild, 143 through webpack and 144 through rollup with `Symbol.metadata`
* absent from all three — and a tsc-compiled consumer on a runtime without the well-known
* symbol is then decorated with `metadata: undefined`, which is to say with no rules at all.
*/
it('installs Symbol.metadata even when nothing model-shaped is imported', async () => {
const code = await bundle(`
import { configure } from ${ENTRY};
export const f = (o) => configure(o);
`);
expect(code, 'the Symbol.metadata install was pruned along with the barrel').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer, MARKER.validator]);
});
it('costs almost nothing to import only an error helper', async () => {
const code = await bundle(`
import { flattenErrors } from ${ENTRY};
export const f = (e) => flattenErrors(e);
`);
expectShaken(code, [], [MARKER.serializer, MARKER.deserializer, MARKER.validator, MARKER.isString]);
expect(code.length).toBeLessThan(1500);
});
it('still contains everything when everything is used', async () => {
const code = await bundle(`
import * as cereale from ${ENTRY};
export default cereale;
`);
// The counterweight to every assertion above: proves the markers are findable at all, so a
// "shaken" result upstream means shaken rather than misspelled.
expectShaken(code, Object.values(MARKER), []);
});
/**
* The cases above bundle `src/`, which is where the annotations are written — so they cannot
* see what happens to them on the way into `dist/`. That is exactly where this broke: the
* flat bundle behind `cereale/min` was built with esbuild's `minify: true`, whose
* `minifyWhitespace` pass strips comments, annotations included. The published entry point
* kept all 26 unrelated rules (5,066 bytes against 1,837) while every source-level check
* stayed green.
*/
describe('the published artifacts', () => {
const dist = path.resolve('dist');
const built = existsSync(path.join(dist, 'cereale.min.js'));
it.runIf(built)('cereale/min tree-shakes as well as the per-module entry', async () => {
const code = await bundle(`
import { IsString } from ${JSON.stringify(path.join(dist, 'cereale.min.js'))};
export const d = IsString();
`);
expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]);
expect(code.length).toBeLessThan(3000);
});
/**
* The source-level case above proves the `sideEffects` mechanism works; this one proves the
* two entries are spelled the way the published tree is laid out. `./src/index.ts` and
* `./dist/esm/index.js` are separate paths in the manifest, and a typo in either is invisible
* from the other side.
*/
it.runIf(built)('installs Symbol.metadata from the published barrel too', async () => {
const code = await bundle(`
import { configure } from ${JSON.stringify(path.join(dist, 'esm/index.js'))};
export const f = (o) => configure(o);
`);
expect(code, 'the Symbol.metadata install was pruned from dist/esm').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer]);
});
});
});
+5 -5
View File
@@ -42,7 +42,7 @@ export class JsonMappingError extends Error {
* next steps in a pollution chain. This library exists to parse request bodies, so the * next steps in a pollution chain. This library exists to parse request bodies, so the
* transform layer drops them rather than trusting callers to sanitise first. * transform layer drops them rather than trusting callers to sanitise first.
*/ */
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const FORBIDDEN_KEYS = /*#__PURE__*/ new Set(['__proto__', 'constructor', 'prototype']);
/** /**
* Stands in for the value of a property that is never serialized, so that a failing password * Stands in for the value of a property that is never serialized, so that a failing password
@@ -96,7 +96,7 @@ interface OutboundProperty {
serializer?: any; serializer?: any;
} }
const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>(); const outboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
/** /**
* Resolves how one property is written out, memoized per (prototype, naming strategy). * Resolves how one property is written out, memoized per (prototype, naming strategy).
@@ -167,7 +167,7 @@ interface InboundNames {
// Name maps are derived purely from decorator metadata, which is fixed once a class is // Name maps are derived purely from decorator metadata, which is fixed once a class is
// declared, so they are cached per (prototype, naming strategy). // declared, so they are cached per (prototype, naming strategy).
const inboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>(); const inboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
/** /**
* Builds the JSON-name -> property-key lookup used when reading a payload. * Builds the JSON-name -> property-key lookup used when reading a payload.
@@ -614,7 +614,7 @@ interface CachedPlan {
// Turning a class model into a per-property plan is cheap, but doing it on every call was // Turning a class model into a per-property plan is cheap, but doing it on every call was
// measurably not: profiling showed roughly half of all validation time re-deriving answers // measurably not: profiling showed roughly half of all validation time re-deriving answers
// that cannot change. Plans are memoized per model and invalidated by the model version. // that cannot change. Plans are memoized per model and invalidated by the model version.
const planCache = new WeakMap<ClassModel, CachedPlan>(); const planCache = /*#__PURE__*/ new WeakMap<ClassModel, CachedPlan>();
/** /**
* Collapses rules that are genuinely identical. * Collapses rules that are genuinely identical.
@@ -663,7 +663,7 @@ function validationPlan(model: ClassModel): PropertyPlan[] {
// Serializers and deserializers are stateless by contract, so one instance per class is // Serializers and deserializers are stateless by contract, so one instance per class is
// enough. Constructing a fresh one for every property of every object was pure waste. // enough. Constructing a fresh one for every property of every object was pure waste.
const converterCache = new WeakMap<object, any>(); const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
function converterFor(clazz: any): any { function converterFor(clazz: any): any {
let instance = converterCache.get(clazz); let instance = converterCache.get(clazz);
+4 -10
View File
@@ -1,5 +1,9 @@
{ {
// Visit https://aka.ms/tsconfig to read more about this file // Visit https://aka.ms/tsconfig to read more about this file
// Shared (@avalon-vanguard/config/tsconfig/library.json): sourceMap, declaration,
// declarationMap, noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch,
// isolatedModules, skipLibCheck.
"extends": "@avalon-vanguard/config/tsconfig/library.json",
"compilerOptions": { "compilerOptions": {
// File Layout // File Layout
"rootDir": "src", "rootDir": "src",
@@ -11,27 +15,17 @@
"lib": ["ESNext", "ESNext.Decorators"], "lib": ["ESNext", "ESNext.Decorators"],
"types": ["node"], "types": ["node"],
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options // Stricter Typechecking Options
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
// Style Options // Style Options
"noImplicitReturns": true,
"noImplicitOverride": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// Recommended Options // Recommended Options
"strict": true, "strict": true,
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"isolatedModules": true,
"skipLibCheck": true,
// NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what // NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what
// gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of // gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of