Nine review angles, fourteen verified findings, all but the byte-table generator applied. The two that mattered most were regressions of mine: - a:hover repainted button-styled anchors, and in dark theme --accent-text equals --accent-solid — hovering the hero CTA drew its label in its own background colour. Verified invisible before (computed color == computed background) and distinct after: 10.39:1 dark, 6.90:1 light. The buttons and the skip link now re-assert their label colours on hover. - Footer links had lost every non-hover affordance: the text-decoration:none carve-out plus body-coloured links left a 2.37:1 shade difference as the only cue. The carve-out is deleted — .nav-links a and .brand already declare none themselves — and the accent rule is back on the footer. Also on the page: #ref-filter, the one text input, moves to --border-ui (it still had the 1.68:1 border the token's own comment calls decorative); focusable code surfaces get the --accent-on-code ring at -2px offset, inside the .code overflow clip; #output .out-err drops #ff8095, the last surviving colour of the deleted indigo palette; the two rgba(255,106,126) washes become color-mix over --err-line so a grep for the token finds them. The theme machinery loses a whole block: the dark media query is guarded with :not([data-theme="light"]), so an explicit light toggle falls through to the bare :root palette and the 21-token hand-copy in [data-theme="light"] is gone. Verified in all four system/toggle combinations. The page stops contradicting the repo: it claimed cereale/min "cannot tree-shake — nothing left to shake" while src/treeshake.test.ts proves the opposite on every run. Corrected here and in FRAMEWORKS.md, with the measured figures (1,837 vs 1,996 bytes for one decorator). The release facts the page was hand-bumping — both tgz names, "0.4.0 lives in the repository", the sixty-eight — now fill from meta.js/the bundle like the version badge always has. The workflows close three holes: - The docs sync gate was blind to NEW untracked build outputs (git diff does not report them; demonstrated). Both workflows now run check:docs-sync, one shared script that fails on anything porcelain reports — which also ends the copy-paste divergence between them. - pages.yml deploys on CI succeeding on main (workflow_run) instead of on the push itself, so a deploy implies green tests, not just in-sync docs. The deploy job refuses refs other than main, closing the workflow_dispatch any-branch deploy, and the build job drops pages/id-token — npm postinstall scripts no longer run alongside an OIDC grant. - The Junie action is pinned to the commit behind v1.7.4 rather than the tag, which is the immutability the previous comment promised but a mutable ref cannot deliver. Verified: every fix confirmed in a rendered browser in both themes; 72 contrast pairs still pass; no overflow at 20 widths; 268 tests, build, check:types, check:docs, actionlint all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
345 lines
12 KiB
Markdown
345 lines
12 KiB
Markdown
# 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>
|
|
```
|