Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f2f3f0af1 | ||
|
|
e4b8c0f6aa | ||
|
|
68d5d46ec0 | ||
|
|
5f2e310f61 | ||
|
|
057162e45b | ||
|
|
46e30909c7 | ||
|
|
b2f2de2bd4 | ||
|
|
1b9202edf1 | ||
|
|
730e5902a5 | ||
|
|
f56d2811cb | ||
|
|
7a8109a50e | ||
|
|
6adf26964e | ||
|
|
88e5ee23a9 | ||
|
|
41ea03f09b | ||
|
|
73f0b88997 | ||
|
|
4b910f19bf | ||
|
|
f4c5e214f9 | ||
|
|
60cb8303ed | ||
|
|
c13aff9f2b | ||
|
|
1720f2274d | ||
|
|
a2deaffe0b | ||
|
|
fb84d82c84 | ||
|
|
5714fab42e | ||
|
|
3bd190bde6 | ||
|
|
c48a106a05 | ||
|
|
2a2f7345ad | ||
|
|
b4f0657d09 |
@@ -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
|
||||
@@ -34,11 +34,7 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Verify published entry points load
|
||||
run: |
|
||||
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');"
|
||||
run: node scripts/check-entry-points.mjs
|
||||
- name: Run Demo
|
||||
run: npm run demo
|
||||
- name: Published types stand alone
|
||||
@@ -46,7 +42,5 @@ jobs:
|
||||
- name: Landing page loads nothing from the network
|
||||
run: npm run check:docs
|
||||
- name: Landing page bundle is in sync with src/
|
||||
run: |
|
||||
npm run build:docs
|
||||
git diff --exit-code -- docs/ \
|
||||
|| (echo "docs/ is stale — run 'npm run build:docs' and commit the result" && exit 1)
|
||||
# Also fails on NEW untracked files under docs/ — a plain `git diff` does not.
|
||||
run: npm run check:docs-sync
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
# second one hard to take back. Run this workflow from the Actions tab when you mean it.
|
||||
#
|
||||
# Before the first real run:
|
||||
# 1. Create an npm automation token and add it as the NPM_TOKEN repository secret.
|
||||
# 2. Run once with dry_run left as `true` and read the file list it prints.
|
||||
# 3. Run again with dry_run set to `false`.
|
||||
# Authentication is by **trusted publishing** (OIDC): 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. To enable it, on npmjs.com → the cereale package →
|
||||
# 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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -18,7 +36,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # required for npm provenance
|
||||
id-token: write # the OIDC identity npm exchanges, and provenance
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -37,6 +55,21 @@ jobs:
|
||||
cache: 'npm'
|
||||
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
|
||||
run: npm ci
|
||||
|
||||
@@ -72,12 +105,28 @@ jobs:
|
||||
run: npm run verify
|
||||
|
||||
- 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
|
||||
|
||||
- name: Publish
|
||||
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
|
||||
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 }}
|
||||
|
||||
- name: Summary
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@avalon-vanguard:registry=https://git.avalonvanguard.com/api/packages/avalon-vanguard/npm/
|
||||
+127
@@ -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/),
|
||||
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
|
||||
|
||||
Every change here comes from the same question: where does cereale currently fail *quietly*?
|
||||
|
||||
+344
@@ -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>
|
||||
```
|
||||
@@ -1,5 +1,12 @@
|
||||
# Cereale
|
||||
|
||||
[](https://www.npmjs.com/package/cereale)
|
||||
[](https://github.com/avalon-vanguard/cereale/actions/workflows/ci.yml)
|
||||
[](https://avalon-vanguard.github.io/cereale/)
|
||||
[](https://github.com/avalon-vanguard/cereale/blob/main/package.json)
|
||||
[](https://github.com/avalon-vanguard/cereale#installation)
|
||||
[](LICENSE)
|
||||
|
||||
**Validated domain objects, not validated data.**
|
||||
|
||||
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
|
||||
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
|
||||
served from `docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
|
||||
and the toolchain matrix. The page has no third-party dependencies — no CDN, no analytics, no webfonts; the only thing it
|
||||
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
|
||||
|
||||
@@ -75,6 +84,9 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d
|
||||
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:
|
||||
|
||||
```json
|
||||
@@ -103,11 +115,25 @@ inside a native binary with no standalone transform API.
|
||||
|
||||
| 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 |
|
||||
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
||||
| **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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+290
-135
@@ -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="color-scheme" content="light dark">
|
||||
<link rel="canonical" href="https://avalon-vanguard.github.io/cereale/">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 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
|
||||
without, and there is no artwork to point at yet. -->
|
||||
@@ -22,35 +22,55 @@
|
||||
|
||||
<style>
|
||||
/* ---------------------------------------------------------------- tokens */
|
||||
/* Chaff: one paper, one ink, one repeated 2px mark. The accent is rye-brown and
|
||||
deliberately NOT wheat-gold — gold is both the cliche and a collision with --warn,
|
||||
which already owns amber. Two amber families would make "our brand" and "needs
|
||||
your attention" the same colour.
|
||||
|
||||
Every token below that differs between themes must be written in THREE places: this
|
||||
block (the light palette), the prefers-color-scheme block, and [data-theme="dark"].
|
||||
The dark media block is guarded with :not([data-theme="light"]), so an explicit light
|
||||
toggle falls straight through to these values — there is no light copy to keep in sync. */
|
||||
:root {
|
||||
--bg: #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;
|
||||
--bg: #faf7f0;
|
||||
--bg-raised: #fffdf7;
|
||||
--bg-sunken: #f2ede1;
|
||||
--text: #1a1712;
|
||||
--text-muted: #5c5346;
|
||||
--text-faint: #6e6353;
|
||||
--border: #e6dfd1;
|
||||
--border-strong: #cfc5b2;
|
||||
/* Icon-only controls need 3:1 against the page (WCAG 1.4.11); --border-strong is
|
||||
decorative and sits well below it. .icon-btn and .btn-secondary use this instead. */
|
||||
--border-ui: #8e8269;
|
||||
--accent: #8a6238;
|
||||
--accent-text: #7a5530;
|
||||
--accent-soft: #f1e9dc;
|
||||
/* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent
|
||||
for legibility against the page, which then leaves white text on it below AA. */
|
||||
--accent-solid: #4f46e5;
|
||||
--on-accent: #ffffff;
|
||||
--bad: #d4183d;
|
||||
--bad-soft: #fff1f3;
|
||||
--ok: #08795a;
|
||||
--ok-soft: #eefaf5;
|
||||
--warn: #9a5b00;
|
||||
--warn-soft: #fff7ea;
|
||||
--accent-solid: #6b4a28;
|
||||
--on-accent: #fffdf7;
|
||||
--bad: #a82820;
|
||||
--bad-soft: #faebe7;
|
||||
--ok: #256b3d;
|
||||
--ok-soft: #e8f2e9;
|
||||
--warn: #8a5a05;
|
||||
--warn-soft: #fbf1dc;
|
||||
|
||||
/* Code surfaces stay dark in both themes: one syntax palette, always legible. */
|
||||
--code-bg: #16161f;
|
||||
--code-bg-raised: #1e1e29;
|
||||
--code-border: #2b2b3a;
|
||||
--code-text: #d6deeb;
|
||||
--code-faint: #8a93b8;
|
||||
/* Code surfaces stay dark in both themes: one syntax palette, always legible.
|
||||
Warmed off the blue-grey axis so the slab does not read cold against oat paper. */
|
||||
--code-bg: #14120c;
|
||||
--code-bg-raised: #1c190f;
|
||||
--code-border: #2e2818;
|
||||
--code-text: #e2dccb;
|
||||
--code-faint: #9a9280;
|
||||
/* The accent is unreadable on the dark slab in light mode, so focus rings drawn on a
|
||||
code surface get their own token. Without it #editor:focus measures 2.86:1 — a live
|
||||
1.4.11 failure on the shipped page. This measures 6.96:1. */
|
||||
--accent-on-code: #c9962f;
|
||||
/* The error line is the page's one visual device; these were three loose literals. */
|
||||
--err-line: #ff6a7e;
|
||||
--err-text: #ffb3c0;
|
||||
--t-comment: #8a93b8;
|
||||
--t-string: #b8e08a;
|
||||
--t-keyword: #c792ea;
|
||||
@@ -58,64 +78,63 @@
|
||||
--t-type: #ffcb6b;
|
||||
--t-number: #f78c6c;
|
||||
|
||||
/* The whole ornament: a 2px mark every 9px. var() resolves against the winning
|
||||
cascaded value, so --accent changing with the theme retints these automatically —
|
||||
they belong on this block only, never in the three below. */
|
||||
--grain-mark: 2px;
|
||||
--grain-pitch: 9px;
|
||||
--rule-x: repeating-linear-gradient(90deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
||||
--rule-y: repeating-linear-gradient(180deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
||||
--rule-code: repeating-linear-gradient(90deg, var(--code-faint) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
||||
|
||||
--radius: 10px;
|
||||
--radius-lg: 16px;
|
||||
--shadow: 0 1px 2px rgba(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;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--measure: 68ch;
|
||||
--measure: 64ch;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0e0e14;
|
||||
--bg-raised: #16161f;
|
||||
--bg-sunken: #12121a;
|
||||
--text: #e8e8f0;
|
||||
--text-muted: #a3a3b4;
|
||||
--text-faint: #9494ab;
|
||||
--border: #262633;
|
||||
--border-strong: #363648;
|
||||
--accent: #8b85ff;
|
||||
--accent-text: #a5a0ff;
|
||||
--accent-soft: #1c1b35;
|
||||
--accent-solid: #8b85ff;
|
||||
--on-accent: #10101a;
|
||||
--bad: #ff8095;
|
||||
--bad-soft: #2a1620;
|
||||
--ok: #5cd0a8;
|
||||
--ok-soft: #10241f;
|
||||
--warn: #e5a54b;
|
||||
--warn-soft: #251d10;
|
||||
--code-bg: #12121a;
|
||||
--code-bg-raised: #191922;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #12100b;
|
||||
--bg-raised: #1a1710;
|
||||
--bg-sunken: #16130d;
|
||||
--text: #ede7da;
|
||||
--text-muted: #aba292;
|
||||
--text-faint: #9b9280;
|
||||
--border: #29241a;
|
||||
--border-strong: #3b3427;
|
||||
--border-ui: #736a59;
|
||||
--accent: #c4a97c;
|
||||
--accent-text: #d8be94;
|
||||
--accent-soft: #2a2115;
|
||||
--accent-solid: #d8be94;
|
||||
--on-accent: #17120a;
|
||||
--bad: #f2867e;
|
||||
--bad-soft: #2b1512;
|
||||
--ok: #6fc98c;
|
||||
--ok-soft: #12241a;
|
||||
--warn: #e9b45a;
|
||||
--warn-soft: #261d0c;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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"] {
|
||||
--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;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0e0e14; --bg-raised: #16161f; --bg-sunken: #12121a;
|
||||
--text: #e8e8f0; --text-muted: #a3a3b4; --text-faint: #9494ab;
|
||||
--border: #262633; --border-strong: #363648;
|
||||
--accent: #8b85ff; --accent-text: #a5a0ff; --accent-soft: #1c1b35;
|
||||
--accent-solid: #8b85ff; --on-accent: #10101a;
|
||||
--bad: #ff8095; --bad-soft: #2a1620; --ok: #5cd0a8; --ok-soft: #10241f;
|
||||
--warn: #e5a54b; --warn-soft: #251d10;
|
||||
--code-bg: #12121a; --code-bg-raised: #191922;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
||||
--bg: #12100b; --bg-raised: #1a1710; --bg-sunken: #16130d;
|
||||
--text: #ede7da; --text-muted: #aba292; --text-faint: #9b9280;
|
||||
--border: #29241a; --border-strong: #3b3427; --border-ui: #736a59;
|
||||
--accent: #c4a97c; --accent-text: #d8be94; --accent-soft: #2a2115;
|
||||
--accent-solid: #d8be94; --on-accent: #17120a;
|
||||
--bad: #f2867e; --bad-soft: #2b1512; --ok: #6fc98c; --ok-soft: #12241a;
|
||||
--warn: #e9b45a; --warn-soft: #261d0c;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -132,17 +151,22 @@ body {
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 680; letter-spacing: -.02em; }
|
||||
h1 { font-size: clamp(2.1rem, 1.3rem + 3.4vw, 3.5rem); letter-spacing: -.035em; }
|
||||
h2 { font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.1rem); letter-spacing: -.028em; }
|
||||
h3 { font-size: 1.125rem; }
|
||||
/* 680 and 640 are fiction on a non-variable system stack — they round to 700. And -.035em
|
||||
is generic-landing-page tracking; it is most of what made this look like every other
|
||||
dev-tool site. */
|
||||
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 700; letter-spacing: -.012em; }
|
||||
h1 { font-size: clamp(2rem, 1.35rem + 2.8vw, 3rem); line-height: 1.14; letter-spacing: -.018em; }
|
||||
h2 { font-size: clamp(1.4rem, 1.1rem + 1.3vw, 1.85rem); letter-spacing: -.012em; }
|
||||
h3 { font-size: 1.0625rem; letter-spacing: -.008em; }
|
||||
p { margin: 0 0 1rem; }
|
||||
a { color: var(--accent-text); text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); text-underline-offset: .18em; }
|
||||
a:hover { text-decoration-color: currentColor; }
|
||||
/* Links keep body colour and are marked by a rule instead. On a palette this warm,
|
||||
--accent-text and --text-muted sit 1.14:1 apart — colour alone would lose them. */
|
||||
a { color: var(--text); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: .16em; }
|
||||
a:hover { color: var(--accent-text); text-decoration-color: currentColor; }
|
||||
code, kbd, pre { font-family: var(--mono); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
@@ -153,15 +177,23 @@ code, kbd, pre { font-family: var(--mono); }
|
||||
section { padding-block: clamp(3rem, 6vw, 5.5rem); }
|
||||
.lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); }
|
||||
.eyebrow {
|
||||
font-size: .75rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
|
||||
color: var(--accent-text); margin: 0 0 .6rem;
|
||||
font-family: var(--mono);
|
||||
font-size: .6875rem; font-weight: 500; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent-text); margin: 0 0 .7rem;
|
||||
}
|
||||
.section-head { margin-bottom: 2.25rem; }
|
||||
/* The ornament, use 1 of 3: a section begins. The hero is the page beginning, not a
|
||||
section, so it deliberately has no .section-head and no mark. */
|
||||
.section-head::before {
|
||||
content: ""; display: block; width: 4.5rem; height: 3px;
|
||||
margin-bottom: .95rem; background-image: var(--rule-x);
|
||||
}
|
||||
.skip {
|
||||
position: absolute; left: -9999px; top: 0; z-index: 100;
|
||||
background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
|
||||
}
|
||||
.skip:focus { left: 0; }
|
||||
.skip:hover { color: var(--on-accent); }
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
||||
@@ -175,8 +207,8 @@ header.nav {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.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 .grain { font-size: 1rem; }
|
||||
.brand { display: flex; align-items: center; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; }
|
||||
.brand .mark { color: var(--accent); flex: none; }
|
||||
.badge {
|
||||
font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px;
|
||||
background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
@@ -187,10 +219,13 @@ header.nav {
|
||||
}
|
||||
.nav-links a:hover { color: var(--text); background: var(--bg-sunken); }
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-strong); }
|
||||
.btn-secondary:hover { border-color: var(--text-faint); }
|
||||
/* Re-assert label colours on hover: the base a:hover (0,1,1) outranks these classes
|
||||
(0,1,0), and in dark theme --accent-text equals --accent-solid — a hovered label
|
||||
painted in its own background. */
|
||||
.btn-primary:hover { filter: brightness(1.08); color: var(--on-accent); }
|
||||
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); }
|
||||
.btn-secondary:hover { border-color: var(--text-faint); color: var(--text); }
|
||||
.fact-row {
|
||||
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
|
||||
font-size: .8125rem; color: var(--text-muted);
|
||||
}
|
||||
.fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; }
|
||||
.fact b { color: var(--text); font-weight: 640; }
|
||||
.fact b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* ---------------------------------------------------------------- code */
|
||||
.code {
|
||||
@@ -228,8 +266,11 @@ header.nav {
|
||||
border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised);
|
||||
font: 500 .75rem/1 var(--mono); color: var(--code-faint);
|
||||
}
|
||||
.code-head .dot { width: .5rem; height: .5rem; border-radius: 50%; background: #33334a; }
|
||||
.code-head .name { margin-left: .35rem; }
|
||||
/* Use 3 of 3, and a deletion: the fake traffic lights are gone. */
|
||||
.code-head::before {
|
||||
content: ""; flex: none; width: 1.05rem; height: 2px;
|
||||
background-image: var(--rule-code);
|
||||
}
|
||||
.code-head .right { margin-left: auto; }
|
||||
.code pre {
|
||||
margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto;
|
||||
@@ -246,18 +287,18 @@ header.nav {
|
||||
|
||||
/* The page's one visual device: the line the compiler refuses. */
|
||||
.ln--error {
|
||||
background: rgba(255, 92, 122, .09);
|
||||
text-decoration: underline wavy #ff5c7a;
|
||||
background: color-mix(in srgb, var(--err-line) 9%, transparent);
|
||||
text-decoration: underline wavy var(--err-line);
|
||||
text-decoration-skip-ink: none;
|
||||
text-underline-offset: .32em;
|
||||
}
|
||||
.tsc-error {
|
||||
display: flex; gap: .6rem; align-items: flex-start;
|
||||
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
|
||||
background: 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);
|
||||
}
|
||||
.tsc-error .mark { color: #ff5c7a; flex-shrink: 0; }
|
||||
.tsc-error .mark { color: var(--err-line); flex-shrink: 0; }
|
||||
|
||||
/* --------------------------------------------------------------- panels */
|
||||
.panel {
|
||||
@@ -271,6 +312,7 @@ header.nav {
|
||||
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
||||
.tick { color: var(--ok); font-weight: 700; }
|
||||
.cross { color: var(--bad); font-weight: 700; }
|
||||
.warn-mark { color: var(--warn); font-weight: 700; }
|
||||
|
||||
.compare { display: grid; gap: 1rem; }
|
||||
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
|
||||
@@ -302,14 +344,17 @@ header.nav {
|
||||
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
#editor:focus { outline: 2px solid var(--accent); 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 {
|
||||
flex: 1; min-height: 400px; margin: 0; overflow: auto;
|
||||
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
|
||||
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
#output .out-err { color: #ff8095; }
|
||||
#output .out-err { color: var(--err-text); }
|
||||
#output .out-dim { color: var(--code-faint); }
|
||||
.pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; }
|
||||
|
||||
@@ -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-filter {
|
||||
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);
|
||||
}
|
||||
#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 */
|
||||
.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 p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
||||
.callout {
|
||||
@@ -364,14 +410,15 @@ footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(
|
||||
|
||||
<header class="nav">
|
||||
<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">
|
||||
<a class="nav-hide" href="#guarantee">Guarantee</a>
|
||||
<a class="nav-hide" href="#playground">Playground</a>
|
||||
<a class="nav-hide" href="#errors">Errors</a>
|
||||
<a class="nav-hide" href="#install">Install</a>
|
||||
<a class="nav-hide" href="#size">Size</a>
|
||||
<a class="nav-hide" href="#reference">Reference</a>
|
||||
<a class="ghost" href="https://github.com/Avalon-Vanguard/cereale">GitHub</a>
|
||||
<a class="ghost" href="https://github.com/avalon-vanguard/cereale">GitHub</a>
|
||||
<button class="icon-btn" id="theme-toggle" type="button" aria-label="Switch between light and dark theme">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
|
||||
@@ -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>
|
||||
Run it in the browser
|
||||
</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 class="fact-row">
|
||||
<span class="fact"><b>0</b> runtime dependencies</span>
|
||||
<span class="fact"><b id="decorator-count">68</b> decorators</span>
|
||||
<span class="fact">TC39 <b>standard decorators</b></span>
|
||||
<span class="fact">ESM + CJS</span>
|
||||
<span class="fact">ESM + CJS + single-file</span>
|
||||
<span class="fact">Node <b id="node-req">≥20</b></span>
|
||||
<span class="fact"><b>1.8 KB</b> for one decorator</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="code">
|
||||
<div class="code-head">
|
||||
<span class="dot" aria-hidden="true"></span><span class="name">order.ts</span>
|
||||
<span class="name">order.ts</span>
|
||||
</div>
|
||||
<pre><code data-lang="ts" data-error-line="9">class Order {
|
||||
@JsonProperty('order_ref')
|
||||
@@ -463,7 +511,7 @@ order.total(); // methods intact</code></pre>
|
||||
<div>
|
||||
<p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p>
|
||||
<div class="code">
|
||||
<div class="code-head"><span class="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 {
|
||||
@IsString()
|
||||
age: number; // accepted by the compiler
|
||||
@@ -476,7 +524,7 @@ order.total(); // methods intact</code></pre>
|
||||
<div>
|
||||
<p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p>
|
||||
<div class="code">
|
||||
<div class="code-head"><span class="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 {
|
||||
@IsString()
|
||||
age!: number; // Type 'number' is not
|
||||
@@ -520,7 +568,7 @@ order.total(); // methods intact</code></pre>
|
||||
</div>
|
||||
<div class="compare">
|
||||
<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 {
|
||||
@IsString() title!: string;
|
||||
}
|
||||
@@ -542,7 +590,7 @@ class Playlist {
|
||||
}</code></pre>
|
||||
</div>
|
||||
<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);
|
||||
const first = list.items[0];
|
||||
|
||||
@@ -587,14 +635,14 @@ if (first instanceof Movie) {
|
||||
<div class="pg-grid">
|
||||
<div class="pg-pane">
|
||||
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
||||
<span class="dot" aria-hidden="true"></span><span class="name">playground.ts</span>
|
||||
<span class="name">playground.ts</span>
|
||||
</div>
|
||||
<textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
||||
aria-label="TypeScript source to run"></textarea>
|
||||
</div>
|
||||
<div class="pg-pane">
|
||||
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
||||
<span class="dot" aria-hidden="true"></span><span class="name">output</span>
|
||||
<span class="name">output</span>
|
||||
<span class="right" id="pg-status"></span>
|
||||
</div>
|
||||
<pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre>
|
||||
@@ -616,7 +664,7 @@ if (first instanceof Movie) {
|
||||
<p class="eyebrow">Diagnosability</p>
|
||||
<h2>Nothing fails quietly</h2>
|
||||
<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
|
||||
into an error that names the cause and the way out.
|
||||
</p>
|
||||
@@ -638,7 +686,7 @@ if (first instanceof Movie) {
|
||||
</div>
|
||||
|
||||
<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.
|
||||
Give the property a @JsonSerialize() serializer that converts it, or drop it from
|
||||
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>
|
||||
<p class="lede">
|
||||
cereale reads the metadata that only a <strong>standard</strong> decorator transform emits, so
|
||||
which compiler you use decides whether it works at all. The three ✓ rows 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
|
||||
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
|
||||
@@ -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>
|
||||
</thead>
|
||||
<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> 5.2+</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code>, <code>target: ES2022</code>+</td></tr>
|
||||
<tr><td>esbuild</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code> via <code>tsconfigRaw</code>, <em>plus</em> esbuild's own top-level <code>target: es2022</code> — its default <code>esnext</code> leaves the decorators in place</td></tr>
|
||||
<tr><td>swc</td><td><span class="tick">✓</span></td><td class="note"><code>jsc.transform.decoratorVersion: "2022-03"</code></td></tr>
|
||||
<tr><td>oxc</td><td><span class="cross">✗</span></td><td class="note">used by <strong>Vite 8</strong> and <strong>Vitest 4</strong> — see below</td></tr>
|
||||
@@ -693,8 +742,29 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
|
||||
that fixes it.
|
||||
</div>
|
||||
|
||||
<div class="table-scroll" style="margin-top:1.5rem">
|
||||
<table>
|
||||
<caption class="sr-only">Framework support</caption>
|
||||
<thead><tr><th scope="col">Framework</th><th scope="col">Works</th><th scope="col">What it takes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Angular 21</td><td><span class="tick">✓</span></td><td class="note">Flip the scaffolded <code>experimentalDecorators</code> to <code>false</code> — Angular does not need it</td></tr>
|
||||
<tr><td>React, Vue, Svelte…</td><td><span class="tick">✓</span></td><td class="note">Any Vite 8 app — add the plugin below</td></tr>
|
||||
<tr><td>Bun 1.3</td><td><span class="tick">✓</span></td><td class="note">Nothing</td></tr>
|
||||
<tr><td>Node + <code>tsc</code></td><td><span class="tick">✓</span></td><td class="note">Just the flag</td></tr>
|
||||
<tr><td>Next.js 16</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — keep models in a package compiled by <code>tsc</code></td></tr>
|
||||
<tr><td>NestJS 11.1</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — its DI needs <code>emitDecoratorMetadata</code>; same precompiled route</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="pg-note" style="max-width:var(--measure)">
|
||||
Each of these was set up and run before it was written down, on 2026-08-05, against the
|
||||
versions listed.
|
||||
<a href="https://github.com/avalon-vanguard/cereale/blob/main/FRAMEWORKS.md">FRAMEWORKS.md</a>
|
||||
has the full recipe for every one, including the two that need the precompiled route.
|
||||
</p>
|
||||
|
||||
<div class="code" style="margin-top:1.25rem;max-width:640px">
|
||||
<div class="code-head"><span class="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';
|
||||
import { standardDecorators } from 'cereale/vite';
|
||||
|
||||
@@ -719,14 +789,14 @@ export default defineConfig({
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<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 class="compare">
|
||||
<div>
|
||||
<p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p>
|
||||
<div class="code">
|
||||
<div class="code-head"><span class="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">{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
@@ -744,22 +814,105 @@ export default defineConfig({
|
||||
</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-head"><span class="dot" aria-hidden="true"></span><span class="name">shell</span></div>
|
||||
<pre><code data-lang="text">git clone https://github.com/Avalon-Vanguard/cereale
|
||||
cd cereale
|
||||
npm install && npm run build
|
||||
npm pack # → cereale-0.3.0.tgz
|
||||
|
||||
# then, from your own project
|
||||
npm install ../cereale/cereale-0.3.0.tgz</code></pre>
|
||||
<div class="code-head"><span class="name">shell</span></div>
|
||||
<pre><code data-lang="text">npm install cereale</code></pre>
|
||||
</div>
|
||||
<p class="pg-note">
|
||||
<code class="inline-code">npm install cereale</code> does <strong>not</strong> resolve to
|
||||
this library — the name is unclaimed on the registry. Installing straight from GitHub
|
||||
will not work either: the build output is not committed, so the package would arrive
|
||||
without its <code class="inline-code">dist/</code>.
|
||||
Published from CI with <strong>provenance</strong>, so the registry carries a verified
|
||||
attestation linking the tarball to the commit it was built from — visible on the
|
||||
<a href="https://www.npmjs.com/package/cereale">package page</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:1.75rem">
|
||||
<h3>No bundler at all</h3>
|
||||
<p>
|
||||
<code class="inline-code">cereale/min</code> is the whole library flattened into one
|
||||
minified ES module — <strong>33.9 KB</strong>, 9.6 KB gzipped — for import maps,
|
||||
a bare <code class="inline-code"><script type="module"></code>, Deno and Workers.
|
||||
</p>
|
||||
<div class="code">
|
||||
<div class="code-head"><span class="name">index.html</span></div>
|
||||
<pre><code data-lang="text"><script type="importmap">
|
||||
{ "imports": { "cereale": "./node_modules/cereale/dist/cereale.min.js" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import { IsString, toInstanceSync } from 'cereale';
|
||||
</script></code></pre>
|
||||
</div>
|
||||
<p class="pg-note">
|
||||
<strong>If you are using a bundler, prefer the default entry.</strong> The flat file keeps
|
||||
its purity annotations, so a bundler can still drop the rules you did not import — that
|
||||
is pinned by a test — but the per-module build shakes slightly leaner (1,837 bytes
|
||||
against 1,996 for one decorator) and is the canonical route.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================= size -->
|
||||
<section id="size" style="background:var(--bg-sunken);border-block:1px solid var(--border)">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Bundle size</p>
|
||||
<h2>You pay for the decorators you name</h2>
|
||||
<p class="lede">
|
||||
<b class="js-dec-count">68</b> decorators is a lot to ship to a browser, so none of the ones you did not
|
||||
import are shipped. Minified bytes, measured through all three bundlers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<caption class="sr-only">Minified bytes by import, across three bundlers</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">What you import</th>
|
||||
<th scope="col">esbuild</th><th scope="col">rollup</th><th scope="col">webpack</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>flattenErrors</code></td><td>394</td><td>367</td><td>394</td></tr>
|
||||
<tr><td>one decorator</td><td>1,837</td><td>1,823</td><td>1,818</td></tr>
|
||||
<tr><td><code>validateSync</code></td><td>3,722</td><td>3,554</td><td>3,738</td></tr>
|
||||
<tr><td><code>toPlainSync</code></td><td>7,744</td><td>7,769</td><td>7,771</td></tr>
|
||||
<tr><td><code>toInstanceSync</code></td><td>7,900</td><td>7,942</td><td>7,944</td></tr>
|
||||
<tr><td>a typical DTO</td><td>10,395</td><td>10,402</td><td>10,360</td></tr>
|
||||
<tr><td>the whole library</td><td>26,266</td><td>25,671</td><td>26,879</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="margin-top:1.5rem">
|
||||
<div class="note-item">
|
||||
<h3>Reading and writing drop independently</h3>
|
||||
<p>
|
||||
Import <code class="inline-code">toInstanceSync</code> and you do not pay for the
|
||||
serializer. The validator stays in both, because
|
||||
<code class="inline-code">validate</code> defaults to
|
||||
<code class="inline-code">true</code> — a real reference, not a missed optimisation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="note-item">
|
||||
<h3>It did not come for free</h3>
|
||||
<p>
|
||||
Every rule is a top-level call. rollup proves such a call side-effect-free by reading
|
||||
the factory; esbuild and webpack will not. Without a
|
||||
<code class="inline-code">/*#__PURE__*/</code> on each, one decorator cost
|
||||
<strong>4,909 bytes instead of 1,837</strong>. Nothing failed — the library was just
|
||||
three times heavier in every bundle, and the only way to find out was to measure.
|
||||
</p>
|
||||
</div>
|
||||
<div class="note-item">
|
||||
<h3>rollup alone would have shown nothing</h3>
|
||||
<p>
|
||||
It was already producing 1,823 bytes and hid the problem. That is the argument for
|
||||
measuring through more than one bundler, and it is why
|
||||
<a href="https://github.com/avalon-vanguard/cereale/blob/main/src/treeshake.test.ts">a test</a>
|
||||
now pins the property rather than the prose.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -791,7 +944,7 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
|
||||
<div class="ref-groups" id="ref-groups"></div>
|
||||
<noscript>
|
||||
<p class="ref-empty">The reference is rendered with JavaScript. The full list is in the
|
||||
<a href="https://github.com/Avalon-Vanguard/cereale#api-reference">README</a>.</p>
|
||||
<a href="https://github.com/avalon-vanguard/cereale#api-reference">README</a>.</p>
|
||||
</noscript>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div class="note-item">
|
||||
<h3>It is not on npm yet</h3>
|
||||
<p>0.3.0 lives in the repository. <code class="inline-code">npm install cereale</code> does not
|
||||
resolve to this library — build it from source until it is published.</p>
|
||||
<h3>It is still 0.x</h3>
|
||||
<p><span class="js-version">0.4.1</span> is published, and under semver a 0.x minor bump
|
||||
is allowed to break you. Pin the version until 1.0; the
|
||||
<a href="https://github.com/avalon-vanguard/cereale/blob/main/CHANGELOG.md">changelog</a>
|
||||
says what moved and why.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -853,9 +1008,9 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
|
||||
<div class="wrap foot-grid">
|
||||
<p style="margin:0">cereale — MIT licensed. Built by Avalon Vanguard.</p>
|
||||
<p style="margin:0">
|
||||
<a href="https://github.com/Avalon-Vanguard/cereale">Source</a> ·
|
||||
<a href="https://github.com/Avalon-Vanguard/cereale/blob/main/CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://github.com/Avalon-Vanguard/cereale/issues">Issues</a>
|
||||
<a href="https://github.com/avalon-vanguard/cereale">Source</a> ·
|
||||
<a href="https://github.com/avalon-vanguard/cereale/blob/main/CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://github.com/avalon-vanguard/cereale/issues">Issues</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// Generated by scripts/build-docs.mjs — do not edit.
|
||||
window.CEREALE_META = {
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"node": ">=20.0.0"
|
||||
};
|
||||
|
||||
@@ -39,6 +39,16 @@
|
||||
if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version;
|
||||
var nodeEl = document.getElementById('node-req');
|
||||
if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', '');
|
||||
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 */
|
||||
var TOKENS = [
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import eslint from '@eslint/js';
|
||||
import avalonBase from '@avalon-vanguard/config/eslint';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import globals from 'globals';
|
||||
|
||||
@@ -6,8 +6,8 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'docs/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
// @eslint/js recommended + typescript-eslint recommended, shared across avalon-vanguard
|
||||
avalonBase,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
|
||||
Generated
+35
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "cereale",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cereale",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@avalon-vanguard/config": "^1.0.0",
|
||||
"@babel/standalone": "^8.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@swc/core": "^1.15.47",
|
||||
@@ -26,6 +27,38 @@
|
||||
"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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
|
||||
+16
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"main": "./dist/cjs/index.js",
|
||||
@@ -16,24 +16,33 @@
|
||||
"types": "./dist/esm/vite.d.ts",
|
||||
"import": "./dist/esm/vite.js",
|
||||
"require": "./dist/cjs/vite.js"
|
||||
},
|
||||
"./min": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/cereale.min.js"
|
||||
}
|
||||
},
|
||||
"sideEffects": [
|
||||
"./dist/esm/index.js",
|
||||
"./dist/esm/metadata.js",
|
||||
"./dist/cjs/metadata.js"
|
||||
"./dist/cereale.min.js",
|
||||
"./src/index.ts",
|
||||
"./src/metadata.ts"
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"FRAMEWORKS.md",
|
||||
"CHANGELOG.md",
|
||||
"!src/**/*.test.ts",
|
||||
"!src/example.ts"
|
||||
],
|
||||
"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",
|
||||
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"typecheck": "npm run type-check",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
@@ -41,8 +50,10 @@
|
||||
"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",
|
||||
"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: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": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -70,6 +81,7 @@
|
||||
},
|
||||
"homepage": "https://avalon-vanguard.github.io/cereale/",
|
||||
"devDependencies": {
|
||||
"@avalon-vanguard/config": "^1.0.0",
|
||||
"@babel/standalone": "^8.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@swc/core": "^1.15.47",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["local>avalon-vanguard/renovate-config"]
|
||||
}
|
||||
@@ -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)`);
|
||||
@@ -49,6 +49,27 @@ await writeFile(
|
||||
`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
|
||||
// snippet the page calls a compile error ever compiles, this fails the build.
|
||||
const { byCase, problems } = await collectDiagnostics(root);
|
||||
|
||||
@@ -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.');
|
||||
@@ -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
@@ -262,17 +262,17 @@ export function ValidateNested(options?: ValidationOptions): FieldDecorator<obje
|
||||
// Type rules
|
||||
// ============================================================================
|
||||
|
||||
export const IsString: Rule<string> = 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 IsInt: Rule<number> = 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 IsBigInt: Rule<bigint> = 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 IsObject: Rule<object> = rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
|
||||
export const IsString: Rule<string> = /*#__PURE__*/ rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
|
||||
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> = /*#__PURE__*/ rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
|
||||
export const IsBoolean: Rule<boolean> = /*#__PURE__*/ rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
|
||||
export const IsBigInt: Rule<bigint> = /*#__PURE__*/ rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
|
||||
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> = /*#__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 IsNotEmpty: Rule<unknown> = rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
||||
export const IsEmpty: Rule<unknown> = rule('isEmpty', v => {
|
||||
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> = /*#__PURE__*/ rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
||||
export const IsEmpty: Rule<unknown> = /*#__PURE__*/ rule('isEmpty', v => {
|
||||
if (v === null || v === undefined || v === '') return true;
|
||||
if (Array.isArray(v)) return 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);
|
||||
}
|
||||
|
||||
export const Positive: Rule<number> = 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 Positive: Rule<number> = /*#__PURE__*/ rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
|
||||
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?: 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. */
|
||||
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;
|
||||
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
|
||||
}, 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 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 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> = /*#__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
|
||||
@@ -358,22 +358,22 @@ export function Length(min: number, max?: number, options?: ValidationOptions):
|
||||
}), options);
|
||||
}
|
||||
|
||||
export const Email: Rule<string> = 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 IsAlphanumeric: Rule<string> = 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 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 Email: Rule<string> = /*#__PURE__*/ pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
|
||||
export const IsAlpha: Rule<string> = /*#__PURE__*/ pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
|
||||
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> = /*#__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> = /*#__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 IsUppercase: Rule<string> = 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 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 IsJSON: Rule<string> = rule('isJson', v => {
|
||||
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> = /*#__PURE__*/ rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
|
||||
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> = /*#__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> = /*#__PURE__*/ rule('isJson', v => {
|
||||
if (typeof v !== 'string') return false;
|
||||
try { JSON.parse(v); return true; } catch { return false; }
|
||||
}, 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; }
|
||||
}, p => `${p} must be a valid URL`);
|
||||
|
||||
@@ -449,10 +449,10 @@ function affix(name: string, test: (value: string, seed: string) => boolean, des
|
||||
return decorator;
|
||||
}
|
||||
|
||||
export const Contains = 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 StartsWith = 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 Contains = /*#__PURE__*/ affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must 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 = /*#__PURE__*/ affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start 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
|
||||
|
||||
+7
-2
@@ -15,8 +15,13 @@ import type { ClassConstructor } from './interfaces.js';
|
||||
*/
|
||||
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`
|
||||
// directly. package.json marks this module as having side effects so it survives bundling.
|
||||
// Also installed globally: tsc's decorator emit reads `Symbol.metadata` directly rather than
|
||||
// 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;
|
||||
|
||||
export interface ValidationArguments {
|
||||
|
||||
@@ -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
@@ -42,7 +42,7 @@ export class JsonMappingError extends Error {
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
@@ -96,7 +96,7 @@ interface OutboundProperty {
|
||||
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).
|
||||
@@ -167,7 +167,7 @@ interface InboundNames {
|
||||
|
||||
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
||||
// 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.
|
||||
@@ -614,7 +614,7 @@ interface CachedPlan {
|
||||
// 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
|
||||
// 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.
|
||||
@@ -663,7 +663,7 @@ function validationPlan(model: ClassModel): PropertyPlan[] {
|
||||
|
||||
// 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.
|
||||
const converterCache = new WeakMap<object, any>();
|
||||
const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
|
||||
|
||||
function converterFor(clazz: any): any {
|
||||
let instance = converterCache.get(clazz);
|
||||
|
||||
+4
-10
@@ -1,5 +1,9 @@
|
||||
{
|
||||
// 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": {
|
||||
// File Layout
|
||||
"rootDir": "src",
|
||||
@@ -11,27 +15,17 @@
|
||||
"lib": ["ESNext", "ESNext.Decorators"],
|
||||
"types": ["node"],
|
||||
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Style Options
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
// NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what
|
||||
// gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of
|
||||
|
||||
Reference in New Issue
Block a user