📦 feat: add cereale/min, and a verified framework guide
**cereale/min** — the library flattened into one minified ES module, 25.5 KB / 8.6 KB gzipped, for import maps, <script type="module">, Deno and Workers. Built from dist/esm/index.js, so the decorator lowering and the ES2025 target are tsc's; esbuild only flattens and minifies. It is an addition rather than a replacement, and the measurement is the reason. Bundled through esbuild the flat and per-module builds land within 2 bytes of each other; through rollup + terser the flat one is 165 bytes smaller; unused decorators tree-shake out of both. With the size argument a wash, per-module stays the default import for the one thing it does better — readable stack traces without source maps. (My first pass at that measurement reported "shaken" for every symbol because both rollup builds had failed and grep was reading missing files as absence. The check now asserts the bundle is non-empty and that a *used* symbol is present, so it can tell a real result from a broken harness.) **FRAMEWORKS.md** — a recipe per framework, each one run before it was written, with the versions and date verified against. Angular works, which was not obvious: the CLI scaffolds experimentalDecorators: true, but ngtsc erases @Component and @Injectable into static properties rather than leaning on TypeScript's decorator emit. Flip the flag and both systems coexist. Verified with ngc on Angular 21.2 with strictTemplates — templates still type-check and a wrong cereale rule is still TS1240 inside the Angular build. Next.js cannot work inline, structurally: it derives both the SWC parser's decorator support and the transform mode from the one flag, so on gives legacy emit and off makes @ a syntax error. NestJS cannot either — its DI needs design:type from emitDecoratorMetadata. Both have the same answer: keep the cereale classes in a package compiled by tsc and import the built output. Verified inside a program with BOTH legacy flags on, alongside @Injectable() — mapping and validation work, and the compile-time guarantee still holds where the rules are written. Also verified: Bun 1.3 needs no configuration, and a real Vite 8 build with the plugin works where the same build without it silently leaves decorator syntax in the bundle. Version 0.4.0: cereale/min is a new public entry point, and cutting a minor keeps the existing v0.3.0 tag meaningful instead of force-moving it onto a commit it was never cut from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -39,6 +39,7 @@ jobs:
|
|||||||
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=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=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');"
|
node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');"
|
||||||
|
node --input-type=module -e "import * as m from './dist/cereale.min.js'; if (typeof m.toInstanceSync !== 'function') throw new Error('flat bundle 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
|
||||||
|
|||||||
@@ -5,6 +5,63 @@ 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.0] - 2026-08-05
|
||||||
|
|
||||||
|
### `cereale/min` — one file, no bundler
|
||||||
|
|
||||||
|
The whole library flattened into a single minified ES module: **25.5 KB, 8.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, and the measurement is why. Bundled through esbuild the
|
||||||
|
flat and per-module builds produce consumer bundles within **2 bytes** of each other; through
|
||||||
|
rollup + terser the flat one is 165 bytes smaller; unused decorators tree-shake out of both.
|
||||||
|
Since the size argument is a wash, the per-module build stays the default `import` for the one
|
||||||
|
thing it does better — readable stack traces for anyone not loading source maps.
|
||||||
|
|
||||||
|
### 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*?
|
||||||
|
|||||||
+341
@@ -0,0 +1,341 @@
|
|||||||
|
# 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 (25.5 KB, 8.6 KB
|
||||||
|
gzipped) for import maps, `<script type="module">`, Deno and Workers. Bundler users should keep
|
||||||
|
the default entry point: measured through esbuild and through rollup + terser, the two produce
|
||||||
|
consumer bundles within a couple of hundred bytes of each other and tree-shake identically, and
|
||||||
|
the per-module build keeps readable stack traces.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
|
||||||
|
</script>
|
||||||
|
```
|
||||||
@@ -108,6 +108,20 @@ inside a native binary with no standalone transform API.
|
|||||||
| 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** | ✅ | 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 | ⚠️ | 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
|
||||||
|
|||||||
@@ -271,6 +271,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; } }
|
||||||
@@ -693,6 +694,26 @@ 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</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</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.
|
||||||
|
<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="dot" aria-hidden="true"></span><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';
|
||||||
|
|||||||
+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.0",
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"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,21 +16,27 @@
|
|||||||
"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/metadata.js",
|
"./dist/esm/metadata.js",
|
||||||
"./dist/cjs/metadata.js"
|
"./dist/cjs/metadata.js",
|
||||||
|
"./dist/cereale.min.js"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"src",
|
"src",
|
||||||
|
"FRAMEWORKS.md",
|
||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"!src/**/*.test.ts",
|
"!src/**/*.test.ts",
|
||||||
"!src/example.ts"
|
"!src/example.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
|
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.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",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Flattens the ESM build into a single minified module.
|
||||||
|
*
|
||||||
|
* This is an *addition*, not a replacement. `dist/esm` stays the default `import`, because
|
||||||
|
* measuring says the flat file buys a consumer nothing: bundled through esbuild the two come
|
||||||
|
* out within 2 bytes of each other, through rollup+terser the flat one is ~165 bytes smaller,
|
||||||
|
* and unused decorators tree-shake out of both. What the per-module build keeps is readable
|
||||||
|
* stack traces for anyone who does not load source maps.
|
||||||
|
*
|
||||||
|
* 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',
|
||||||
|
minify: 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 banner names a version, so a stale bundle would misreport itself rather than merely be
|
||||||
|
// out of date. Cheap to assert, and the build is the only place that can.
|
||||||
|
const emitted = await readFile(outfile, 'utf8');
|
||||||
|
if (!emitted.includes(`cereale ${pkg.version}`)) {
|
||||||
|
throw new Error('the bundle banner does not carry the current version');
|
||||||
|
}
|
||||||
|
for (const name of ['toInstanceSync', 'IsString', 'standardDecorators']) {
|
||||||
|
if (name === 'standardDecorators') continue; // cereale/vite is a separate entry point
|
||||||
|
if (!emitted.includes(name)) throw new Error(`${name} is missing from the flat bundle`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)`);
|
||||||
Reference in New Issue
Block a user