Compare commits
38
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 | ||
|
|
83375f1ae4 | ||
|
|
fd42675d3d | ||
|
|
e3873dcdd3 | ||
|
|
e626a1003a | ||
|
|
364f44f4bf | ||
|
|
2dcc8d74d0 | ||
|
|
8bd770e343 | ||
|
|
b5b5576439 | ||
|
|
9796d599dc | ||
|
|
7aaef4d388 | ||
|
|
551d53912f |
@@ -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
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Verify published entry points load
|
- name: Verify published entry points load
|
||||||
run: |
|
run: node scripts/check-entry-points.mjs
|
||||||
node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');"
|
|
||||||
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
|
|
||||||
node --input-type=module -e "import { standardDecorators } from './dist/esm/vite.js'; if (standardDecorators().enforce !== 'pre') throw new Error('ESM cereale/vite broken');"
|
|
||||||
node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');"
|
|
||||||
- name: Run Demo
|
- name: Run Demo
|
||||||
run: npm run demo
|
run: npm run demo
|
||||||
- name: Published types stand alone
|
- name: Published types stand alone
|
||||||
@@ -46,7 +42,5 @@ jobs:
|
|||||||
- name: Landing page loads nothing from the network
|
- name: Landing page loads nothing from the network
|
||||||
run: npm run check:docs
|
run: npm run check:docs
|
||||||
- name: Landing page bundle is in sync with src/
|
- name: Landing page bundle is in sync with src/
|
||||||
run: |
|
# Also fails on NEW untracked files under docs/ — a plain `git diff` does not.
|
||||||
npm run build:docs
|
run: npm run check:docs-sync
|
||||||
git diff --exit-code -- docs/ \
|
|
||||||
|| (echo "docs/ is stale — run 'npm run build:docs' and commit the result" && exit 1)
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
name: Publish to npm
|
||||||
|
|
||||||
|
# Deliberately manual. Pushing a tag does NOT publish — a tag is a decision to cut a release,
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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:
|
||||||
|
dry_run:
|
||||||
|
description: 'Resolve and pack everything, but do not publish'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # the OIDC identity npm exchanges, and provenance
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: ${{ inputs.dry_run && 'Dry run' || 'Publish' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Tags are not fetched at the default depth, and the version gate below
|
||||||
|
# resolves one — without this it fails on every release, tag or no tag.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
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
|
||||||
|
|
||||||
|
# The tag and the manifest disagreeing is the classic way to publish 0.3.0 as 0.2.0.
|
||||||
|
- name: Tag and package.json version must agree
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "package.json version: $VERSION"
|
||||||
|
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
|
||||||
|
echo "tag v$VERSION exists"
|
||||||
|
else
|
||||||
|
echo "::error::No tag v$VERSION. Tag the release commit before publishing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "v$VERSION^{commit}")" ]; then
|
||||||
|
echo "::error::v$VERSION does not point at the commit being published."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Refuse to republish a version already on the registry
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
NAME=$(node -p "require('./package.json').name")
|
||||||
|
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
|
||||||
|
echo "::error::$NAME@$VERSION is already published. Bump the version."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$NAME@$VERSION is not on the registry yet."
|
||||||
|
|
||||||
|
# The same gate that guards every push: type-check, lint, 259 tests, build, and the
|
||||||
|
# checks that the published types stand alone and the landing page has no CDN deps.
|
||||||
|
- name: Verify
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
{
|
||||||
|
echo "### cereale@$VERSION"
|
||||||
|
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||||
|
echo "Dry run — nothing was published."
|
||||||
|
else
|
||||||
|
echo "Published to https://www.npmjs.com/package/cereale/v/$VERSION"
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@avalon-vanguard:registry=https://git.avalonvanguard.com/api/packages/avalon-vanguard/npm/
|
||||||
+146
@@ -5,6 +5,133 @@ All notable changes to this project are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-08-20
|
||||||
|
|
||||||
|
No code changes. Every emitted file is byte-identical to 0.4.0 except the banner line of
|
||||||
|
`dist/cereale.min.js`, which carries the version string. This exists because the README
|
||||||
|
inside the 0.4.0 tarball is the one npmjs.com renders, and it said the package was not on
|
||||||
|
npm: *"`npm install cereale` does not resolve to this library — the name is unclaimed on
|
||||||
|
the registry."* True when it was written, nonsense on the package page of the thing it
|
||||||
|
describes.
|
||||||
|
|
||||||
|
0.4.0 was the first publish, so nothing could have carried the corrected text: the docs
|
||||||
|
could only be fixed after the registry proved the claim wrong. The install instructions,
|
||||||
|
the landing page panel, and the "what cereale is not" entry now say `npm install cereale`,
|
||||||
|
and the README carries an npm version badge.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-05
|
||||||
|
|
||||||
|
### `cereale/min` — one file, no bundler
|
||||||
|
|
||||||
|
The whole library flattened into a single minified ES module: **33.9 KB, 9.6 KB gzipped**, for
|
||||||
|
import maps, `<script type="module">`, Deno and Workers. It is built from `dist/esm/index.js`,
|
||||||
|
so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only
|
||||||
|
flattens and minifies.
|
||||||
|
|
||||||
|
It is an addition, not a replacement. The per-module build stays the default `import`: it keeps
|
||||||
|
readable stack traces for anyone not loading source maps, and it is what a bundler should be
|
||||||
|
given.
|
||||||
|
|
||||||
|
The flat file is minified for syntax and identifiers but **not** whitespace. Full minification
|
||||||
|
strips comments — including the `/*#__PURE__*/` annotations below — which silently made
|
||||||
|
`cereale/min` un-tree-shakable: one decorator came out at 5,066 bytes against 1,837 from the
|
||||||
|
per-module entry, with all 26 unrelated rule messages back in the output. Keeping the
|
||||||
|
annotations costs about a kilobyte gzipped and is asserted by the build.
|
||||||
|
|
||||||
|
### Tree-shaking
|
||||||
|
|
||||||
|
Importing one decorator pulled in the message and validator of all 68. **4,909 bytes instead of
|
||||||
|
1,837** through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing
|
||||||
|
warned; the library was simply about three times heavier than it needed to be in every
|
||||||
|
consumer's bundle.
|
||||||
|
|
||||||
|
The cause is that every rule is a top-level call — `export const IsString = rule(…)`. rollup
|
||||||
|
proves such a call side-effect-free by reading the factory, which is why rollup was already
|
||||||
|
producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and
|
||||||
|
webpack will not do that analysis, and keep the call. Thirty declarations now carry
|
||||||
|
`/*#__PURE__*/`. On the single-decorator import that exposed the problem the three bundlers now
|
||||||
|
land within 19 bytes of each other; on larger imports they still differ by up to a few hundred,
|
||||||
|
which is ordinary bundler variation rather than anything left unshaken.
|
||||||
|
|
||||||
|
Measured, minified, across esbuild / rollup / webpack:
|
||||||
|
|
||||||
|
| What you import | esbuild | rollup | webpack |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 394 | 367 | 394 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,738 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
|
||||||
|
| a typical DTO | 10,395 | 10,402 | 10,360 |
|
||||||
|
| everything | 26,266 | 25,671 | 26,879 |
|
||||||
|
|
||||||
|
The serializer and deserializer drop independently. The validator is kept by both mapping
|
||||||
|
entry points because `validate` defaults to `true`, which is a real reference rather than a
|
||||||
|
missed optimisation.
|
||||||
|
|
||||||
|
`src/treeshake.test.ts` pins it. The assertions are mostly about content rather than bytes — it
|
||||||
|
names the rules that must not appear — and one case asserts that everything IS present when
|
||||||
|
everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip
|
||||||
|
the annotations and it fails with `"must be a latitude" should have been shaken out`.
|
||||||
|
|
||||||
|
The same pass shook out something that was supposed to stay. Cereale installs `Symbol.metadata`
|
||||||
|
when the runtime lacks it, and `sideEffects` named the module holding that install — but not the
|
||||||
|
barrel that re-exports it. A side-effect-free barrel is droppable as a whole, so all three
|
||||||
|
bundlers pruned the `export * from './metadata.js'` edge before metadata.js's own marking was
|
||||||
|
ever consulted: `import { configure } from 'cereale'` came out at 145 bytes through esbuild, 143
|
||||||
|
through webpack and 144 through rollup, with `Symbol.metadata` in none of them. That matters
|
||||||
|
because `tsc`'s decorator emit reads the well-known symbol directly — `typeof Symbol ===
|
||||||
|
"function" && Symbol.metadata ? Object.create(null) : void 0` — so without the install a
|
||||||
|
decorated class gets `metadata: undefined`, which is to say no rules at all.
|
||||||
|
|
||||||
|
`index.js` and `index.ts` are now listed too. It costs about 100 bytes, and only on imports that
|
||||||
|
reach nothing else; every row of the table above except the first was byte-identical before and
|
||||||
|
after, across all three bundlers. Two more cases in `treeshake.test.ts` pin it, one on the source
|
||||||
|
and one on `dist/esm`, because those are separate paths in the manifest and a typo in either is
|
||||||
|
invisible from the other side.
|
||||||
|
|
||||||
|
### Frameworks
|
||||||
|
|
||||||
|
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
|
||||||
|
with the versions and date it was verified against.
|
||||||
|
|
||||||
|
The finding worth stating first: **Angular works**. The CLI scaffolds
|
||||||
|
`"experimentalDecorators": true`, but Angular does not need it — `ngtsc` erases `@Component`
|
||||||
|
and `@Injectable` into static properties rather than relying on TypeScript's decorator emit.
|
||||||
|
Flip the flag and both systems work in one program. Verified with `ngc` on Angular 21.2 with
|
||||||
|
`strictTemplates`: templates still type-check, and a wrong cereale rule is still a compile
|
||||||
|
error inside the Angular build.
|
||||||
|
|
||||||
|
**Next.js cannot work inline**, and the reason is structural rather than a missing option. It
|
||||||
|
derives *both* the SWC parser's decorator support and the transform mode from the single
|
||||||
|
`experimentalDecorators` flag, so the flag on gives legacy emit that cereale refuses, and the
|
||||||
|
flag off makes `@` a syntax error. There is no third setting.
|
||||||
|
|
||||||
|
**NestJS cannot work inline** either: its dependency injection genuinely needs the
|
||||||
|
`design:type` metadata only `emitDecoratorMetadata` produces.
|
||||||
|
|
||||||
|
Both have the same answer, and it is better than it sounds: put the cereale classes in a
|
||||||
|
package compiled by `tsc` and import the built output. The decorators run at class-definition
|
||||||
|
time inside that package, so the app only ever sees plain JavaScript and its own decorator
|
||||||
|
setting stops mattering. Verified inside a program with **both** legacy flags on, running
|
||||||
|
alongside `@Injectable()` — mapping and validation work normally, and the compile-time
|
||||||
|
guarantee still holds where the rules are written.
|
||||||
|
|
||||||
|
Also verified: **Bun** 1.3 needs no configuration at all, and a real Vite 8 build with the
|
||||||
|
`cereale/vite` plugin produces working output where the same build without it silently leaves
|
||||||
|
decorator syntax in the bundle.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
`FRAMEWORKS.md` ships with the package. `sideEffects` now lists the flat bundle, which inlines
|
||||||
|
the `Symbol.metadata` install.
|
||||||
|
|
||||||
|
### Why 0.4.0 and not 0.3.1
|
||||||
|
|
||||||
|
`cereale/min` is a new public entry point, which is a minor bump under 0.x. It also keeps the
|
||||||
|
existing `v0.3.0` tag meaningful instead of force-moving it onto a commit it was never cut
|
||||||
|
from.
|
||||||
|
|
||||||
## [0.3.0] - 2026-08-05
|
## [0.3.0] - 2026-08-05
|
||||||
|
|
||||||
Every change here comes from the same question: where does cereale currently fail *quietly*?
|
Every change here comes from the same question: where does cereale currently fail *quietly*?
|
||||||
@@ -166,6 +293,25 @@ Serialization is a few percent slower for the representability check. Primitives
|
|||||||
inline, and arrays and dates skip it, so it costs one `Symbol.toStringTag` read per object.
|
inline, and arrays and dates skip it, so it costs one `Symbol.toStringTag` read per object.
|
||||||
Validation is unchanged.
|
Validation is unchanged.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
`files` was `["dist"]`, but `dist` carries 256 KB of `.js.map` and `.d.ts.map` files whose
|
||||||
|
`sources` point at `../../src/*.ts` — which was not published. Every shipped sourcemap
|
||||||
|
resolved to nothing: 44% of the tarball, dead. The source is only 116 KB and its comments are
|
||||||
|
the most detailed explanation of why the engine does what it does, so it is now published
|
||||||
|
(tests and the demo excluded) and the maps resolve. Stepping into cereale in a debugger, and
|
||||||
|
"go to definition" from a decorator, both land in the real TypeScript.
|
||||||
|
|
||||||
|
`CHANGELOG.md` ships too. The `repository`, `homepage` and `bugs` URLs said `Avalon-Vanguard`
|
||||||
|
and only worked through GitHub's redirect; they now use the org's actual lowercase name.
|
||||||
|
`publishConfig.access` is set explicitly so a future move to a scoped name cannot quietly
|
||||||
|
attempt a private publish.
|
||||||
|
|
||||||
|
A `Publish to npm` workflow is in place but deliberately manual — pushing a tag does not
|
||||||
|
publish. It checks that the tag exists and points at the commit being published, refuses a
|
||||||
|
version already on the registry, runs the full `verify` gate, prints the file list, and
|
||||||
|
defaults to a dry run. Publishing needs an `NPM_TOKEN` secret and someone choosing to run it.
|
||||||
|
|
||||||
## [0.2.0] - 2026-08-04
|
## [0.2.0] - 2026-08-04
|
||||||
|
|
||||||
> The project stays on 0.x while nothing has been published: under semver that signals the
|
> The project stays on 0.x while nothing has been published: under semver that signals the
|
||||||
|
|||||||
+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
|
# 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.**
|
**Validated domain objects, not validated data.**
|
||||||
|
|
||||||
Cereale maps JSON onto your own classes and gives you back real instances — with your methods,
|
Cereale maps JSON onto your own classes and gives you back real instances — with your methods,
|
||||||
@@ -25,9 +32,12 @@ const user = fromJsonSync(User, body); // a real User
|
|||||||
user.greet(); // your methods are still there
|
user.greet(); // your methods are still there
|
||||||
```
|
```
|
||||||
|
|
||||||
`docs/index.html` is a self-contained page with an interactive playground that runs this
|
**[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an
|
||||||
library in the browser. Build its assets with `npm run build:docs` and open the file — it
|
interactive playground that runs this library in your browser, the full decorator reference,
|
||||||
loads nothing from the network.
|
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
|
## Where it fits
|
||||||
|
|
||||||
@@ -74,6 +84,9 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d
|
|||||||
npm install cereale
|
npm install cereale
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Published with [provenance](https://www.npmjs.com/package/cereale), so the registry carries a
|
||||||
|
verified attestation linking the tarball to the commit it was built from.
|
||||||
|
|
||||||
Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag:
|
Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -102,11 +115,25 @@ inside a native binary with no standalone transform API.
|
|||||||
|
|
||||||
| Transformer | Status | Notes |
|
| Transformer | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tsc` | ✅ | With `experimentalDecorators: false` and `target: ES2022`+ |
|
| `tsc` 5.2+ | ✅ | With `experimentalDecorators: false` and `target: ES2022`+ |
|
||||||
| esbuild | ✅ | `experimentalDecorators: false` via `tsconfigRaw`, **plus** esbuild's own top-level `target: es2022`. Its default `esnext` target leaves decorator syntax in the output |
|
| esbuild | ✅ | `experimentalDecorators: false` via `tsconfigRaw`, **plus** esbuild's own top-level `target: es2022`. Its default `esnext` target leaves decorator syntax in the output |
|
||||||
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
||||||
| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below |
|
| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below |
|
||||||
|
|
||||||
|
### Frameworks
|
||||||
|
|
||||||
|
**[FRAMEWORKS.md](FRAMEWORKS.md)** has a setup recipe for each, every one of them run before it
|
||||||
|
was written. The short version:
|
||||||
|
|
||||||
|
| | | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Angular** 21 | ✅ | Flip the scaffolded `experimentalDecorators` to `false`. Angular does not need it — `ngtsc` erases its own decorators itself |
|
||||||
|
| **React, Vue, Svelte, Solid, Astro, Nuxt** | ✅ | Any Vite 8 app: add the `cereale/vite` plugin below |
|
||||||
|
| **Bun** 1.3 | ✅ | No configuration |
|
||||||
|
| **Node** + `tsc` | ✅ | Just the flag |
|
||||||
|
| **Next.js** 16 | ⚠️ | Not inline: it derives both the SWC parser *and* the transform from one flag, so decorators either compile legacy or fail to parse. Keep your models in a package compiled by `tsc` |
|
||||||
|
| **NestJS** 11.1 | ⚠️ | Not inline: its DI needs `emitDecoratorMetadata`. Same precompiled-package route — verified working alongside `@Injectable()` in a program with both legacy flags on |
|
||||||
|
|
||||||
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
|
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
|
||||||
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
|
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
|
||||||
reports success while emitting a bundle that throws the moment it is imported. Cereale ships
|
reports success while emitting a bundle that throws the moment it is imported. Cereale ships
|
||||||
@@ -479,6 +506,50 @@ costs a few percent on `toPlain`, which is the price of never emitting `{}` wher
|
|||||||
used to be; primitives are handled inline and the check is skipped for arrays and dates, so
|
used to be; primitives are handled inline and the check is skipped for arrays and dates, so
|
||||||
it is one `Symbol.toStringTag` read per object.
|
it is one `Symbol.toStringTag` read per object.
|
||||||
|
|
||||||
|
## Bundle size
|
||||||
|
|
||||||
|
Cereale tree-shakes. Every rule is declared so that a bundler can drop the ones you did not
|
||||||
|
import, which matters for a library with 68 decorators — you pay for what you name and nothing
|
||||||
|
else. Minified bytes, measured through esbuild, rollup and webpack. The table was measured by hand;
|
||||||
|
what is [pinned by a test](src/treeshake.test.ts) is the property behind it — that a given
|
||||||
|
import drops the parts of the library it does not reach — checked through esbuild on both the
|
||||||
|
source and the published bundle:
|
||||||
|
|
||||||
|
| What you import | esbuild | rollup | webpack |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 394 | 367 | 394 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,738 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
|
||||||
|
| a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,360 |
|
||||||
|
| the whole library | 26,266 | 25,671 | 26,879 |
|
||||||
|
|
||||||
|
The serializer and the deserializer drop independently: read JSON and you do not pay for
|
||||||
|
writing it. The validator is kept by both, because `validate` defaults to `true` and the entry
|
||||||
|
points reference it whatever a given call site passes.
|
||||||
|
|
||||||
|
The floor is about a hundred bytes: cereale installs `Symbol.metadata` if the runtime lacks it,
|
||||||
|
and that install has to survive tree-shaking or a `tsc`-compiled consumer decorates its classes
|
||||||
|
with no metadata at all. It is why `sideEffects` names `index.js` as well as `metadata.js` —
|
||||||
|
marking only the latter leaves the barrel itself droppable, so the edge to it is pruned before
|
||||||
|
its own marking is ever read. That cost lands only on the first row; every import that touches a
|
||||||
|
model was already carrying it.
|
||||||
|
|
||||||
|
This did not come for free. Thirty of the rules are declared as top-level calls —
|
||||||
|
`export const IsString = rule(…)` — and rollup can prove such a call side-effect-free by reading
|
||||||
|
the factory, but esbuild and webpack will not. Without a `/*#__PURE__*/` annotation on each of
|
||||||
|
them, importing one decorator pulled in the message and validator of all 68: **4,909 bytes
|
||||||
|
instead of 1,837**. Nothing failed; the library was simply three times heavier in every
|
||||||
|
consumer's bundle, and the only way to find out was to measure. Note what that means for
|
||||||
|
measuring: rollup alone would have shown nothing wrong.
|
||||||
|
|
||||||
|
`cereale/min` tree-shakes too, which took a second fix — esbuild's `minify` strips comments,
|
||||||
|
annotations included, so the flat bundle was silently reproducing the same bug (5,066 bytes for
|
||||||
|
one decorator). It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw,
|
||||||
|
9.6 KB gzipped, about a kilobyte over the wire more than full minification would give. Still,
|
||||||
|
if you are using a bundler, import from `cereale` rather than `cereale/min`.
|
||||||
|
|
||||||
## Notes and Limitations
|
## Notes and Limitations
|
||||||
|
|
||||||
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+302
-135
@@ -6,39 +6,71 @@
|
|||||||
<title>cereale — validated domain objects, not validated data</title>
|
<title>cereale — validated domain objects, not validated data</title>
|
||||||
<meta name="description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks the validation rules against the fields they are attached to, at compile time.">
|
<meta name="description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks the validation rules against the fields they are attached to, at compile time.">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<link rel="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="canonical" href="https://avalon-vanguard.github.io/cereale/">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 18'><rect x='4' width='2' height='18' fill='%23a0784a' opacity='.35'/><rect y='2' width='10' height='2' fill='%23a0784a'/><rect y='6' width='10' height='2' fill='%23a0784a'/><rect y='10' width='10' height='2' fill='%23a0784a'/><rect y='14' width='10' height='2' fill='%23a0784a'/></svg>">
|
||||||
|
|
||||||
|
<!-- Link previews. No og:image: a preview card with a broken image is worse than one
|
||||||
|
without, and there is no artwork to point at yet. -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://avalon-vanguard.github.io/cereale/">
|
||||||
|
<meta property="og:site_name" content="cereale">
|
||||||
|
<meta property="og:title" content="cereale — validated domain objects, not validated data">
|
||||||
|
<meta property="og:description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks every rule against the field it is attached to, at compile time.">
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="cereale — validated domain objects, not validated data">
|
||||||
|
<meta name="twitter:description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Rules are checked against fields at compile time.">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ---------------------------------------------------------------- tokens */
|
/* ---------------------------------------------------------------- tokens */
|
||||||
|
/* Chaff: one paper, one ink, one repeated 2px mark. The accent is rye-brown and
|
||||||
|
deliberately NOT wheat-gold — gold is both the cliche and a collision with --warn,
|
||||||
|
which already owns amber. Two amber families would make "our brand" and "needs
|
||||||
|
your attention" the same colour.
|
||||||
|
|
||||||
|
Every token below that differs between themes must be written in THREE places: this
|
||||||
|
block (the light palette), the prefers-color-scheme block, and [data-theme="dark"].
|
||||||
|
The dark media block is guarded with :not([data-theme="light"]), so an explicit light
|
||||||
|
toggle falls straight through to these values — there is no light copy to keep in sync. */
|
||||||
:root {
|
:root {
|
||||||
--bg: #fbfbfd;
|
--bg: #faf7f0;
|
||||||
--bg-raised: #ffffff;
|
--bg-raised: #fffdf7;
|
||||||
--bg-sunken: #f3f3f7;
|
--bg-sunken: #f2ede1;
|
||||||
--text: #16161d;
|
--text: #1a1712;
|
||||||
--text-muted: #55555f;
|
--text-muted: #5c5346;
|
||||||
--text-faint: #6b6b78;
|
--text-faint: #6e6353;
|
||||||
--border: #e3e3ea;
|
--border: #e6dfd1;
|
||||||
--border-strong: #cfcfd9;
|
--border-strong: #cfc5b2;
|
||||||
--accent: #4f46e5;
|
/* Icon-only controls need 3:1 against the page (WCAG 1.4.11); --border-strong is
|
||||||
--accent-text: #4338ca;
|
decorative and sits well below it. .icon-btn and .btn-secondary use this instead. */
|
||||||
--accent-soft: #eef2ff;
|
--border-ui: #8e8269;
|
||||||
|
--accent: #8a6238;
|
||||||
|
--accent-text: #7a5530;
|
||||||
|
--accent-soft: #f1e9dc;
|
||||||
/* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent
|
/* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent
|
||||||
for legibility against the page, which then leaves white text on it below AA. */
|
for legibility against the page, which then leaves white text on it below AA. */
|
||||||
--accent-solid: #4f46e5;
|
--accent-solid: #6b4a28;
|
||||||
--on-accent: #ffffff;
|
--on-accent: #fffdf7;
|
||||||
--bad: #d4183d;
|
--bad: #a82820;
|
||||||
--bad-soft: #fff1f3;
|
--bad-soft: #faebe7;
|
||||||
--ok: #08795a;
|
--ok: #256b3d;
|
||||||
--ok-soft: #eefaf5;
|
--ok-soft: #e8f2e9;
|
||||||
--warn: #9a5b00;
|
--warn: #8a5a05;
|
||||||
--warn-soft: #fff7ea;
|
--warn-soft: #fbf1dc;
|
||||||
|
|
||||||
/* Code surfaces stay dark in both themes: one syntax palette, always legible. */
|
/* Code surfaces stay dark in both themes: one syntax palette, always legible.
|
||||||
--code-bg: #16161f;
|
Warmed off the blue-grey axis so the slab does not read cold against oat paper. */
|
||||||
--code-bg-raised: #1e1e29;
|
--code-bg: #14120c;
|
||||||
--code-border: #2b2b3a;
|
--code-bg-raised: #1c190f;
|
||||||
--code-text: #d6deeb;
|
--code-border: #2e2818;
|
||||||
--code-faint: #8a93b8;
|
--code-text: #e2dccb;
|
||||||
|
--code-faint: #9a9280;
|
||||||
|
/* The accent is unreadable on the dark slab in light mode, so focus rings drawn on a
|
||||||
|
code surface get their own token. Without it #editor:focus measures 2.86:1 — a live
|
||||||
|
1.4.11 failure on the shipped page. This measures 6.96:1. */
|
||||||
|
--accent-on-code: #c9962f;
|
||||||
|
/* The error line is the page's one visual device; these were three loose literals. */
|
||||||
|
--err-line: #ff6a7e;
|
||||||
|
--err-text: #ffb3c0;
|
||||||
--t-comment: #8a93b8;
|
--t-comment: #8a93b8;
|
||||||
--t-string: #b8e08a;
|
--t-string: #b8e08a;
|
||||||
--t-keyword: #c792ea;
|
--t-keyword: #c792ea;
|
||||||
@@ -46,64 +78,63 @@
|
|||||||
--t-type: #ffcb6b;
|
--t-type: #ffcb6b;
|
||||||
--t-number: #f78c6c;
|
--t-number: #f78c6c;
|
||||||
|
|
||||||
|
/* The whole ornament: a 2px mark every 9px. var() resolves against the winning
|
||||||
|
cascaded value, so --accent changing with the theme retints these automatically —
|
||||||
|
they belong on this block only, never in the three below. */
|
||||||
|
--grain-mark: 2px;
|
||||||
|
--grain-pitch: 9px;
|
||||||
|
--rule-x: repeating-linear-gradient(90deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
||||||
|
--rule-y: repeating-linear-gradient(180deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
||||||
|
--rule-code: repeating-linear-gradient(90deg, var(--code-faint) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--radius-lg: 16px;
|
--radius-lg: 16px;
|
||||||
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18);
|
--shadow: 0 1px 2px rgba(40, 30, 14, .05), 0 8px 24px -12px rgba(40, 30, 14, .20);
|
||||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
--measure: 68ch;
|
--measure: 64ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #0e0e14;
|
--bg: #12100b;
|
||||||
--bg-raised: #16161f;
|
--bg-raised: #1a1710;
|
||||||
--bg-sunken: #12121a;
|
--bg-sunken: #16130d;
|
||||||
--text: #e8e8f0;
|
--text: #ede7da;
|
||||||
--text-muted: #a3a3b4;
|
--text-muted: #aba292;
|
||||||
--text-faint: #9494ab;
|
--text-faint: #9b9280;
|
||||||
--border: #262633;
|
--border: #29241a;
|
||||||
--border-strong: #363648;
|
--border-strong: #3b3427;
|
||||||
--accent: #8b85ff;
|
--border-ui: #736a59;
|
||||||
--accent-text: #a5a0ff;
|
--accent: #c4a97c;
|
||||||
--accent-soft: #1c1b35;
|
--accent-text: #d8be94;
|
||||||
--accent-solid: #8b85ff;
|
--accent-soft: #2a2115;
|
||||||
--on-accent: #10101a;
|
--accent-solid: #d8be94;
|
||||||
--bad: #ff8095;
|
--on-accent: #17120a;
|
||||||
--bad-soft: #2a1620;
|
--bad: #f2867e;
|
||||||
--ok: #5cd0a8;
|
--bad-soft: #2b1512;
|
||||||
--ok-soft: #10241f;
|
--ok: #6fc98c;
|
||||||
--warn: #e5a54b;
|
--ok-soft: #12241a;
|
||||||
--warn-soft: #251d10;
|
--warn: #e9b45a;
|
||||||
--code-bg: #12121a;
|
--warn-soft: #261d0c;
|
||||||
--code-bg-raised: #191922;
|
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The toggle wins over the media query in both directions. */
|
/* An explicit light choice: the guarded media block above no longer matches, so the
|
||||||
|
bare :root palette wins on its own. Only the UA hint needs stating. */
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--bg: #fbfbfd; --bg-raised: #ffffff; --bg-sunken: #f3f3f7;
|
|
||||||
--text: #16161d; --text-muted: #55555f; --text-faint: #6b6b78;
|
|
||||||
--border: #e3e3ea; --border-strong: #cfcfd9;
|
|
||||||
--accent: #4f46e5; --accent-text: #4338ca; --accent-soft: #eef2ff;
|
|
||||||
--accent-solid: #4f46e5; --on-accent: #ffffff;
|
|
||||||
--bad: #d4183d; --bad-soft: #fff1f3; --ok: #08795a; --ok-soft: #eefaf5;
|
|
||||||
--warn: #9a5b00; --warn-soft: #fff7ea;
|
|
||||||
--code-bg: #16161f; --code-bg-raised: #1e1e29;
|
|
||||||
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18);
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--bg: #0e0e14; --bg-raised: #16161f; --bg-sunken: #12121a;
|
--bg: #12100b; --bg-raised: #1a1710; --bg-sunken: #16130d;
|
||||||
--text: #e8e8f0; --text-muted: #a3a3b4; --text-faint: #9494ab;
|
--text: #ede7da; --text-muted: #aba292; --text-faint: #9b9280;
|
||||||
--border: #262633; --border-strong: #363648;
|
--border: #29241a; --border-strong: #3b3427; --border-ui: #736a59;
|
||||||
--accent: #8b85ff; --accent-text: #a5a0ff; --accent-soft: #1c1b35;
|
--accent: #c4a97c; --accent-text: #d8be94; --accent-soft: #2a2115;
|
||||||
--accent-solid: #8b85ff; --on-accent: #10101a;
|
--accent-solid: #d8be94; --on-accent: #17120a;
|
||||||
--bad: #ff8095; --bad-soft: #2a1620; --ok: #5cd0a8; --ok-soft: #10241f;
|
--bad: #f2867e; --bad-soft: #2b1512; --ok: #6fc98c; --ok-soft: #12241a;
|
||||||
--warn: #e5a54b; --warn-soft: #251d10;
|
--warn: #e9b45a; --warn-soft: #261d0c;
|
||||||
--code-bg: #12121a; --code-bg-raised: #191922;
|
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,17 +151,22 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 680; letter-spacing: -.02em; }
|
/* 680 and 640 are fiction on a non-variable system stack — they round to 700. And -.035em
|
||||||
h1 { font-size: clamp(2.1rem, 1.3rem + 3.4vw, 3.5rem); letter-spacing: -.035em; }
|
is generic-landing-page tracking; it is most of what made this look like every other
|
||||||
h2 { font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.1rem); letter-spacing: -.028em; }
|
dev-tool site. */
|
||||||
h3 { font-size: 1.125rem; }
|
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 700; letter-spacing: -.012em; }
|
||||||
|
h1 { font-size: clamp(2rem, 1.35rem + 2.8vw, 3rem); line-height: 1.14; letter-spacing: -.018em; }
|
||||||
|
h2 { font-size: clamp(1.4rem, 1.1rem + 1.3vw, 1.85rem); letter-spacing: -.012em; }
|
||||||
|
h3 { font-size: 1.0625rem; letter-spacing: -.008em; }
|
||||||
p { margin: 0 0 1rem; }
|
p { margin: 0 0 1rem; }
|
||||||
a { color: var(--accent-text); text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); text-underline-offset: .18em; }
|
/* Links keep body colour and are marked by a rule instead. On a palette this warm,
|
||||||
a:hover { text-decoration-color: currentColor; }
|
--accent-text and --text-muted sit 1.14:1 apart — colour alone would lose them. */
|
||||||
|
a { color: var(--text); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: .16em; }
|
||||||
|
a:hover { color: var(--accent-text); text-decoration-color: currentColor; }
|
||||||
code, kbd, pre { font-family: var(--mono); }
|
code, kbd, pre { font-family: var(--mono); }
|
||||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
|
||||||
@@ -141,15 +177,23 @@ code, kbd, pre { font-family: var(--mono); }
|
|||||||
section { padding-block: clamp(3rem, 6vw, 5.5rem); }
|
section { padding-block: clamp(3rem, 6vw, 5.5rem); }
|
||||||
.lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); }
|
.lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); }
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-size: .75rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
|
font-family: var(--mono);
|
||||||
color: var(--accent-text); margin: 0 0 .6rem;
|
font-size: .6875rem; font-weight: 500; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
color: var(--accent-text); margin: 0 0 .7rem;
|
||||||
}
|
}
|
||||||
.section-head { margin-bottom: 2.25rem; }
|
.section-head { margin-bottom: 2.25rem; }
|
||||||
|
/* The ornament, use 1 of 3: a section begins. The hero is the page beginning, not a
|
||||||
|
section, so it deliberately has no .section-head and no mark. */
|
||||||
|
.section-head::before {
|
||||||
|
content: ""; display: block; width: 4.5rem; height: 3px;
|
||||||
|
margin-bottom: .95rem; background-image: var(--rule-x);
|
||||||
|
}
|
||||||
.skip {
|
.skip {
|
||||||
position: absolute; left: -9999px; top: 0; z-index: 100;
|
position: absolute; left: -9999px; top: 0; z-index: 100;
|
||||||
background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
|
background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
|
||||||
}
|
}
|
||||||
.skip:focus { left: 0; }
|
.skip:focus { left: 0; }
|
||||||
|
.skip:hover { color: var(--on-accent); }
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||||
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
||||||
@@ -163,8 +207,8 @@ header.nav {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; }
|
.nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; }
|
||||||
.brand { display: flex; align-items: baseline; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; }
|
.brand { display: flex; align-items: center; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; }
|
||||||
.brand .grain { font-size: 1rem; }
|
.brand .mark { color: var(--accent); flex: none; }
|
||||||
.badge {
|
.badge {
|
||||||
font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px;
|
font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px;
|
||||||
background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
@@ -175,10 +219,13 @@ header.nav {
|
|||||||
}
|
}
|
||||||
.nav-links a:hover { color: var(--text); background: var(--bg-sunken); }
|
.nav-links a:hover { color: var(--text); background: var(--bg-sunken); }
|
||||||
.nav-links a.ghost { border: 1px solid var(--border-strong); }
|
.nav-links a.ghost { border: 1px solid var(--border-strong); }
|
||||||
@media (max-width: 640px) { .nav-hide { display: none; } }
|
/* The section links need ~810px before they stop pushing the header past the viewport.
|
||||||
|
At 640px they already did not fit: the page overflowed by 63px through the whole
|
||||||
|
641-767px band, which body{overflow-x:hidden} hid rather than fixed. */
|
||||||
|
@media (max-width: 860px) { .nav-hide { display: none; } }
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem;
|
display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem;
|
||||||
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
border: 1px solid var(--border-ui); border-radius: var(--radius);
|
||||||
background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0;
|
background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0;
|
||||||
}
|
}
|
||||||
.icon-btn:hover { color: var(--text); }
|
.icon-btn:hover { color: var(--text); }
|
||||||
@@ -196,15 +243,18 @@ header.nav {
|
|||||||
padding: .75rem 1.15rem; border-radius: var(--radius); text-decoration: none; cursor: pointer; border: 1px solid transparent;
|
padding: .75rem 1.15rem; border-radius: var(--radius); text-decoration: none; cursor: pointer; border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
.btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
|
.btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
|
||||||
.btn-primary:hover { filter: brightness(1.08); }
|
/* Re-assert label colours on hover: the base a:hover (0,1,1) outranks these classes
|
||||||
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-strong); }
|
(0,1,0), and in dark theme --accent-text equals --accent-solid — a hovered label
|
||||||
.btn-secondary:hover { border-color: var(--text-faint); }
|
painted in its own background. */
|
||||||
|
.btn-primary:hover { filter: brightness(1.08); color: var(--on-accent); }
|
||||||
|
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); }
|
||||||
|
.btn-secondary:hover { border-color: var(--text-faint); color: var(--text); }
|
||||||
.fact-row {
|
.fact-row {
|
||||||
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
|
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
|
||||||
font-size: .8125rem; color: var(--text-muted);
|
font-size: .8125rem; color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; }
|
.fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; }
|
||||||
.fact b { color: var(--text); font-weight: 640; }
|
.fact b { color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- code */
|
/* ---------------------------------------------------------------- code */
|
||||||
.code {
|
.code {
|
||||||
@@ -216,8 +266,11 @@ header.nav {
|
|||||||
border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised);
|
border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised);
|
||||||
font: 500 .75rem/1 var(--mono); color: var(--code-faint);
|
font: 500 .75rem/1 var(--mono); color: var(--code-faint);
|
||||||
}
|
}
|
||||||
.code-head .dot { width: .5rem; height: .5rem; border-radius: 50%; background: #33334a; }
|
/* Use 3 of 3, and a deletion: the fake traffic lights are gone. */
|
||||||
.code-head .name { margin-left: .35rem; }
|
.code-head::before {
|
||||||
|
content: ""; flex: none; width: 1.05rem; height: 2px;
|
||||||
|
background-image: var(--rule-code);
|
||||||
|
}
|
||||||
.code-head .right { margin-left: auto; }
|
.code-head .right { margin-left: auto; }
|
||||||
.code pre {
|
.code pre {
|
||||||
margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto;
|
margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto;
|
||||||
@@ -234,18 +287,18 @@ header.nav {
|
|||||||
|
|
||||||
/* The page's one visual device: the line the compiler refuses. */
|
/* The page's one visual device: the line the compiler refuses. */
|
||||||
.ln--error {
|
.ln--error {
|
||||||
background: rgba(255, 92, 122, .09);
|
background: color-mix(in srgb, var(--err-line) 9%, transparent);
|
||||||
text-decoration: underline wavy #ff5c7a;
|
text-decoration: underline wavy var(--err-line);
|
||||||
text-decoration-skip-ink: none;
|
text-decoration-skip-ink: none;
|
||||||
text-underline-offset: .32em;
|
text-underline-offset: .32em;
|
||||||
}
|
}
|
||||||
.tsc-error {
|
.tsc-error {
|
||||||
display: flex; gap: .6rem; align-items: flex-start;
|
display: flex; gap: .6rem; align-items: flex-start;
|
||||||
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
|
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
|
||||||
background: rgba(255, 92, 122, .08); color: #ffb3c0;
|
background: color-mix(in srgb, var(--err-line) 8%, transparent); color: var(--err-text);
|
||||||
font: 500 .78125rem/1.5 var(--mono);
|
font: 500 .78125rem/1.5 var(--mono);
|
||||||
}
|
}
|
||||||
.tsc-error .mark { color: #ff5c7a; flex-shrink: 0; }
|
.tsc-error .mark { color: var(--err-line); flex-shrink: 0; }
|
||||||
|
|
||||||
/* --------------------------------------------------------------- panels */
|
/* --------------------------------------------------------------- panels */
|
||||||
.panel {
|
.panel {
|
||||||
@@ -259,6 +312,7 @@ header.nav {
|
|||||||
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
||||||
.tick { color: var(--ok); font-weight: 700; }
|
.tick { color: var(--ok); font-weight: 700; }
|
||||||
.cross { color: var(--bad); font-weight: 700; }
|
.cross { color: var(--bad); font-weight: 700; }
|
||||||
|
.warn-mark { color: var(--warn); font-weight: 700; }
|
||||||
|
|
||||||
.compare { display: grid; gap: 1rem; }
|
.compare { display: grid; gap: 1rem; }
|
||||||
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
|
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
|
||||||
@@ -290,14 +344,17 @@ header.nav {
|
|||||||
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre;
|
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
#editor:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
|
#editor:focus { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
|
||||||
|
/* Scrollable code samples are keyboard-focusable in Chromium; the page-level ring is
|
||||||
|
unreadable on the dark slab and its +2px offset would be clipped by .code overflow. */
|
||||||
|
.code :focus-visible, #output:focus-visible { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
|
||||||
#output {
|
#output {
|
||||||
flex: 1; min-height: 400px; margin: 0; overflow: auto;
|
flex: 1; min-height: 400px; margin: 0; overflow: auto;
|
||||||
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
|
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
|
||||||
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||||
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); white-space: pre-wrap; word-break: break-word;
|
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); white-space: pre-wrap; word-break: break-word;
|
||||||
}
|
}
|
||||||
#output .out-err { color: #ff8095; }
|
#output .out-err { color: var(--err-text); }
|
||||||
#output .out-dim { color: var(--code-faint); }
|
#output .out-dim { color: var(--code-faint); }
|
||||||
.pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; }
|
.pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; }
|
||||||
|
|
||||||
@@ -313,7 +370,7 @@ td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
|
|||||||
.ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; }
|
.ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; }
|
||||||
#ref-filter {
|
#ref-filter {
|
||||||
flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem;
|
flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem;
|
||||||
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
border: 1px solid var(--border-ui); border-radius: var(--radius);
|
||||||
background: var(--bg-raised); color: var(--text);
|
background: var(--bg-raised); color: var(--text);
|
||||||
}
|
}
|
||||||
#ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
#ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||||
@@ -331,7 +388,8 @@ td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
|
|||||||
|
|
||||||
/* --------------------------------------------------------------- prose */
|
/* --------------------------------------------------------------- prose */
|
||||||
.notes { display: grid; gap: .9rem; }
|
.notes { display: grid; gap: .9rem; }
|
||||||
.note-item { border-left: 2px solid var(--border-strong); padding-left: 1rem; }
|
/* Use 2 of 3: the same rhythm stood on end. */
|
||||||
|
.note-item { padding-left: 1rem; background: var(--rule-y) left top / 2px 100% no-repeat; }
|
||||||
.note-item h3 { font-size: .9375rem; margin-bottom: .25rem; }
|
.note-item h3 { font-size: .9375rem; margin-bottom: .25rem; }
|
||||||
.note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
.note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
||||||
.callout {
|
.callout {
|
||||||
@@ -352,14 +410,15 @@ footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(
|
|||||||
|
|
||||||
<header class="nav">
|
<header class="nav">
|
||||||
<div class="wrap nav-inner">
|
<div class="wrap nav-inner">
|
||||||
<a class="brand" href="#top"><span class="grain" aria-hidden="true">🌾</span>cereale <span class="badge" id="version-badge">v0.3.0</span></a>
|
<a class="brand" href="#top"><svg class="mark" width="10" height="18" viewBox="0 0 10 18" aria-hidden="true" focusable="false"><rect x="4" y="0" width="2" height="18" fill="currentColor" opacity=".3"/><rect x="0" y="2" width="10" height="2" fill="currentColor"/><rect x="0" y="6" width="10" height="2" fill="currentColor"/><rect x="0" y="10" width="10" height="2" fill="currentColor"/><rect x="0" y="14" width="10" height="2" fill="currentColor"/></svg>cereale <span class="badge" id="version-badge">v0.4.1</span></a>
|
||||||
<nav class="nav-links" aria-label="Primary">
|
<nav class="nav-links" aria-label="Primary">
|
||||||
<a class="nav-hide" href="#guarantee">Guarantee</a>
|
<a class="nav-hide" href="#guarantee">Guarantee</a>
|
||||||
<a class="nav-hide" href="#playground">Playground</a>
|
<a class="nav-hide" href="#playground">Playground</a>
|
||||||
<a class="nav-hide" href="#errors">Errors</a>
|
<a class="nav-hide" href="#errors">Errors</a>
|
||||||
<a class="nav-hide" href="#install">Install</a>
|
<a class="nav-hide" href="#install">Install</a>
|
||||||
|
<a class="nav-hide" href="#size">Size</a>
|
||||||
<a class="nav-hide" href="#reference">Reference</a>
|
<a class="nav-hide" href="#reference">Reference</a>
|
||||||
<a class="ghost" href="https://github.com/Avalon-Vanguard/cereale">GitHub</a>
|
<a class="ghost" href="https://github.com/avalon-vanguard/cereale">GitHub</a>
|
||||||
<button class="icon-btn" id="theme-toggle" type="button" aria-label="Switch between light and dark theme">
|
<button class="icon-btn" id="theme-toggle" type="button" aria-label="Switch between light and dark theme">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
|
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
|
||||||
@@ -386,21 +445,22 @@ footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(
|
|||||||
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6V4z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||||
Run it in the browser
|
Run it in the browser
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-secondary" href="https://github.com/Avalon-Vanguard/cereale">Read the source</a>
|
<a class="btn btn-secondary" href="https://github.com/avalon-vanguard/cereale">Read the source</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="fact-row">
|
<div class="fact-row">
|
||||||
<span class="fact"><b>0</b> runtime dependencies</span>
|
<span class="fact"><b>0</b> runtime dependencies</span>
|
||||||
<span class="fact"><b id="decorator-count">68</b> decorators</span>
|
<span class="fact"><b id="decorator-count">68</b> decorators</span>
|
||||||
<span class="fact">TC39 <b>standard decorators</b></span>
|
<span class="fact">TC39 <b>standard decorators</b></span>
|
||||||
<span class="fact">ESM + CJS</span>
|
<span class="fact">ESM + CJS + single-file</span>
|
||||||
<span class="fact">Node <b id="node-req">≥20</b></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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="code">
|
<div class="code">
|
||||||
<div class="code-head">
|
<div class="code-head">
|
||||||
<span class="dot" aria-hidden="true"></span><span class="name">order.ts</span>
|
<span class="name">order.ts</span>
|
||||||
</div>
|
</div>
|
||||||
<pre><code data-lang="ts" data-error-line="9">class Order {
|
<pre><code data-lang="ts" data-error-line="9">class Order {
|
||||||
@JsonProperty('order_ref')
|
@JsonProperty('order_ref')
|
||||||
@@ -451,7 +511,7 @@ order.total(); // methods intact</code></pre>
|
|||||||
<div>
|
<div>
|
||||||
<p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p>
|
<p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p>
|
||||||
<div class="code">
|
<div class="code">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">with legacy decorators</span></div>
|
<div class="code-head"><span class="name">with legacy decorators</span></div>
|
||||||
<pre><code data-lang="ts">class User {
|
<pre><code data-lang="ts">class User {
|
||||||
@IsString()
|
@IsString()
|
||||||
age: number; // accepted by the compiler
|
age: number; // accepted by the compiler
|
||||||
@@ -464,7 +524,7 @@ order.total(); // methods intact</code></pre>
|
|||||||
<div>
|
<div>
|
||||||
<p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p>
|
<p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p>
|
||||||
<div class="code">
|
<div class="code">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">with standard decorators</span></div>
|
<div class="code-head"><span class="name">with standard decorators</span></div>
|
||||||
<pre><code data-lang="ts" data-error-line="2">class User {
|
<pre><code data-lang="ts" data-error-line="2">class User {
|
||||||
@IsString()
|
@IsString()
|
||||||
age!: number; // Type 'number' is not
|
age!: number; // Type 'number' is not
|
||||||
@@ -508,7 +568,7 @@ order.total(); // methods intact</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
<div class="compare">
|
<div class="compare">
|
||||||
<div class="code">
|
<div class="code">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">catalogue.ts</span></div>
|
<div class="code-head"><span class="name">catalogue.ts</span></div>
|
||||||
<pre><code data-lang="ts">class Media {
|
<pre><code data-lang="ts">class Media {
|
||||||
@IsString() title!: string;
|
@IsString() title!: string;
|
||||||
}
|
}
|
||||||
@@ -530,7 +590,7 @@ class Playlist {
|
|||||||
}</code></pre>
|
}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="code">
|
<div class="code">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">what comes out</span></div>
|
<div class="code-head"><span class="name">what comes out</span></div>
|
||||||
<pre><code data-lang="ts">const list = fromJsonSync(Playlist, body);
|
<pre><code data-lang="ts">const list = fromJsonSync(Playlist, body);
|
||||||
const first = list.items[0];
|
const first = list.items[0];
|
||||||
|
|
||||||
@@ -575,14 +635,14 @@ if (first instanceof Movie) {
|
|||||||
<div class="pg-grid">
|
<div class="pg-grid">
|
||||||
<div class="pg-pane">
|
<div class="pg-pane">
|
||||||
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
||||||
<span class="dot" aria-hidden="true"></span><span class="name">playground.ts</span>
|
<span class="name">playground.ts</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
<textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
||||||
aria-label="TypeScript source to run"></textarea>
|
aria-label="TypeScript source to run"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="pg-pane">
|
<div class="pg-pane">
|
||||||
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
|
||||||
<span class="dot" aria-hidden="true"></span><span class="name">output</span>
|
<span class="name">output</span>
|
||||||
<span class="right" id="pg-status"></span>
|
<span class="right" id="pg-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre>
|
<pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre>
|
||||||
@@ -604,7 +664,7 @@ if (first instanceof Movie) {
|
|||||||
<p class="eyebrow">Diagnosability</p>
|
<p class="eyebrow">Diagnosability</p>
|
||||||
<h2>Nothing fails quietly</h2>
|
<h2>Nothing fails quietly</h2>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
The 0.3.0 release exists because of this. A mapping layer that loses your data and reports
|
The 0.3.0 release existed because of this. A mapping layer that loses your data and reports
|
||||||
success is worse than one that stops, so every silent failure found in the engine was turned
|
success is worse than one that stops, so every silent failure found in the engine was turned
|
||||||
into an error that names the cause and the way out.
|
into an error that names the cause and the way out.
|
||||||
</p>
|
</p>
|
||||||
@@ -626,7 +686,7 @@ if (first instanceof Movie) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:1.5rem" class="code">
|
<div style="margin-top:1.5rem" class="code">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">what you get instead</span></div>
|
<div class="code-head"><span class="name">what you get instead</span></div>
|
||||||
<pre><code data-lang="text">JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON.
|
<pre><code data-lang="text">JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON.
|
||||||
Give the property a @JsonSerialize() serializer that converts it, or drop it from
|
Give the property a @JsonSerialize() serializer that converts it, or drop it from
|
||||||
the output with @JsonIgnore().
|
the output with @JsonIgnore().
|
||||||
@@ -650,7 +710,8 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
|
|||||||
<h2>The toolchain cost, stated plainly</h2>
|
<h2>The toolchain cost, stated plainly</h2>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
cereale reads the metadata that only a <strong>standard</strong> decorator transform emits, so
|
cereale reads the metadata that only a <strong>standard</strong> decorator transform emits, so
|
||||||
which compiler you use decides whether it works at all. The three ✓ rows are executed by a
|
which compiler you use decides whether it works at all. The three ✓ rows in the first table are
|
||||||
|
executed by a
|
||||||
test on every CI run rather than asserted here — each one compiles a decorated class with
|
test on every CI run rather than asserted here — each one compiles a decorated class with
|
||||||
that tool and checks the metadata arrived. The ✗ row cannot be: oxc ships inside a native
|
that tool and checks the metadata arrived. The ✗ row cannot be: oxc ships inside a native
|
||||||
binary with no standalone transform API, so it was established by hand, and the plugin below
|
binary with no standalone transform API, so it was established by hand, and the plugin below
|
||||||
@@ -665,7 +726,7 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
|
|||||||
<tr><th scope="col">Transformer</th><th scope="col">Works</th><th scope="col">Setting</th></tr>
|
<tr><th scope="col">Transformer</th><th scope="col">Works</th><th scope="col">Setting</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>tsc</code></td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code>, <code>target: ES2022</code>+</td></tr>
|
<tr><td><code>tsc</code> 5.2+</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code>, <code>target: ES2022</code>+</td></tr>
|
||||||
<tr><td>esbuild</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code> via <code>tsconfigRaw</code>, <em>plus</em> esbuild's own top-level <code>target: es2022</code> — its default <code>esnext</code> leaves the decorators in place</td></tr>
|
<tr><td>esbuild</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code> via <code>tsconfigRaw</code>, <em>plus</em> esbuild's own top-level <code>target: es2022</code> — its default <code>esnext</code> leaves the decorators in place</td></tr>
|
||||||
<tr><td>swc</td><td><span class="tick">✓</span></td><td class="note"><code>jsc.transform.decoratorVersion: "2022-03"</code></td></tr>
|
<tr><td>swc</td><td><span class="tick">✓</span></td><td class="note"><code>jsc.transform.decoratorVersion: "2022-03"</code></td></tr>
|
||||||
<tr><td>oxc</td><td><span class="cross">✗</span></td><td class="note">used by <strong>Vite 8</strong> and <strong>Vitest 4</strong> — see below</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>
|
||||||
@@ -681,8 +742,29 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
|
|||||||
that fixes it.
|
that fixes it.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="table-scroll" style="margin-top:1.5rem">
|
||||||
|
<table>
|
||||||
|
<caption class="sr-only">Framework support</caption>
|
||||||
|
<thead><tr><th scope="col">Framework</th><th scope="col">Works</th><th scope="col">What it takes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Angular 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" style="margin-top:1.25rem;max-width:640px">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">vite.config.ts</span></div>
|
<div class="code-head"><span class="name">vite.config.ts</span></div>
|
||||||
<pre><code data-lang="ts">import { defineConfig } from 'vite';
|
<pre><code data-lang="ts">import { defineConfig } from 'vite';
|
||||||
import { standardDecorators } from 'cereale/vite';
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
@@ -707,14 +789,14 @@ export default defineConfig({
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<p class="eyebrow">Getting started</p>
|
<p class="eyebrow">Getting started</p>
|
||||||
<h2>Two settings, and one caveat</h2>
|
<h2>Two settings, one caveat, and two ways in</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="compare">
|
<div class="compare">
|
||||||
<div>
|
<div>
|
||||||
<p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p>
|
<p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p>
|
||||||
<div class="code">
|
<div class="code">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">tsconfig.json</span></div>
|
<div class="code-head"><span class="name">tsconfig.json</span></div>
|
||||||
<pre><code data-lang="text">{
|
<pre><code data-lang="text">{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
@@ -732,22 +814,105 @@ export default defineConfig({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="compare-label"><span class="pill pill-bad">not on npm yet</span> installing it today</p>
|
<p class="compare-label"><span class="pill pill-ok">on npm</span> installing it</p>
|
||||||
<div class="code">
|
<div class="code">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">shell</span></div>
|
<div class="code-head"><span class="name">shell</span></div>
|
||||||
<pre><code data-lang="text">git clone https://github.com/Avalon-Vanguard/cereale
|
<pre><code data-lang="text">npm install cereale</code></pre>
|
||||||
cd cereale
|
|
||||||
npm install && npm run build
|
|
||||||
npm pack # → cereale-0.3.0.tgz
|
|
||||||
|
|
||||||
# then, from your own project
|
|
||||||
npm install ../cereale/cereale-0.3.0.tgz</code></pre>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="pg-note">
|
<p class="pg-note">
|
||||||
<code class="inline-code">npm install cereale</code> does <strong>not</strong> resolve to
|
Published from CI with <strong>provenance</strong>, so the registry carries a verified
|
||||||
this library — the name is unclaimed on the registry. Installing straight from GitHub
|
attestation linking the tarball to the commit it was built from — visible on the
|
||||||
will not work either: the build output is not committed, so the package would arrive
|
<a href="https://www.npmjs.com/package/cereale">package page</a>.
|
||||||
without its <code class="inline-code">dist/</code>.
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top:1.75rem">
|
||||||
|
<h3>No bundler at all</h3>
|
||||||
|
<p>
|
||||||
|
<code class="inline-code">cereale/min</code> is the whole library flattened into one
|
||||||
|
minified ES module — <strong>33.9 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -779,7 +944,7 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
|
|||||||
<div class="ref-groups" id="ref-groups"></div>
|
<div class="ref-groups" id="ref-groups"></div>
|
||||||
<noscript>
|
<noscript>
|
||||||
<p class="ref-empty">The reference is rendered with JavaScript. The full list is in the
|
<p class="ref-empty">The reference is rendered with JavaScript. The full list is in the
|
||||||
<a href="https://github.com/Avalon-Vanguard/cereale#api-reference">README</a>.</p>
|
<a href="https://github.com/avalon-vanguard/cereale#api-reference">README</a>.</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -826,9 +991,11 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
|
|||||||
cannot reach. Both are errors rather than silent no-ops.</p>
|
cannot reach. Both are errors rather than silent no-ops.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="note-item">
|
<div class="note-item">
|
||||||
<h3>It is not on npm yet</h3>
|
<h3>It is still 0.x</h3>
|
||||||
<p>0.3.0 lives in the repository. <code class="inline-code">npm install cereale</code> does not
|
<p><span class="js-version">0.4.1</span> is published, and under semver a 0.x minor bump
|
||||||
resolve to this library — build it from source until it is published.</p>
|
is allowed to break you. Pin the version until 1.0; the
|
||||||
|
<a href="https://github.com/avalon-vanguard/cereale/blob/main/CHANGELOG.md">changelog</a>
|
||||||
|
says what moved and why.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -841,9 +1008,9 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
|
|||||||
<div class="wrap foot-grid">
|
<div class="wrap foot-grid">
|
||||||
<p style="margin:0">cereale — MIT licensed. Built by Avalon Vanguard.</p>
|
<p style="margin:0">cereale — MIT licensed. Built by Avalon Vanguard.</p>
|
||||||
<p style="margin:0">
|
<p style="margin:0">
|
||||||
<a href="https://github.com/Avalon-Vanguard/cereale">Source</a> ·
|
<a href="https://github.com/avalon-vanguard/cereale">Source</a> ·
|
||||||
<a href="https://github.com/Avalon-Vanguard/cereale/blob/main/CHANGELOG.md">Changelog</a> ·
|
<a href="https://github.com/avalon-vanguard/cereale/blob/main/CHANGELOG.md">Changelog</a> ·
|
||||||
<a href="https://github.com/Avalon-Vanguard/cereale/issues">Issues</a>
|
<a href="https://github.com/avalon-vanguard/cereale/issues">Issues</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// Generated by scripts/build-docs.mjs — do not edit.
|
// Generated by scripts/build-docs.mjs — do not edit.
|
||||||
window.CEREALE_META = {
|
window.CEREALE_META = {
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,16 @@
|
|||||||
if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version;
|
if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version;
|
||||||
var nodeEl = document.getElementById('node-req');
|
var nodeEl = document.getElementById('node-req');
|
||||||
if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', '');
|
if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', '');
|
||||||
|
if (meta.version) {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.js-version'), function (el) {
|
||||||
|
el.textContent = meta.version;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (decoratorCount) {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.js-dec-count'), function (el) {
|
||||||
|
el.textContent = String(decoratorCount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------- highlighting */
|
/* ------------------------------------------------------- highlighting */
|
||||||
var TOKENS = [
|
var TOKENS = [
|
||||||
|
|||||||
Vendored
+2
@@ -3,3 +3,5 @@
|
|||||||
Generated by `npm run build:docs`. Do not edit by hand.
|
Generated by `npm run build:docs`. Do not edit by hand.
|
||||||
|
|
||||||
- `babel.min.js` — @babel/standalone 8.0.4, used by the playground to compile TypeScript with standard decorators in the browser. Vendored rather than loaded from a CDN: the previous page referenced an unpinned unpkg URL, which silently began serving Babel 8 and broke the playground.
|
- `babel.min.js` — @babel/standalone 8.0.4, used by the playground to compile TypeScript with standard decorators in the browser. Vendored rather than loaded from a CDN: the previous page referenced an unpinned unpkg URL, which silently began serving Babel 8 and broke the playground.
|
||||||
|
|
||||||
|
- `../.nojekyll` — opts the directory out of Jekyll, so GitHub Pages serves it verbatim. Jekyll ignores paths beginning with an underscore and carries default `vendor/` exclusions, and the failure mode is an asset that silently does not publish — for this page, the playground's compiler 404ing while everything else looks fine.
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import eslint from '@eslint/js';
|
import avalonBase from '@avalon-vanguard/config/eslint';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'docs/**'],
|
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'docs/**'],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
// @eslint/js recommended + typescript-eslint recommended, shared across avalon-vanguard
|
||||||
...tseslint.configs.recommended,
|
avalonBase,
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
|
|||||||
Generated
+35
-2
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@avalon-vanguard/config": "^1.0.0",
|
||||||
"@babel/standalone": "^8.0.4",
|
"@babel/standalone": "^8.0.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@swc/core": "^1.15.47",
|
"@swc/core": "^1.15.47",
|
||||||
@@ -26,6 +27,38 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@avalon-vanguard/config": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://git.avalonvanguard.com/api/packages/avalon-vanguard/npm/%40avalon-vanguard%2Fconfig/-/1.0.0/config-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ySojd0OONRb9N9KT4CJeyLf5GUrfRP8jxfPTMRv3HUk7gld4VuzZd16lW774SfLkMBMOUYVVf9SKBMO+JATTTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@eslint/js": "^10.0.0",
|
||||||
|
"angular-eslint": "^22.0.0",
|
||||||
|
"eslint": "^10.0.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"typescript": "^6.0.0",
|
||||||
|
"typescript-eslint": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@eslint/js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"angular-eslint": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"eslint": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript-eslint": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
|
|||||||
+27
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
|
"description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
@@ -16,20 +16,33 @@
|
|||||||
"types": "./dist/esm/vite.d.ts",
|
"types": "./dist/esm/vite.d.ts",
|
||||||
"import": "./dist/esm/vite.js",
|
"import": "./dist/esm/vite.js",
|
||||||
"require": "./dist/cjs/vite.js"
|
"require": "./dist/cjs/vite.js"
|
||||||
|
},
|
||||||
|
"./min": {
|
||||||
|
"types": "./dist/esm/index.d.ts",
|
||||||
|
"default": "./dist/cereale.min.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
"./dist/esm/index.js",
|
||||||
"./dist/esm/metadata.js",
|
"./dist/esm/metadata.js",
|
||||||
"./dist/cjs/metadata.js"
|
"./dist/cereale.min.js",
|
||||||
|
"./src/index.ts",
|
||||||
|
"./src/metadata.ts"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"src",
|
||||||
|
"FRAMEWORKS.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"!src/**/*.test.ts",
|
||||||
|
"!src/example.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
|
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node scripts/build-bundle.mjs",
|
||||||
"build:docs": "node scripts/build-docs.mjs",
|
"build:docs": "node scripts/build-docs.mjs",
|
||||||
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"typecheck": "npm run type-check",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
@@ -37,15 +50,17 @@
|
|||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs",
|
"verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs",
|
||||||
"prepublishOnly": "npm run verify",
|
"prepublishOnly": "npm run verify",
|
||||||
|
"check": "node scripts/check-entry-points.mjs && npm run demo && npm run check:types && npm run check:docs && npm run check:docs-sync",
|
||||||
"check:docs": "node scripts/check-docs.mjs",
|
"check:docs": "node scripts/check-docs.mjs",
|
||||||
"check:types": "node scripts/check-types.mjs"
|
"check:types": "node scripts/check-types.mjs",
|
||||||
|
"check:docs-sync": "npm run build:docs && node scripts/check-docs-sync.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/Avalon-Vanguard/cereale.git"
|
"url": "git+https://github.com/avalon-vanguard/cereale.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"json",
|
"json",
|
||||||
@@ -62,10 +77,11 @@
|
|||||||
"author": "Avalon Vanguard",
|
"author": "Avalon Vanguard",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/Avalon-Vanguard/cereale/issues"
|
"url": "https://github.com/avalon-vanguard/cereale/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/Avalon-Vanguard/cereale#readme",
|
"homepage": "https://avalon-vanguard.github.io/cereale/",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@avalon-vanguard/config": "^1.0.0",
|
||||||
"@babel/standalone": "^8.0.4",
|
"@babel/standalone": "^8.0.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@swc/core": "^1.15.47",
|
"@swc/core": "^1.15.47",
|
||||||
@@ -78,5 +94,8 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.4"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)`);
|
||||||
+29
-1
@@ -49,6 +49,27 @@ await writeFile(
|
|||||||
`window.CEREALE_META = ${JSON.stringify({ version: pkg.version, node: pkg.engines.node }, null, 2)};\n`
|
`window.CEREALE_META = ${JSON.stringify({ version: pkg.version, node: pkg.engines.node }, null, 2)};\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2b. The same version, stamped into the two spots in index.html that page.js later
|
||||||
|
// overwrites from meta.js. Those are the no-JavaScript fallbacks: correct in a browser,
|
||||||
|
// stale in a text reader or a scraper, and hand-bumped until now — they went stale on
|
||||||
|
// 0.4.0 and again on 0.4.1. Stamping them here means the docs-sync gate catches the
|
||||||
|
// drift instead of a reviewer. The regexes are asserted, so if the markup is renamed
|
||||||
|
// the build fails loudly rather than silently stamping nothing.
|
||||||
|
const indexPath = path.join(docs, 'index.html');
|
||||||
|
let index = await readFile(indexPath, 'utf8');
|
||||||
|
const stamps = [
|
||||||
|
[/(<span class="badge" id="version-badge">)v[\d.]+(<\/span>)/, `$1v${pkg.version}$2`],
|
||||||
|
[/(<span class="js-version">)[\d.]+(<\/span>)/g, `$1${pkg.version}$2`],
|
||||||
|
];
|
||||||
|
for (const [re, replacement] of stamps) {
|
||||||
|
if (!re.test(index)) {
|
||||||
|
console.error(`build-docs: nothing in docs/index.html matched ${re} — the version fallback markup moved.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
index = index.replace(re, replacement);
|
||||||
|
}
|
||||||
|
await writeFile(indexPath, index);
|
||||||
|
|
||||||
// 3. The compiler errors the page quotes, produced by actually running the compiler. If a
|
// 3. The compiler errors the page quotes, produced by actually running the compiler. If a
|
||||||
// snippet the page calls a compile error ever compiles, this fails the build.
|
// snippet the page calls a compile error ever compiles, this fails the build.
|
||||||
const { byCase, problems } = await collectDiagnostics(root);
|
const { byCase, problems } = await collectDiagnostics(root);
|
||||||
@@ -73,9 +94,16 @@ await writeFile(
|
|||||||
`- \`babel.min.js\` — @babel/standalone ${JSON.parse(await readFile(path.join(root, 'node_modules/@babel/standalone/package.json'), 'utf8')).version}, ` +
|
`- \`babel.min.js\` — @babel/standalone ${JSON.parse(await readFile(path.join(root, 'node_modules/@babel/standalone/package.json'), 'utf8')).version}, ` +
|
||||||
`used by the playground to compile TypeScript with standard decorators in the browser. ` +
|
`used by the playground to compile TypeScript with standard decorators in the browser. ` +
|
||||||
`Vendored rather than loaded from a CDN: the previous page referenced an unpinned unpkg URL, ` +
|
`Vendored rather than loaded from a CDN: the previous page referenced an unpinned unpkg URL, ` +
|
||||||
`which silently began serving Babel 8 and broke the playground.\n`
|
`which silently began serving Babel 8 and broke the playground.\n\n` +
|
||||||
|
`- \`../.nojekyll\` — opts the directory out of Jekyll, so GitHub Pages serves it verbatim. ` +
|
||||||
|
`Jekyll ignores paths beginning with an underscore and carries default \`vendor/\` exclusions, ` +
|
||||||
|
`and the failure mode is an asset that silently does not publish — for this page, the ` +
|
||||||
|
`playground's compiler 404ing while everything else looks fine.\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 5. Written rather than committed by hand so it cannot be lost in a docs/ rewrite.
|
||||||
|
await writeFile(path.join(docs, '.nojekyll'), '');
|
||||||
|
|
||||||
console.log(`docs/cereale.js ${await size(path.join(docs, 'cereale.js'))}`);
|
console.log(`docs/cereale.js ${await size(path.join(docs, 'cereale.js'))}`);
|
||||||
console.log(`docs/meta.js ${await size(path.join(docs, 'meta.js'))} (v${pkg.version})`);
|
console.log(`docs/meta.js ${await size(path.join(docs, 'meta.js'))} (v${pkg.version})`);
|
||||||
console.log(`docs/vendor/babel.min.js ${await size(path.join(vendor, 'babel.min.js'))}`);
|
console.log(`docs/vendor/babel.min.js ${await size(path.join(vendor, 'babel.min.js'))}`);
|
||||||
|
|||||||
@@ -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.');
|
||||||
+21
-1
@@ -22,12 +22,23 @@ const failures = [];
|
|||||||
/** Subresource references — the things a browser fetches without being clicked. */
|
/** Subresource references — the things a browser fetches without being clicked. */
|
||||||
const SUBRESOURCES = [
|
const SUBRESOURCES = [
|
||||||
[/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'script src'],
|
[/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'script src'],
|
||||||
[/<link\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi, 'link href'],
|
|
||||||
[/<(?:img|iframe|video|audio|source|embed)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'media src'],
|
[/<(?:img|iframe|video|audio|source|embed)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'media src'],
|
||||||
[/@import\s+(?:url\()?["']([^"']+)["']/gi, 'css @import'],
|
[/@import\s+(?:url\()?["']([^"']+)["']/gi, 'css @import'],
|
||||||
[/url\(\s*["']?(https?:\/\/[^)"']+)/gi, 'css url()'],
|
[/url\(\s*["']?(https?:\/\/[^)"']+)/gi, 'css url()'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<link>` relations the browser actually fetches or connects to.
|
||||||
|
*
|
||||||
|
* Checked against `rel` rather than flagging every `<link href>`, because the metadata
|
||||||
|
* relations — `canonical` above all — are declarations about the document, not requests. A
|
||||||
|
* check that cannot tell the difference gets switched off the first time it is wrong.
|
||||||
|
*/
|
||||||
|
const FETCHING_REL = new Set([
|
||||||
|
'stylesheet', 'icon', 'shortcut icon', 'apple-touch-icon', 'apple-touch-icon-precomposed',
|
||||||
|
'manifest', 'preload', 'modulepreload', 'prefetch', 'prerender', 'preconnect', 'dns-prefetch',
|
||||||
|
]);
|
||||||
|
|
||||||
const isRemote = (url) => /^(?:https?:)?\/\//i.test(url);
|
const isRemote = (url) => /^(?:https?:)?\/\//i.test(url);
|
||||||
|
|
||||||
const html = (await readdir(docs)).filter((name) => name.endsWith('.html'));
|
const html = (await readdir(docs)).filter((name) => name.endsWith('.html'));
|
||||||
@@ -40,6 +51,15 @@ for (const name of html) {
|
|||||||
if (isRemote(match[1])) failures.push(`docs/${name}: remote ${kind} — ${match[1]}`);
|
if (isRemote(match[1])) failures.push(`docs/${name}: remote ${kind} — ${match[1]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const match of source.matchAll(/<link\b([^>]*)>/gi)) {
|
||||||
|
const attrs = match[1];
|
||||||
|
const rel = (/\brel\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1] ?? '').trim().toLowerCase();
|
||||||
|
const href = /\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1];
|
||||||
|
if (href && isRemote(href) && FETCHING_REL.has(rel)) {
|
||||||
|
failures.push(`docs/${name}: remote link rel="${rel}" — ${href}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// A fetch to a CDN would not be caught by the markup scan.
|
// A fetch to a CDN would not be caught by the markup scan.
|
||||||
for (const match of source.matchAll(/\b(?:fetch|importScripts)\(\s*["'`](https?:\/\/[^"'`]+)/gi)) {
|
for (const match of source.matchAll(/\b(?:fetch|importScripts)\(\s*["'`](https?:\/\/[^"'`]+)/gi)) {
|
||||||
failures.push(`docs/${name}: remote fetch — ${match[1]}`);
|
failures.push(`docs/${name}: remote fetch — ${match[1]}`);
|
||||||
|
|||||||
@@ -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
|
// Type rules
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const IsString: Rule<string> = rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
|
export const IsString: Rule<string> = /*#__PURE__*/ rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
|
||||||
export const IsNumber: Rule<number> = rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`);
|
export const IsNumber: Rule<number> = /*#__PURE__*/ rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`);
|
||||||
export const IsInt: Rule<number> = rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
|
export const IsInt: Rule<number> = /*#__PURE__*/ rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
|
||||||
export const IsBoolean: Rule<boolean> = rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
|
export const IsBoolean: Rule<boolean> = /*#__PURE__*/ rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
|
||||||
export const IsBigInt: Rule<bigint> = rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
|
export const IsBigInt: Rule<bigint> = /*#__PURE__*/ rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
|
||||||
export const IsDate: Rule<Date> = rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`);
|
export const IsDate: Rule<Date> = /*#__PURE__*/ rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`);
|
||||||
export const IsObject: Rule<object> = rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
|
export const IsObject: Rule<object> = /*#__PURE__*/ rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
|
||||||
|
|
||||||
export const IsDefined: Rule<unknown> = rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`);
|
export const IsDefined: Rule<unknown> = /*#__PURE__*/ rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`);
|
||||||
export const IsNotEmpty: Rule<unknown> = rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
export const IsNotEmpty: Rule<unknown> = /*#__PURE__*/ rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
||||||
export const IsEmpty: Rule<unknown> = rule('isEmpty', v => {
|
export const IsEmpty: Rule<unknown> = /*#__PURE__*/ rule('isEmpty', v => {
|
||||||
if (v === null || v === undefined || v === '') return true;
|
if (v === null || v === undefined || v === '') return true;
|
||||||
if (Array.isArray(v)) return v.length === 0;
|
if (Array.isArray(v)) return v.length === 0;
|
||||||
if (typeof v === 'object') return Object.keys(v).length === 0;
|
if (typeof v === 'object') return Object.keys(v).length === 0;
|
||||||
@@ -301,8 +301,8 @@ export function Max(max: number, options?: ValidationOptions): unknown {
|
|||||||
}), options);
|
}), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Positive: Rule<number> = rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
|
export const Positive: Rule<number> = /*#__PURE__*/ rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
|
||||||
export const Negative: Rule<number> = rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`);
|
export const Negative: Rule<number> = /*#__PURE__*/ rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`);
|
||||||
|
|
||||||
export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each<number>;
|
export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each<number>;
|
||||||
export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One<number>;
|
export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One<number>;
|
||||||
@@ -315,13 +315,13 @@ export function IsDivisibleBy(divisor: number, options?: ValidationOptions): unk
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** An integer in 0..65535. Accepts a number or a numeric string. */
|
/** An integer in 0..65535. Accepts a number or a numeric string. */
|
||||||
export const IsPort: Rule<number | string> = rule('isPort', v => {
|
export const IsPort: Rule<number | string> = /*#__PURE__*/ rule('isPort', v => {
|
||||||
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
|
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
|
||||||
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
|
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
|
||||||
}, p => `${p} must be a valid port number`);
|
}, p => `${p} must be a valid port number`);
|
||||||
|
|
||||||
export const IsLatitude: Rule<number> = rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`);
|
export const IsLatitude: Rule<number> = /*#__PURE__*/ rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`);
|
||||||
export const IsLongitude: Rule<number> = rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`);
|
export const IsLongitude: Rule<number> = /*#__PURE__*/ rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Strings
|
// Strings
|
||||||
@@ -358,22 +358,22 @@ export function Length(min: number, max?: number, options?: ValidationOptions):
|
|||||||
}), options);
|
}), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Email: Rule<string> = pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
|
export const Email: Rule<string> = /*#__PURE__*/ pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
|
||||||
export const IsAlpha: Rule<string> = pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
|
export const IsAlpha: Rule<string> = /*#__PURE__*/ pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
|
||||||
export const IsAlphanumeric: Rule<string> = pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`);
|
export const IsAlphanumeric: Rule<string> = /*#__PURE__*/ pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`);
|
||||||
export const IsSemVer: Rule<string> = pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`);
|
export const IsSemVer: Rule<string> = /*#__PURE__*/ pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`);
|
||||||
export const IsHexColor: Rule<string> = pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`);
|
export const IsHexColor: Rule<string> = /*#__PURE__*/ pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`);
|
||||||
|
|
||||||
export const IsLowercase: Rule<string> = rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`);
|
export const IsLowercase: Rule<string> = /*#__PURE__*/ rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`);
|
||||||
export const IsUppercase: Rule<string> = rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
|
export const IsUppercase: Rule<string> = /*#__PURE__*/ rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
|
||||||
export const IsNumberString: Rule<string> = rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`);
|
export const IsNumberString: Rule<string> = /*#__PURE__*/ rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`);
|
||||||
export const IsDateString: Rule<string> = rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`);
|
export const IsDateString: Rule<string> = /*#__PURE__*/ rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`);
|
||||||
export const IsJSON: Rule<string> = rule('isJson', v => {
|
export const IsJSON: Rule<string> = /*#__PURE__*/ rule('isJson', v => {
|
||||||
if (typeof v !== 'string') return false;
|
if (typeof v !== 'string') return false;
|
||||||
try { JSON.parse(v); return true; } catch { return false; }
|
try { JSON.parse(v); return true; } catch { return false; }
|
||||||
}, p => `${p} must be a JSON string`);
|
}, p => `${p} must be a JSON string`);
|
||||||
|
|
||||||
export const IsUrl: Rule<string> = rule('isUrl', v => {
|
export const IsUrl: Rule<string> = /*#__PURE__*/ rule('isUrl', v => {
|
||||||
try { new URL(v as string); return true; } catch { return false; }
|
try { new URL(v as string); return true; } catch { return false; }
|
||||||
}, p => `${p} must be a valid URL`);
|
}, p => `${p} must be a valid URL`);
|
||||||
|
|
||||||
@@ -449,10 +449,10 @@ function affix(name: string, test: (value: string, seed: string) => boolean, des
|
|||||||
return decorator;
|
return decorator;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Contains = affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`);
|
export const Contains = /*#__PURE__*/ affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`);
|
||||||
export const NotContains = affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`);
|
export const NotContains = /*#__PURE__*/ affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`);
|
||||||
export const StartsWith = affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`);
|
export const StartsWith = /*#__PURE__*/ affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`);
|
||||||
export const EndsWith = affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`);
|
export const EndsWith = /*#__PURE__*/ affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Equality and membership
|
// Equality and membership
|
||||||
|
|||||||
+7
-2
@@ -15,8 +15,13 @@ import type { ClassConstructor } from './interfaces.js';
|
|||||||
*/
|
*/
|
||||||
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
|
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
|
||||||
|
|
||||||
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata`
|
// Also installed globally: tsc's decorator emit reads `Symbol.metadata` directly rather than
|
||||||
// directly. package.json marks this module as having side effects so it survives bundling.
|
// falling back the way we do — `typeof Symbol === "function" && Symbol.metadata ? … : void 0` —
|
||||||
|
// so without this a decorated class gets `metadata: undefined` and no rules at all.
|
||||||
|
//
|
||||||
|
// `sideEffects` in package.json keeps the statement through bundling, and must name `index.*`
|
||||||
|
// as well as this module: marking only this one leaves the barrel droppable, so the edge to it
|
||||||
|
// is pruned before this marking is ever read. Pinned by treeshake.test.ts.
|
||||||
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
|
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
|
||||||
|
|
||||||
export interface ValidationArguments {
|
export interface ValidationArguments {
|
||||||
|
|||||||
@@ -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
|
* next steps in a pollution chain. This library exists to parse request bodies, so the
|
||||||
* transform layer drops them rather than trusting callers to sanitise first.
|
* transform layer drops them rather than trusting callers to sanitise first.
|
||||||
*/
|
*/
|
||||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
const FORBIDDEN_KEYS = /*#__PURE__*/ new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stands in for the value of a property that is never serialized, so that a failing password
|
* Stands in for the value of a property that is never serialized, so that a failing password
|
||||||
@@ -96,7 +96,7 @@ interface OutboundProperty {
|
|||||||
serializer?: any;
|
serializer?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
const outboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
||||||
@@ -167,7 +167,7 @@ interface InboundNames {
|
|||||||
|
|
||||||
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
||||||
// declared, so they are cached per (prototype, naming strategy).
|
// declared, so they are cached per (prototype, naming strategy).
|
||||||
const inboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
const inboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
||||||
@@ -614,7 +614,7 @@ interface CachedPlan {
|
|||||||
// Turning a class model into a per-property plan is cheap, but doing it on every call was
|
// Turning a class model into a per-property plan is cheap, but doing it on every call was
|
||||||
// measurably not: profiling showed roughly half of all validation time re-deriving answers
|
// measurably not: profiling showed roughly half of all validation time re-deriving answers
|
||||||
// that cannot change. Plans are memoized per model and invalidated by the model version.
|
// that cannot change. Plans are memoized per model and invalidated by the model version.
|
||||||
const planCache = new WeakMap<ClassModel, CachedPlan>();
|
const planCache = /*#__PURE__*/ new WeakMap<ClassModel, CachedPlan>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapses rules that are genuinely identical.
|
* Collapses rules that are genuinely identical.
|
||||||
@@ -663,7 +663,7 @@ function validationPlan(model: ClassModel): PropertyPlan[] {
|
|||||||
|
|
||||||
// Serializers and deserializers are stateless by contract, so one instance per class is
|
// Serializers and deserializers are stateless by contract, so one instance per class is
|
||||||
// enough. Constructing a fresh one for every property of every object was pure waste.
|
// enough. Constructing a fresh one for every property of every object was pure waste.
|
||||||
const converterCache = new WeakMap<object, any>();
|
const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
|
||||||
|
|
||||||
function converterFor(clazz: any): any {
|
function converterFor(clazz: any): any {
|
||||||
let instance = converterCache.get(clazz);
|
let instance = converterCache.get(clazz);
|
||||||
|
|||||||
+4
-10
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
// Visit https://aka.ms/tsconfig to read more about this file
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
// Shared (@avalon-vanguard/config/tsconfig/library.json): sourceMap, declaration,
|
||||||
|
// declarationMap, noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch,
|
||||||
|
// isolatedModules, skipLibCheck.
|
||||||
|
"extends": "@avalon-vanguard/config/tsconfig/library.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// File Layout
|
// File Layout
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
@@ -11,27 +15,17 @@
|
|||||||
"lib": ["ESNext", "ESNext.Decorators"],
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|
||||||
// Other Outputs
|
|
||||||
"sourceMap": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
|
|
||||||
// Stricter Typechecking Options
|
// Stricter Typechecking Options
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
|
|
||||||
// Style Options
|
// Style Options
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
|
|
||||||
// Recommended Options
|
// Recommended Options
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
"isolatedModules": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
// NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what
|
// NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what
|
||||||
// gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of
|
// gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of
|
||||||
|
|||||||
Reference in New Issue
Block a user