🔊 feat!: make the three silent failures loud
Every change here answers one question: where does cereale currently fail
without saying so?
**Vite 8 / Vitest 4 drop decorators silently.** Both transform with oxc,
which does not implement the standard decorator transform and does not
report that. `vitest` prints "0 test" beside a bare SyntaxError, and
`vite build` reports success while emitting a bundle that throws on first
import. Ship the plugin that fixes it as `cereale/vite`, transforming with
esbuild and falling back to tsc — cereale depends on neither. The library's
own suite now runs through it, so it is exercised by every test.
**Legacy decorators died opaquely.** With `experimentalDecorators: true`,
still the default in most existing TypeScript projects, decorators are
invoked as (prototype, "name") and cereale raised "TypeError: Cannot convert
undefined or null to object". All decorators now resolve metadata through
one checkpoint that names the tsconfig setting instead, and reject
application to a method, getter or accessor field.
**Values JSON cannot carry were emptied.** A populated Map serialized to
{}, a Uint8Array to index-keyed noise, a bigint straight through so the
caller's own JSON.stringify threw somewhere unrelated. All now raise
JsonMappingError naming the property path and both ways out. Covers what a
@JsonSerialize serializer returns, sync or async. Circular-reference and
depth errors name the path too.
Also fixed: defineRule on a subclass with no decorators of its own wrote
the rule into its base class, because the base's metadata object is
inherited through the static prototype chain and `??=` found it non-nullish.
The README's toolchain table (tsc, esbuild, swc ✅, oxc ❌) is now executed
by a test rather than asserted, and the positioning leads with
class-validator + class-transformer, the stack cereale actually replaces,
rather than Zod, which it deliberately is not.
193 -> 249 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -37,5 +37,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');"
|
node --input-type=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=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
|
||||||
|
|||||||
@@ -5,6 +5,90 @@ 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.3.0] - 2026-08-05
|
||||||
|
|
||||||
|
Every change here comes from the same question: where does cereale currently fail *quietly*?
|
||||||
|
Three answers, each of which cost a real user nothing to hit and everything to diagnose.
|
||||||
|
|
||||||
|
### Vite 8 and Vitest 4 silently drop decorators — `cereale/vite`
|
||||||
|
|
||||||
|
Both transform TypeScript with oxc, which does not implement the standard decorator transform
|
||||||
|
and does not say so. It leaves the syntax in the output, so:
|
||||||
|
|
||||||
|
- `vitest` reports `0 test` next to a bare `SyntaxError`
|
||||||
|
- `vite build` reports **success**, having emitted a bundle that throws the moment it is imported
|
||||||
|
|
||||||
|
Cereale now ships the plugin that fixes it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vite.config.ts / vitest.config.ts
|
||||||
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [standardDecorators()] });
|
||||||
|
```
|
||||||
|
|
||||||
|
It transforms with esbuild, falling back to the TypeScript compiler; cereale depends on
|
||||||
|
neither, and says which to install if somehow neither is present. Options: `include`,
|
||||||
|
`target`, and `transformer` to pin one deliberately. The library's own test suite runs
|
||||||
|
through it, so it is exercised by every test rather than by one test about itself.
|
||||||
|
|
||||||
|
### Legacy decorators now say so
|
||||||
|
|
||||||
|
With `experimentalDecorators: true` — still the default in most existing TypeScript projects,
|
||||||
|
because class-validator required it — decorators are invoked as `(prototype, "name")` and
|
||||||
|
cereale died with `TypeError: Cannot convert undefined or null to object`, which names neither
|
||||||
|
the cause nor the fix. Every decorator now resolves its metadata through one checkpoint that
|
||||||
|
raises an error naming the tsconfig setting instead. The same checkpoint rejects application
|
||||||
|
to a method, getter or `accessor` field, all of which previously recorded metadata that
|
||||||
|
nothing would ever read.
|
||||||
|
|
||||||
|
### Values JSON cannot carry are refused, not emptied
|
||||||
|
|
||||||
|
A populated `Map` serialized to `{}`. A `Set` serialized to `{}`. A `Uint8Array` to
|
||||||
|
`{"0":1,"1":2}`. A `bigint` passed straight through, so the caller's own `JSON.stringify`
|
||||||
|
threw somewhere unrelated. `RegExp`, `Error`, `Promise`, `WeakMap`, `DataView`, symbols and
|
||||||
|
functions all had their own version of the same failure. All of them now raise a
|
||||||
|
`JsonMappingError` that names the property path and the two ways out:
|
||||||
|
|
||||||
|
```
|
||||||
|
JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON.
|
||||||
|
Give the property a @JsonSerialize() serializer that converts it, or drop it from the
|
||||||
|
output with @JsonIgnore().
|
||||||
|
```
|
||||||
|
|
||||||
|
The check also covers what a `@JsonSerialize` serializer hands back, sync or async. This is
|
||||||
|
**breaking** for anyone relying on the old behaviour, though "relying on" is a strong word for
|
||||||
|
losing data without being told.
|
||||||
|
|
||||||
|
Circular-reference and depth-limit errors now name the path too (`at child.parent`), which
|
||||||
|
came free with the bookkeeping.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `defineRule` on a subclass with no decorators of its own wrote the rule into its **base
|
||||||
|
class**, because the base's metadata object is inherited through the static prototype chain
|
||||||
|
and `??=` found it non-nullish. Every sibling subclass then inherited a rule meant for one
|
||||||
|
of them.
|
||||||
|
- The plugin's TypeScript path emitted a `//# sourceMappingURL=` comment pointing at a file
|
||||||
|
nobody wrote, which Vite followed and failed to read on every transformed module.
|
||||||
|
|
||||||
|
### Positioning
|
||||||
|
|
||||||
|
`zod-alternative` is out of the keywords, and the README leads with the comparison that
|
||||||
|
actually applies: cereale replaces **class-validator + class-transformer**. It does not infer
|
||||||
|
types from schemas, and framing it against Zod invited exactly the objection that it is
|
||||||
|
missing `z.infer` — which is a different design, not a gap.
|
||||||
|
|
||||||
|
The README's toolchain support table (`tsc`, esbuild, swc ✅, oxc ❌) is now
|
||||||
|
[executed by a test](src/toolchain.test.ts): each row compiles a decorated class with that
|
||||||
|
tool and asserts the metadata arrived, so the table cannot quietly go stale.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
Serialization is a few percent slower for the representability check. Primitives are handled
|
||||||
|
inline, and arrays and dates skip it, so it costs one `Symbol.toStringTag` read per object.
|
||||||
|
Validation is unchanged.
|
||||||
|
|
||||||
## [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
|
||||||
|
|||||||
@@ -25,22 +25,30 @@ const user = fromJsonSync(User, body); // a real User
|
|||||||
user.greet(); // your methods are still there
|
user.greet(); // your methods are still there
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why not Zod?
|
## Where it fits
|
||||||
|
|
||||||
Zod is excellent, and if a plain validated object is what you want, use it. The difference is
|
The stack Cereale replaces is **class-validator + class-transformer**:
|
||||||
what you get back:
|
|
||||||
|
| | class-validator + class-transformer | Cereale |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Packages to install | 2, plus `reflect-metadata` | 1, no runtime dependencies |
|
||||||
|
| Decorators | legacy (`experimentalDecorators`) | TC39 standard |
|
||||||
|
| Rules checked against the field | no — `@IsInt() name: string` compiles | **yes, at compile time** |
|
||||||
|
| Mapping and validation | two libraries that must agree | one model |
|
||||||
|
|
||||||
|
The comparison people ask about is **Zod**, and it is worth being precise about, because
|
||||||
|
Cereale is not a drop-in for it:
|
||||||
|
|
||||||
| | Zod | Cereale |
|
| | Zod | Cereale |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Result of parsing | an anonymous object matching a schema | an instance of **your class** |
|
| Result of parsing | an anonymous object matching a schema | an instance of **your class** |
|
||||||
| Methods, getters, inheritance | none — data only | preserved |
|
| Methods, getters, inheritance | none — data only | preserved |
|
||||||
| Where the type comes from | inferred from the schema | your class declaration |
|
| Where the type comes from | inferred from the schema | your class declaration |
|
||||||
| Rules checked against the type | not applicable — schema *is* the type | **yes, at compile time** |
|
|
||||||
| Bidirectional mapping (renaming both ways) | not the focus | first-class |
|
| Bidirectional mapping (renaming both ways) | not the focus | first-class |
|
||||||
|
|
||||||
Cereale does not infer your type from a schema, so you still write the field type and the rule.
|
Cereale does **not** infer your type from a schema. You write the field type and the rule, and
|
||||||
What it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not
|
what it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not
|
||||||
compile. That is the guarantee class-validator has never offered.
|
compile. If you want `z.infer`, you want Zod; that is a different design, not a missing feature.
|
||||||
|
|
||||||
Reach for Cereale when your domain model is already a class — a NestJS provider, a TypeORM
|
Reach for Cereale when your domain model is already a class — a NestJS provider, a TypeORM
|
||||||
entity, anything with behaviour attached. Reach for Zod when you just want the data.
|
entity, anything with behaviour attached. Reach for Zod when you just want the data.
|
||||||
@@ -51,6 +59,8 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d
|
|||||||
- **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes.
|
- **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes.
|
||||||
- **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions.
|
- **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions.
|
||||||
- **Access control:** keep passwords out of responses and server-owned ids out of requests.
|
- **Access control:** keep passwords out of responses and server-owned ids out of requests.
|
||||||
|
- **Nothing fails quietly:** a misconfigured compiler, a cycle, or a value JSON cannot carry
|
||||||
|
raises an error that names the cause — never an empty object.
|
||||||
- **Sync and async:** every entry point has a synchronous twin.
|
- **Sync and async:** every entry point has a synchronous twin.
|
||||||
- **Zero dependencies**, ESM + CJS, Node 20+.
|
- **Zero dependencies**, ESM + CJS, Node 20+.
|
||||||
|
|
||||||
@@ -60,7 +70,7 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d
|
|||||||
npm install cereale
|
npm install cereale
|
||||||
```
|
```
|
||||||
|
|
||||||
Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` flag:
|
Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -74,10 +84,44 @@ Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` fla
|
|||||||
Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and
|
Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and
|
||||||
`emitDecoratorMetadata` is not read.
|
`emitDecoratorMetadata` is not read.
|
||||||
|
|
||||||
> **Toolchain note.** Standard decorators are transformed by `tsc` and by esbuild (so Vite
|
`experimentalDecorators` must be **off**. The two decorator systems cannot coexist in one
|
||||||
> works). They are **not** yet transformed by oxc; if your toolchain uses it, decorator syntax
|
program, so a project that still needs legacy decorators for another library cannot use
|
||||||
> will fail to parse. The 0.1.x line, which uses legacy decorators, remains available for
|
Cereale yet. If yours is configured for them, you get an error saying exactly that rather
|
||||||
> those setups.
|
than a `TypeError` from somewhere inside the engine.
|
||||||
|
|
||||||
|
### Toolchain support
|
||||||
|
|
||||||
|
Whether Cereale works at all depends on your compiler emitting standard decorators, so this
|
||||||
|
table is [checked by a test](src/toolchain.test.ts) rather than asserted here:
|
||||||
|
|
||||||
|
| Transformer | Status | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tsc` | ✅ | With `experimentalDecorators: false` and `target: ES2022`+ |
|
||||||
|
| esbuild | ✅ | Same settings via `tsconfigRaw` |
|
||||||
|
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
||||||
|
| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below |
|
||||||
|
|
||||||
|
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
|
||||||
|
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
|
||||||
|
reports success while emitting a bundle that throws the moment it is imported. Cereale ships
|
||||||
|
the plugin that fixes it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vite.config.ts / vitest.config.ts
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [standardDecorators()],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
It transforms `.ts`, `.mts` and `.cts` outside `node_modules` with esbuild, falling back to
|
||||||
|
the TypeScript compiler if esbuild is not installed — Cereale depends on neither. Pass
|
||||||
|
`include` to widen or narrow the set (decorated classes in `.tsx` files need this),
|
||||||
|
`transformer: 'esbuild' | 'typescript'` to pin one, or `target` to change the output level
|
||||||
|
from the default `es2022`. Nothing in the plugin is specific to Cereale; delete it once oxc
|
||||||
|
implements the transform.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -424,6 +468,11 @@ against `JSON.parse` + `JSON.stringify` (5.8 us) on the same machine:
|
|||||||
If you validate at the edge and map internally afterwards, `{ validate: false }` skips the
|
If you validate at the edge and map internally afterwards, `{ validate: false }` skips the
|
||||||
dominant cost.
|
dominant cost.
|
||||||
|
|
||||||
|
Serialization also checks every value it walks against the set JSON cannot represent. That
|
||||||
|
costs a few percent on `toPlain`, which is the price of never emitting `{}` where a `Map`
|
||||||
|
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.
|
||||||
|
|
||||||
## 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;
|
||||||
@@ -431,10 +480,24 @@ dominant cost.
|
|||||||
model, not this one.
|
model, not this one.
|
||||||
- **`abstract` fields cannot be decorated.** Standard decorators do not apply to abstract
|
- **`abstract` fields cannot be decorated.** Standard decorators do not apply to abstract
|
||||||
members. Declare the field concretely in the base class instead.
|
members. Declare the field concretely in the base class instead.
|
||||||
- **oxc does not transform standard decorators yet.** `tsc` and esbuild do.
|
- **`accessor` fields cannot be decorated.** Their value lives in a private slot that mapping
|
||||||
|
and validation cannot reach. Applying a decorator to one is an error, not a silent no-op.
|
||||||
|
- **oxc does not transform standard decorators yet.** `tsc`, esbuild and swc do — see
|
||||||
|
[Toolchain support](#toolchain-support) for the Vite/Vitest plugin.
|
||||||
|
- **Values JSON cannot carry are rejected**, not quietly dropped. `Map`, `Set`, `RegExp`,
|
||||||
|
`Error`, typed arrays, `bigint`, `symbol` and functions all raise a `JsonMappingError` naming
|
||||||
|
the property path:
|
||||||
|
|
||||||
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
```
|
||||||
the cycle with `@JsonIgnore()` on the back-reference.
|
JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON.
|
||||||
|
Give the property a @JsonSerialize() serializer that converts it, or drop it from the
|
||||||
|
output with @JsonIgnore().
|
||||||
|
```
|
||||||
|
|
||||||
|
Serializing a populated `Map` to `{}` and returning success is the failure mode this
|
||||||
|
library exists to prevent, so it does not do it either.
|
||||||
|
- **Circular references** are rejected during serialization with a `JsonMappingError` that
|
||||||
|
names where the cycle closed. Break it with `@JsonIgnore()` on the back-reference.
|
||||||
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
|
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
|
||||||
the instance you get back from `toInstance`, not the raw payload.
|
the instance you get back from `toInstance`, not the raw payload.
|
||||||
- **Renaming is not backwards-compatible by itself.** Once a property carries
|
- **Renaming is not backwards-compatible by itself.** Once a property carries
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
Generated
+265
-2
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@swc/core": "^1.15.47",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vitest/coverage-v8": "^4.1.4",
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -1034,6 +1035,268 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.27"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.15.47",
|
||||||
|
"@swc/core-darwin-x64": "1.15.47",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.15.47",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.15.47",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.15.47",
|
||||||
|
"@swc/core-linux-ppc64-gnu": "1.15.47",
|
||||||
|
"@swc/core-linux-s390x-gnu": "1.15.47",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.15.47",
|
||||||
|
"@swc/core-linux-x64-musl": "1.15.47",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.15.47",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.15.47",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.15.47"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
|
"version": "1.15.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz",
|
||||||
|
"integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@swc/types": {
|
||||||
|
"version": "0.1.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
|
||||||
|
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/node10": {
|
"node_modules/@tsconfig/node10": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||||
|
|||||||
+12
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data",
|
"description": "Strongly typed JSON mapping and validation for TypeScript classes — 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",
|
||||||
"module": "./dist/esm/index.js",
|
"module": "./dist/esm/index.js",
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
"types": "./dist/esm/index.d.ts",
|
"types": "./dist/esm/index.d.ts",
|
||||||
"import": "./dist/esm/index.js",
|
"import": "./dist/esm/index.js",
|
||||||
"require": "./dist/cjs/index.js"
|
"require": "./dist/cjs/index.js"
|
||||||
|
},
|
||||||
|
"./vite": {
|
||||||
|
"types": "./dist/esm/vite.d.ts",
|
||||||
|
"import": "./dist/esm/vite.js",
|
||||||
|
"require": "./dist/cjs/vite.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
@@ -44,11 +49,13 @@
|
|||||||
"json",
|
"json",
|
||||||
"validation",
|
"validation",
|
||||||
"decorators",
|
"decorators",
|
||||||
|
"standard-decorators",
|
||||||
"typescript",
|
"typescript",
|
||||||
"zod-alternative",
|
|
||||||
"dto",
|
"dto",
|
||||||
"serialization",
|
"serialization",
|
||||||
"class-validator"
|
"class-validator",
|
||||||
|
"class-transformer",
|
||||||
|
"class-validator-alternative"
|
||||||
],
|
],
|
||||||
"author": "Avalon Vanguard",
|
"author": "Avalon Vanguard",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -58,6 +65,7 @@
|
|||||||
"homepage": "https://github.com/Avalon-Vanguard/cereale#readme",
|
"homepage": "https://github.com/Avalon-Vanguard/cereale#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@swc/core": "^1.15.47",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vitest/coverage-v8": "^4.1.4",
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
|||||||
+15
-14
@@ -2,7 +2,7 @@ import type {
|
|||||||
ClassConstructor, FieldDecorator, JsonDeserializer, JsonSerializer,
|
ClassConstructor, FieldDecorator, JsonDeserializer, JsonSerializer,
|
||||||
} from './interfaces.js';
|
} from './interfaces.js';
|
||||||
import {
|
import {
|
||||||
addConstraint, propertyModel,
|
addConstraint, fieldMetadata, propertyModel,
|
||||||
type EachValidationOptions, type PolymorphicInfo, type ValidationArguments,
|
type EachValidationOptions, type PolymorphicInfo, type ValidationArguments,
|
||||||
type ValidationConstraint, type ValidationOptions, type ValidatorConstraintInterface,
|
type ValidationConstraint, type ValidationOptions, type ValidatorConstraintInterface,
|
||||||
} from './metadata.js';
|
} from './metadata.js';
|
||||||
@@ -35,7 +35,7 @@ function decorate(
|
|||||||
): FieldDecorator<unknown> {
|
): FieldDecorator<unknown> {
|
||||||
return ((_target: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_target: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
const property = String(context.name);
|
const property = String(context.name);
|
||||||
addConstraint(context.metadata, property, build(property), options);
|
addConstraint(fieldMetadata(context), property, build(property), options);
|
||||||
}) as FieldDecorator<unknown>;
|
}) as FieldDecorator<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ function pattern(name: string, regex: RegExp, message: (property: string) => str
|
|||||||
*/
|
*/
|
||||||
export function JsonProperty(name: string): FieldDecorator<unknown> {
|
export function JsonProperty(name: string): FieldDecorator<unknown> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name)).name = name;
|
propertyModel(fieldMetadata(context), String(context.name)).name = name;
|
||||||
}) as FieldDecorator<unknown>;
|
}) as FieldDecorator<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +84,14 @@ export function JsonProperty(name: string): FieldDecorator<unknown> {
|
|||||||
*/
|
*/
|
||||||
export function JsonAlias(...names: string[]): FieldDecorator<unknown> {
|
export function JsonAlias(...names: string[]): FieldDecorator<unknown> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
const model = propertyModel(context.metadata, String(context.name));
|
const model = propertyModel(fieldMetadata(context), String(context.name));
|
||||||
model.aliases = [...(model.aliases ?? []), ...names];
|
model.aliases = [...(model.aliases ?? []), ...names];
|
||||||
}) as FieldDecorator<unknown>;
|
}) as FieldDecorator<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function access(value: 'none' | 'readonly' | 'writeonly'): FieldDecorator<unknown> {
|
function access(value: 'none' | 'readonly' | 'writeonly'): FieldDecorator<unknown> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name)).access = value;
|
propertyModel(fieldMetadata(context), String(context.name)).access = value;
|
||||||
}) as FieldDecorator<unknown>;
|
}) as FieldDecorator<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export function JsonSerialize<T>(
|
|||||||
serializer: ClassConstructor<JsonSerializer<T, any>>
|
serializer: ClassConstructor<JsonSerializer<T, any>>
|
||||||
): FieldDecorator<T | null | undefined> {
|
): FieldDecorator<T | null | undefined> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name)).serializer = serializer;
|
propertyModel(fieldMetadata(context), String(context.name)).serializer = serializer;
|
||||||
}) as FieldDecorator<T | null | undefined>;
|
}) as FieldDecorator<T | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ export function JsonDeserialize<R>(
|
|||||||
deserializer: ClassConstructor<JsonDeserializer<any, R>>
|
deserializer: ClassConstructor<JsonDeserializer<any, R>>
|
||||||
): FieldDecorator<R | null | undefined> {
|
): FieldDecorator<R | null | undefined> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name)).deserializer = deserializer;
|
propertyModel(fieldMetadata(context), String(context.name)).deserializer = deserializer;
|
||||||
}) as FieldDecorator<R | null | undefined>;
|
}) as FieldDecorator<R | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ export function JsonType<T extends object>(
|
|||||||
typeFunction: () => ClassConstructor<T>
|
typeFunction: () => ClassConstructor<T>
|
||||||
): FieldDecorator<T | readonly T[] | null | undefined> {
|
): FieldDecorator<T | readonly T[] | null | undefined> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name)).type = typeFunction;
|
propertyModel(fieldMetadata(context), String(context.name)).type = typeFunction;
|
||||||
}) as FieldDecorator<T | readonly T[] | null | undefined>;
|
}) as FieldDecorator<T | readonly T[] | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ export function JsonPolymorphic<Base extends object = object>(
|
|||||||
onUnknown: options?.onUnknown ?? 'keep',
|
onUnknown: options?.onUnknown ?? 'keep',
|
||||||
...(options?.fallback ? { fallback: options.fallback as ClassConstructor<any> } : {}),
|
...(options?.fallback ? { fallback: options.fallback as ClassConstructor<any> } : {}),
|
||||||
};
|
};
|
||||||
propertyModel(context.metadata, String(context.name)).polymorphic = info;
|
propertyModel(fieldMetadata(context), String(context.name)).polymorphic = info;
|
||||||
}) as FieldDecorator<Base | readonly Base[] | null | undefined>;
|
}) as FieldDecorator<Base | readonly Base[] | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ export function JsonPolymorphic<Base extends object = object>(
|
|||||||
/** Skips every other rule on this field when the value is `null` or `undefined`. */
|
/** Skips every other rule on this field when the value is `null` or `undefined`. */
|
||||||
export function IsOptional(): FieldDecorator<unknown> {
|
export function IsOptional(): FieldDecorator<unknown> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name)).optional = true;
|
propertyModel(fieldMetadata(context), String(context.name)).optional = true;
|
||||||
}) as FieldDecorator<unknown>;
|
}) as FieldDecorator<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ export function IsOptional(): FieldDecorator<unknown> {
|
|||||||
*/
|
*/
|
||||||
export function ValidateIf<This>(condition: (object: This) => boolean): FieldDecorator<unknown> {
|
export function ValidateIf<This>(condition: (object: This) => boolean): FieldDecorator<unknown> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name)).condition = condition as (o: any) => boolean;
|
propertyModel(fieldMetadata(context), String(context.name)).condition = condition as (o: any) => boolean;
|
||||||
}) as FieldDecorator<unknown>;
|
}) as FieldDecorator<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ export function ValidateIf<This>(condition: (object: This) => boolean): FieldDec
|
|||||||
*/
|
*/
|
||||||
export function Allow(): FieldDecorator<unknown> {
|
export function Allow(): FieldDecorator<unknown> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
propertyModel(context.metadata, String(context.name));
|
propertyModel(fieldMetadata(context), String(context.name));
|
||||||
}) as FieldDecorator<unknown>;
|
}) as FieldDecorator<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,10 +244,11 @@ export function Allow(): FieldDecorator<unknown> {
|
|||||||
*/
|
*/
|
||||||
export function ValidateNested(options?: ValidationOptions): FieldDecorator<object | readonly unknown[] | null | undefined> {
|
export function ValidateNested(options?: ValidationOptions): FieldDecorator<object | readonly unknown[] | null | undefined> {
|
||||||
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
|
const metadata = fieldMetadata(context);
|
||||||
const property = String(context.name);
|
const property = String(context.name);
|
||||||
propertyModel(context.metadata, property).nested = true;
|
propertyModel(metadata, property).nested = true;
|
||||||
if (options?.each) {
|
if (options?.each) {
|
||||||
addConstraint(context.metadata, property, {
|
addConstraint(metadata, property, {
|
||||||
name: 'nestedEach',
|
name: 'nestedEach',
|
||||||
validate: v => Array.isArray(v),
|
validate: v => Array.isArray(v),
|
||||||
message: `${property} must be an array`,
|
message: `${property} must be an array`,
|
||||||
|
|||||||
@@ -219,3 +219,44 @@ describe('validate() accepts options', () => {
|
|||||||
expect(await validate(order)).toHaveLength(1);
|
expect(await validate(order)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('decorator context guards', () => {
|
||||||
|
// Every decorator resolves its metadata through one checkpoint, so one representative
|
||||||
|
// decorator per shape is enough to cover the rule.
|
||||||
|
const shapes: [string, unknown][] = [
|
||||||
|
['a method', { kind: 'method', name: 'run', metadata: {} }],
|
||||||
|
['a getter', { kind: 'getter', name: 'total', metadata: {} }],
|
||||||
|
['an accessor', { kind: 'accessor', name: 'value', metadata: {} }],
|
||||||
|
['a class', { kind: 'class', name: 'Thing', metadata: {} }],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, context] of shapes) {
|
||||||
|
it(`refuses being applied to ${label}`, () => {
|
||||||
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/apply to fields/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('explains why `accessor` in particular cannot work', () => {
|
||||||
|
const context = { kind: 'accessor', name: 'value', metadata: {} };
|
||||||
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/private slot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a legacy decorator call shape', () => {
|
||||||
|
// What `experimentalDecorators: true` emits: (prototype, propertyKey).
|
||||||
|
expect(() => (IsString() as any)({}, 'name')).toThrow(/experimentalDecorators/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a standard context that carries no metadata', () => {
|
||||||
|
const context = { kind: 'field', name: 'value', metadata: undefined };
|
||||||
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/no metadata object/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the field it could not record', () => {
|
||||||
|
const context = { kind: 'field', name: 'nickname', metadata: null };
|
||||||
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/"nickname"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards the mapping decorators too, not just the rules', () => {
|
||||||
|
expect(() => (JsonSerialize(class {} as any) as any)({}, 'name')).toThrow(/experimentalDecorators/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+60
-1
@@ -126,6 +126,58 @@ function ownModel(metadata: DecoratorMetadata): ClassModel {
|
|||||||
return (metadata as Record<symbol, ClassModel>)[MODEL]!;
|
return (metadata as Record<symbol, ClassModel>)[MODEL]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a decorator context and returns the metadata object to record into.
|
||||||
|
*
|
||||||
|
* Every decorator goes through here rather than reading `context.metadata` directly, because
|
||||||
|
* each of the three failures below is a configuration mistake with a one-line fix, and the
|
||||||
|
* error you get without the check — `TypeError: Cannot convert undefined or null to object`,
|
||||||
|
* raised somewhere inside cereale — points at none of them.
|
||||||
|
*
|
||||||
|
* Typed as `unknown` deliberately: the whole point is to inspect a context that may not have
|
||||||
|
* the shape the type says it has, because it came from the wrong decorator transform.
|
||||||
|
*/
|
||||||
|
export function fieldMetadata(context: unknown): DecoratorMetadata {
|
||||||
|
const ctx = context as { kind?: unknown; name?: unknown; metadata?: unknown } | null | undefined;
|
||||||
|
|
||||||
|
// A legacy (`experimentalDecorators: true`) field decorator is invoked as
|
||||||
|
// `(prototype, "propertyName")`, so the second argument is a string, not a context object.
|
||||||
|
if (typeof ctx !== 'object' || ctx === null || typeof ctx.kind !== 'string') {
|
||||||
|
throw new TypeError(
|
||||||
|
'cereale needs TC39 standard decorators, but the compiler emitted legacy ones. ' +
|
||||||
|
'Set "experimentalDecorators": false in tsconfig.json (and drop "emitDecoratorMetadata"). ' +
|
||||||
|
'The two decorator systems cannot coexist in one program, so a project that still needs ' +
|
||||||
|
'legacy decorators for another library cannot use cereale yet.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.kind !== 'field') {
|
||||||
|
throw new TypeError(
|
||||||
|
`cereale decorators apply to fields, but this one was applied to a ${ctx.kind}.` +
|
||||||
|
(ctx.kind === 'accessor'
|
||||||
|
? ' An `accessor` field keeps its value in a private slot that mapping and validation ' +
|
||||||
|
'cannot reach — declare it as a plain field instead.'
|
||||||
|
: '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard decorators are specified to always carry a metadata object, but the emitted
|
||||||
|
// helpers create it conditionally: tsc writes `Symbol.metadata ? Object.create(...) : void 0`.
|
||||||
|
// Importing cereale installs the `Symbol.metadata` fallback, so this only fires if the
|
||||||
|
// decorated class somehow evaluates first.
|
||||||
|
if (typeof ctx.metadata !== 'object' || ctx.metadata === null) {
|
||||||
|
throw new TypeError(
|
||||||
|
`The decorator context for "${String(ctx.name)}" carries no metadata object, so cereale ` +
|
||||||
|
'has nowhere to record the rule. The compiler emitted its decorator helpers without ' +
|
||||||
|
'metadata support: make sure cereale is imported before the decorated class is evaluated ' +
|
||||||
|
'(importing it installs the Symbol.metadata fallback) and that the build targets ES2022 ' +
|
||||||
|
'or later.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.metadata as DecoratorMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns (creating if needed) the model entry for one field. */
|
/** Returns (creating if needed) the model entry for one field. */
|
||||||
export function propertyModel(metadata: DecoratorMetadata, property: string): PropertyModel {
|
export function propertyModel(metadata: DecoratorMetadata, property: string): PropertyModel {
|
||||||
version++;
|
version++;
|
||||||
@@ -181,6 +233,13 @@ export function defineRule<T>(
|
|||||||
options?: ValidationOptions
|
options?: ValidationOptions
|
||||||
): void {
|
): void {
|
||||||
const holder = clazz as unknown as Record<symbol, DecoratorMetadata | undefined>;
|
const holder = clazz as unknown as Record<symbol, DecoratorMetadata | undefined>;
|
||||||
holder[METADATA_KEY] ??= Object.create(null) as DecoratorMetadata;
|
// `hasOwn`, not `??=`: a subclass with no decorators of its own *inherits* its base's
|
||||||
|
// metadata object through the static side of the prototype chain, and `??=` would find it
|
||||||
|
// non-nullish and write the rule straight into the base. Creating an own object that
|
||||||
|
// prototype-chains to the inherited one is what the decorator transform itself does, and it
|
||||||
|
// is what lets `ownModel` copy-on-write the base's rules instead of mutating them.
|
||||||
|
if (!Object.hasOwn(holder, METADATA_KEY)) {
|
||||||
|
holder[METADATA_KEY] = Object.create(holder[METADATA_KEY] ?? null) as DecoratorMetadata;
|
||||||
|
}
|
||||||
addConstraint(holder[METADATA_KEY]!, property, constraint, options);
|
addConstraint(holder[METADATA_KEY]!, property, constraint, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, JsonIgnore, JsonSerialize, JsonSerializer, JsonMappingError,
|
||||||
|
toPlain, toPlainSync, defineRule, modelOf, validateSync,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before 0.3.0 every case in this file produced `{}` (or index-keyed noise, or a bigint that
|
||||||
|
* made the caller's own `JSON.stringify` throw somewhere unrelated) with nothing logged and
|
||||||
|
* no error raised. A mapping layer that loses data quietly is worse than one that stops.
|
||||||
|
*/
|
||||||
|
describe('values JSON cannot carry', () => {
|
||||||
|
class Basket {
|
||||||
|
// Typed loosely on purpose: the point is what happens at runtime, and the decorators are
|
||||||
|
// deliberately absent so nothing is claiming to handle these.
|
||||||
|
items: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withItems = (items: unknown) => Object.assign(new Basket(), { items });
|
||||||
|
|
||||||
|
const cases: [string, unknown, RegExp][] = [
|
||||||
|
['a Map', new Map([['a', 1]]), /is a Map/],
|
||||||
|
['a Set', new Set([1, 2]), /is a Set/],
|
||||||
|
['a WeakMap', new WeakMap(), /is a WeakMap/],
|
||||||
|
['a WeakSet', new WeakSet(), /is a WeakSet/],
|
||||||
|
['a Promise', Promise.resolve(1), /is a Promise/],
|
||||||
|
['a RegExp', /abc/g, /is a RegExp/],
|
||||||
|
['an Error', new Error('boom'), /is an Error/],
|
||||||
|
['a TypeError', new TypeError('boom'), /is an Error/],
|
||||||
|
['an ArrayBuffer', new ArrayBuffer(8), /is an ArrayBuffer/],
|
||||||
|
['a DataView', new DataView(new ArrayBuffer(8)), /is a DataView/],
|
||||||
|
['a Uint8Array', new Uint8Array([1, 2, 3]), /is a Uint8Array/],
|
||||||
|
['a Float64Array', new Float64Array([1.5]), /is a Float64Array/],
|
||||||
|
['a bigint', 10n, /is a bigint/],
|
||||||
|
['a symbol', Symbol('x'), /is a symbol/],
|
||||||
|
['a function', () => 1, /is a function/],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, value, expected] of cases) {
|
||||||
|
it(`refuses ${label}`, () => {
|
||||||
|
expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(JsonMappingError);
|
||||||
|
expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('names the property in the message and points at the way out', () => {
|
||||||
|
expect(() => toPlainSync(withItems(new Map()), { validate: false }))
|
||||||
|
.toThrow(/items is a Map.*@JsonSerialize\(\).*@JsonIgnore\(\)/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the full path through nested objects and arrays', () => {
|
||||||
|
class Line { tags: any }
|
||||||
|
class Order { lines: any }
|
||||||
|
const order = Object.assign(new Order(), {
|
||||||
|
lines: [Object.assign(new Line(), { tags: [] }), Object.assign(new Line(), { tags: [new Set(['a'])] })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => toPlainSync(order, { validate: false })).toThrow(/lines\[1\]\.tags\[0\] is a Set/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the root when the offending value is the argument itself', () => {
|
||||||
|
expect(() => toPlainSync(new Map(), { validate: false })).toThrow(/the value passed in is a Map/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows the built-ins that do map cleanly', () => {
|
||||||
|
class Fine {
|
||||||
|
when = new Date('2024-01-01T00:00:00.000Z');
|
||||||
|
list = [1, 'two', true, null];
|
||||||
|
nested = { deep: { deeper: [{ ok: true }] } };
|
||||||
|
empty = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(toPlainSync(new Fine(), { validate: false })).toEqual({
|
||||||
|
when: '2024-01-01T00:00:00.000Z',
|
||||||
|
list: [1, 'two', true, null],
|
||||||
|
nested: { deep: { deeper: [{ ok: true }] } },
|
||||||
|
empty: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a Map once a serializer converts it', () => {
|
||||||
|
class TagsSerializer implements JsonSerializer<Map<string, number>, Record<string, number>> {
|
||||||
|
serialize(value: Map<string, number>) { return Object.fromEntries(value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Post {
|
||||||
|
@JsonSerialize(TagsSerializer)
|
||||||
|
tags!: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = new Post();
|
||||||
|
post.tags = new Map([['a', 1], ['b', 2]]);
|
||||||
|
expect(toPlainSync(post, { validate: false })).toEqual({ tags: { a: 1, b: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a Map once the property is ignored', () => {
|
||||||
|
class Cache {
|
||||||
|
@IsString() name = 'x';
|
||||||
|
@JsonIgnore() entries = new Map([['a', 1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(toPlainSync(new Cache(), { validate: false })).toEqual({ name: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a serializer that hands back something unrepresentable', () => {
|
||||||
|
class BadSerializer implements JsonSerializer<string, unknown> {
|
||||||
|
serialize() { return new Set(['still a Set']); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Thing {
|
||||||
|
@JsonSerialize(BadSerializer)
|
||||||
|
label!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thing = new Thing();
|
||||||
|
thing.label = 'x';
|
||||||
|
expect(() => toPlainSync(thing, { validate: false })).toThrow(/label is a Set/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an async serializer that resolves to something unrepresentable', async () => {
|
||||||
|
class SlowBadSerializer implements JsonSerializer<string, unknown> {
|
||||||
|
async serialize() { return new Map([['a', 1]]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Thing {
|
||||||
|
@JsonSerialize(SlowBadSerializer)
|
||||||
|
label!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thing = new Thing();
|
||||||
|
thing.label = 'x';
|
||||||
|
await expect(toPlain(thing, { validate: false })).rejects.toThrow(/label is a Map/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serialization error paths', () => {
|
||||||
|
it('names where the cycle was found', () => {
|
||||||
|
class Node { name = 'root'; child: any = null; parent: any = null }
|
||||||
|
const root = new Node();
|
||||||
|
const child = new Node();
|
||||||
|
child.name = 'child';
|
||||||
|
child.parent = root;
|
||||||
|
root.child = child;
|
||||||
|
|
||||||
|
expect(() => toPlainSync(root, { validate: false })).toThrow(/at child\.parent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names where the depth limit was hit', () => {
|
||||||
|
class Deep { next: any = null }
|
||||||
|
const root = new Deep();
|
||||||
|
let tip = root;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
tip.next = new Deep();
|
||||||
|
tip = tip.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => toPlainSync(root, { validate: false, maxDepth: 3 }))
|
||||||
|
.toThrow(/exceeded while serializing at next\.next\.next/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defineRule', () => {
|
||||||
|
// `??=` on an inherited static symbol property finds the base class's metadata object and
|
||||||
|
// never creates an own one, so the rule lands on the base and every sibling inherits it.
|
||||||
|
it('does not write a subclass rule into its base class', () => {
|
||||||
|
class Base {
|
||||||
|
@IsString() name!: string;
|
||||||
|
}
|
||||||
|
class Sub extends Base { extra!: string }
|
||||||
|
class Sibling extends Base { }
|
||||||
|
|
||||||
|
defineRule(Sub, 'extra', {
|
||||||
|
name: 'isShouty',
|
||||||
|
validate: (v: any) => typeof v === 'string' && v === v.toUpperCase(),
|
||||||
|
message: 'extra must be upper case',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(modelOf(Sub)).sort()).toEqual(['extra', 'name']);
|
||||||
|
expect(Object.keys(modelOf(Base))).toEqual(['name']);
|
||||||
|
expect(Object.keys(modelOf(Sibling))).toEqual(['name']);
|
||||||
|
|
||||||
|
const sibling = Object.assign(new Sibling(), { name: 'ok' });
|
||||||
|
expect(validateSync(sibling)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { transform } from 'esbuild';
|
||||||
|
import { transform as swcTransform } from '@swc/core';
|
||||||
|
|
||||||
|
import { IsString, modelOf } from './index.js';
|
||||||
|
import { standardDecorators } from './vite.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The support matrix in the README, executed.
|
||||||
|
*
|
||||||
|
* cereale reads `context.metadata`, which only exists if the compiler emitted TC39 standard
|
||||||
|
* decorators — so which compiler a consumer uses, and how it is configured, decides whether
|
||||||
|
* the library works at all. Claiming that in prose is not worth much; each row below actually
|
||||||
|
* compiles a decorated class with the tool in question and checks the metadata arrived.
|
||||||
|
*
|
||||||
|
* The one row that cannot run here is oxc, the transformer Vite 8 and Vitest 4 use, because it
|
||||||
|
* ships inside a native binary with no standalone transform API. Its behaviour is why
|
||||||
|
* `cereale/vite` exists, and the plugin is covered further down.
|
||||||
|
*/
|
||||||
|
const PROBE = `
|
||||||
|
const Rule = globalThis.__cerealeProbeRule;
|
||||||
|
|
||||||
|
export class Probe {
|
||||||
|
@Rule() name;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Compiler options a consumer needs for cereale to work. */
|
||||||
|
const STANDARD = { experimentalDecorators: false, useDefineForClassFields: true };
|
||||||
|
/** What most existing TypeScript projects still have, because class-validator required it. */
|
||||||
|
const LEGACY = { experimentalDecorators: true, useDefineForClassFields: false };
|
||||||
|
|
||||||
|
const emit = {
|
||||||
|
tsc(source: string, options: typeof STANDARD): string {
|
||||||
|
return ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
}).outputText;
|
||||||
|
},
|
||||||
|
async esbuild(source: string, options: typeof STANDARD): Promise<string> {
|
||||||
|
const result = await transform(source, {
|
||||||
|
loader: 'ts',
|
||||||
|
target: 'es2022',
|
||||||
|
tsconfigRaw: { compilerOptions: options },
|
||||||
|
});
|
||||||
|
return result.code;
|
||||||
|
},
|
||||||
|
async swc(source: string, options: typeof STANDARD): Promise<string> {
|
||||||
|
const result = await swcTransform(source, {
|
||||||
|
filename: 'probe.ts',
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: 'typescript', decorators: true },
|
||||||
|
target: 'es2022',
|
||||||
|
// swc spells the choice as a proposal date rather than a boolean.
|
||||||
|
transform: { decoratorVersion: options.experimentalDecorators ? '2021-12' : '2022-03' },
|
||||||
|
},
|
||||||
|
module: { type: 'es6' },
|
||||||
|
});
|
||||||
|
return result.code;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let workspace: string;
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
workspace = await mkdtemp(path.join(tmpdir(), 'cereale-toolchain-'));
|
||||||
|
// The emitted probe reaches the decorator through a global rather than an import, so that
|
||||||
|
// it needs no module resolution back into a package that has not been built yet.
|
||||||
|
(globalThis as Record<string, unknown>).__cerealeProbeRule = IsString;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete (globalThis as Record<string, unknown>).__cerealeProbeRule;
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Writes emitted JavaScript to disk and imports it, the way a consumer's runtime would. */
|
||||||
|
async function load(code: string): Promise<{ Probe: unknown }> {
|
||||||
|
const file = path.join(workspace, `probe-${counter++}.mjs`);
|
||||||
|
await writeFile(file, code);
|
||||||
|
return import(pathToFileURL(file).href) as Promise<{ Probe: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compilers that emit standard decorators', () => {
|
||||||
|
it('tsc records the rule', async () => {
|
||||||
|
const { Probe } = await load(emit.tsc(PROBE, STANDARD));
|
||||||
|
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('esbuild records the rule', async () => {
|
||||||
|
const { Probe } = await load(await emit.esbuild(PROBE, STANDARD));
|
||||||
|
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swc records the rule', async () => {
|
||||||
|
const { Probe } = await load(await emit.swc(PROBE, STANDARD));
|
||||||
|
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compilers configured for legacy decorators', () => {
|
||||||
|
// Left unguarded, both of these die inside cereale with `TypeError: Cannot convert undefined
|
||||||
|
// or null to object`, which names neither the cause nor the setting that fixes it.
|
||||||
|
it('tsc emit is refused by name', async () => {
|
||||||
|
await expect(load(emit.tsc(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('esbuild emit is refused by name', async () => {
|
||||||
|
await expect(load(await emit.esbuild(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swc emit is refused by name', async () => {
|
||||||
|
await expect(load(await emit.swc(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cereale/vite', () => {
|
||||||
|
const plugin = (options?: Parameters<typeof standardDecorators>[0]) => standardDecorators(options);
|
||||||
|
|
||||||
|
it('lowers decorator syntax that oxc would pass through untouched', async () => {
|
||||||
|
const result = await plugin().transform(PROBE, '/app/src/model.ts');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.code).not.toMatch(/@Rule\(\)/);
|
||||||
|
|
||||||
|
const { Probe } = await load(result!.code);
|
||||||
|
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces working output through the TypeScript compiler too', async () => {
|
||||||
|
const result = await plugin({ transformer: 'typescript' }).transform(PROBE, '/app/src/model.ts');
|
||||||
|
const { Probe } = await load(result!.code);
|
||||||
|
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a source map', async () => {
|
||||||
|
const result = await plugin().transform(PROBE, '/app/src/model.ts');
|
||||||
|
expect(result!.map).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// tsc appends one pointing at a file that was never written; Vite follows it and logs a
|
||||||
|
// failure to read the map for every transformed module.
|
||||||
|
it('does not leave a sourceMappingURL comment behind', async () => {
|
||||||
|
for (const transformer of ['esbuild', 'typescript'] as const) {
|
||||||
|
const result = await plugin({ transformer }).transform(PROBE, '/app/src/model.ts');
|
||||||
|
expect(result!.code, transformer).not.toMatch(/sourceMappingURL/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of ['/app/src/model.ts', '/app/src/model.mts', '/app/src/model.cts', '/app/src/model.ts?v=123']) {
|
||||||
|
it(`transforms ${id}`, async () => {
|
||||||
|
expect(await plugin().transform(PROBE, id)).not.toBeNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ['/app/node_modules/dep/model.ts', '/app/src/model.js', '/app/src/model.tsx', '/app/src/style.css']) {
|
||||||
|
it(`leaves ${id} alone`, async () => {
|
||||||
|
expect(await plugin().transform(PROBE, id)).toBeNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('honours a caller-supplied include', async () => {
|
||||||
|
const onlyModels = plugin({ include: id => id.includes('/models/') });
|
||||||
|
expect(await onlyModels.transform(PROBE, '/app/src/models/user.ts')).not.toBeNull();
|
||||||
|
expect(await onlyModels.transform(PROBE, '/app/src/routes/user.ts')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs before Vite’s own transform', () => {
|
||||||
|
expect(plugin().enforce).toBe('pre');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a target the compiler does not know', async () => {
|
||||||
|
const bad = plugin({ transformer: 'typescript', target: 'es1999' });
|
||||||
|
await expect(bad.transform(PROBE, '/app/src/model.ts')).rejects.toThrow(/es1999/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+131
-12
@@ -242,14 +242,113 @@ function refuseAsync(deferred: Deferred, operation: string, asyncName: string):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number, deferred: Deferred): any {
|
/**
|
||||||
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
* Values that carry their data in internal slots rather than in own enumerable properties.
|
||||||
|
*
|
||||||
|
* Walking one of these with `Object.keys` yields `{}` — a populated `Map` becomes an empty
|
||||||
|
* object and nothing anywhere says so. Keyed by `Symbol.toStringTag`, which every one of them
|
||||||
|
* defines on its prototype, so the lookup costs a single property read and still recognises
|
||||||
|
* instances that came from another realm.
|
||||||
|
*/
|
||||||
|
const UNREPRESENTABLE: Record<string, string> = {
|
||||||
|
Map: 'a Map',
|
||||||
|
Set: 'a Set',
|
||||||
|
WeakMap: 'a WeakMap',
|
||||||
|
WeakSet: 'a WeakSet',
|
||||||
|
WeakRef: 'a WeakRef',
|
||||||
|
Promise: 'a Promise',
|
||||||
|
ArrayBuffer: 'an ArrayBuffer',
|
||||||
|
SharedArrayBuffer: 'a SharedArrayBuffer',
|
||||||
|
DataView: 'a DataView',
|
||||||
|
Generator: 'a generator',
|
||||||
|
AsyncGenerator: 'an async generator',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes why a value cannot be represented in JSON, or returns null if it can.
|
||||||
|
*
|
||||||
|
* The alternative to raising this is what the engine used to do: emit `{}` for a `Map`,
|
||||||
|
* index-keyed noise for a `Uint8Array`, and a bigint that makes the caller's own
|
||||||
|
* `JSON.stringify` throw somewhere else entirely. This library's position is that silent
|
||||||
|
* success is the worst failure mode a mapping layer can have, and that has to include its own.
|
||||||
|
*/
|
||||||
|
function unrepresentableObject(value: object): string | null {
|
||||||
|
const tag = (value as Record<symbol, unknown>)[Symbol.toStringTag];
|
||||||
|
if (typeof tag === 'string') {
|
||||||
|
const known = UNREPRESENTABLE[tag];
|
||||||
|
if (known !== undefined) return known;
|
||||||
|
// Typed arrays are tagged with their own name and would serialize to `{"0":…,"1":…}`.
|
||||||
|
if (ArrayBuffer.isView(value)) return `a ${tag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegExp and Error get their `Object.prototype.toString` tag from a spec special case
|
||||||
|
// rather than from `Symbol.toStringTag`, so neither is caught above.
|
||||||
|
if (value instanceof RegExp) return 'a RegExp';
|
||||||
|
if (value instanceof Error) return 'an Error, whose message and stack are not enumerable';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIGINT_REASON = 'a bigint, which JSON has no representation for';
|
||||||
|
|
||||||
|
/** The same question for a value of any type. `serialize` inlines the primitive half. */
|
||||||
|
function unrepresentable(value: unknown): string | null {
|
||||||
|
switch (typeof value) {
|
||||||
|
case 'bigint': return BIGINT_REASON;
|
||||||
|
case 'symbol': return 'a symbol';
|
||||||
|
case 'function': return 'a function';
|
||||||
|
case 'object': return value === null ? null : unrepresentableObject(value);
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trail of keys walked to reach a value: property names as strings, array positions as
|
||||||
|
* numbers. Numbers are kept unformatted so that walking an array costs no string building.
|
||||||
|
*/
|
||||||
|
type Path = (string | number)[];
|
||||||
|
|
||||||
|
/** Renders a {@link Path} for error messages. */
|
||||||
|
function describePath(path: readonly (string | number)[]): string {
|
||||||
|
if (path.length === 0) return 'the value passed in';
|
||||||
|
let out = '';
|
||||||
|
for (const segment of path) {
|
||||||
|
if (typeof segment === 'number') out += `[${segment}]`;
|
||||||
|
else out += out === '' ? segment : `.${segment}`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refuseUnrepresentable(why: string, path: readonly (string | number)[]): never {
|
||||||
|
throw new JsonMappingError(
|
||||||
|
`${describePath(path)} is ${why}, which cannot be serialized to JSON. ` +
|
||||||
|
'Give the property a @JsonSerialize() serializer that converts it, or drop it from the ' +
|
||||||
|
'output with @JsonIgnore().'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param path The keys walked to reach `obj`, kept as a stack so that errors can name the
|
||||||
|
* offending property. Pushed and popped rather than concatenated, so the bookkeeping costs
|
||||||
|
* no string building on the way down.
|
||||||
|
*/
|
||||||
|
function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number, deferred: Deferred, path: Path): any {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
|
||||||
|
// Primitives dominate the walk, so their check is inline: one `typeof` and, for the three
|
||||||
|
// types JSON cannot carry, a throw. Everything else defers to `unrepresentableObject`,
|
||||||
|
// which is only reached once per object and skipped entirely for arrays and dates.
|
||||||
|
const type = typeof obj;
|
||||||
|
if (type !== 'object') {
|
||||||
|
if (type === 'bigint') refuseUnrepresentable(BIGINT_REASON, path);
|
||||||
|
if (type === 'symbol') refuseUnrepresentable('a symbol', path);
|
||||||
|
if (type === 'function') refuseUnrepresentable('a function', path);
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (depth > ctx.maxDepth) {
|
if (depth > ctx.maxDepth) {
|
||||||
throw new JsonMappingError(
|
throw new JsonMappingError(
|
||||||
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing. ` +
|
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing at ${describePath(path)}. ` +
|
||||||
`Raise it with the maxDepth option if this structure is legitimate.`
|
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,19 +357,28 @@ function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth:
|
|||||||
return obj.toISOString();
|
return obj.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isArray = Array.isArray(obj);
|
||||||
|
if (!isArray) {
|
||||||
|
const why = unrepresentableObject(obj);
|
||||||
|
if (why !== null) refuseUnrepresentable(why, path);
|
||||||
|
}
|
||||||
|
|
||||||
if (ancestors.has(obj)) {
|
if (ancestors.has(obj)) {
|
||||||
throw new JsonMappingError(
|
throw new JsonMappingError(
|
||||||
'Circular reference detected during serialization. Break the cycle with @JsonIgnore() ' +
|
`Circular reference detected during serialization at ${describePath(path)}. Break the cycle ` +
|
||||||
'on the back-reference, or supply a @JsonSerialize() serializer for that property.'
|
'with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for ' +
|
||||||
|
'that property.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ancestors.add(obj);
|
ancestors.add(obj);
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(obj)) {
|
if (isArray) {
|
||||||
const out: any[] = [];
|
const out: any[] = [];
|
||||||
for (const item of obj) {
|
for (let index = 0; index < obj.length; index++) {
|
||||||
out.push(serialize(item, ancestors, ctx, depth + 1, deferred));
|
path.push(index);
|
||||||
|
out.push(serialize(obj[index], ancestors, ctx, depth + 1, deferred, path));
|
||||||
|
path.pop();
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -283,6 +391,7 @@ function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth:
|
|||||||
if (property.skip) continue;
|
if (property.skip) continue;
|
||||||
|
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
|
path.push(key);
|
||||||
|
|
||||||
// Custom serializers only see real values. Handing a serializer `undefined` for a
|
// Custom serializers only see real values. Handing a serializer `undefined` for a
|
||||||
// property that was simply never set turns an optional field into a crash.
|
// property that was simply never set turns an optional field into a crash.
|
||||||
@@ -292,14 +401,24 @@ function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth:
|
|||||||
const slot = property.name;
|
const slot = property.name;
|
||||||
// Claim the key now so the deferred write lands in declaration order rather than
|
// Claim the key now so the deferred write lands in declaration order rather than
|
||||||
// being appended after every synchronous property.
|
// being appended after every synchronous property.
|
||||||
|
const where = [...path];
|
||||||
result[slot] = undefined;
|
result[slot] = undefined;
|
||||||
deferred.push(produced.then((settled: any) => { result[slot] = settled; }));
|
deferred.push(produced.then((settled: any) => {
|
||||||
|
const bad = unrepresentable(settled);
|
||||||
|
if (bad !== null) refuseUnrepresentable(bad, where);
|
||||||
|
result[slot] = settled;
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
|
// A serializer that hands back a Map is the same silent `{}` by another route.
|
||||||
|
const bad = unrepresentable(produced);
|
||||||
|
if (bad !== null) refuseUnrepresentable(bad, path);
|
||||||
result[property.name] = produced;
|
result[property.name] = produced;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred);
|
result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -795,7 +914,7 @@ export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<an
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deferred: Deferred = [];
|
const deferred: Deferred = [];
|
||||||
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
|
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred, []);
|
||||||
await settle(deferred);
|
await settle(deferred);
|
||||||
return plain;
|
return plain;
|
||||||
}
|
}
|
||||||
@@ -816,7 +935,7 @@ export function toPlainSync<T>(obj: T, options?: TransformOptions): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deferred: Deferred = [];
|
const deferred: Deferred = [];
|
||||||
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
|
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred, []);
|
||||||
refuseAsync(deferred, 'toPlainSync()', 'toPlain()');
|
refuseAsync(deferred, 'toPlainSync()', 'toPlain()');
|
||||||
return plain;
|
return plain;
|
||||||
}
|
}
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* A Vite plugin that lowers TC39 standard decorators, for projects on Vite 8 or Vitest 4.
|
||||||
|
*
|
||||||
|
* Those versions transform TypeScript with oxc, which does not implement the standard
|
||||||
|
* decorator transform yet. It does not report that: it leaves the decorator syntax in the
|
||||||
|
* output, so `vitest` prints "0 test" next to a bare `SyntaxError`, and `vite build` reports
|
||||||
|
* success while emitting a bundle that throws `SyntaxError` the moment anything imports it.
|
||||||
|
*
|
||||||
|
* Nothing here is specific to cereale — any library built on standard decorators needs it —
|
||||||
|
* but cereale ships it because a consumer's first experience of the library should not be a
|
||||||
|
* syntax error with no obvious cause. Delete it once oxc supports the transform.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* // vite.config.ts / vitest.config.ts
|
||||||
|
* import { standardDecorators } from 'cereale/vite';
|
||||||
|
*
|
||||||
|
* export default defineConfig({ plugins: [standardDecorators()] });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The transform is done by esbuild if it is installed, otherwise by the TypeScript compiler.
|
||||||
|
* cereale depends on neither; one of the two is present in essentially every TypeScript
|
||||||
|
* project, and the plugin says which to install if somehow neither is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape Vite expects of a plugin, declared here rather than imported.
|
||||||
|
*
|
||||||
|
* `cereale/vite` must not drag `vite` into a consumer's type-checking just to describe its own
|
||||||
|
* return value — this object is structurally assignable to Vite's `Plugin`.
|
||||||
|
*/
|
||||||
|
export interface StandardDecoratorsPlugin {
|
||||||
|
name: string;
|
||||||
|
enforce: 'pre';
|
||||||
|
transform(code: string, id: string): Promise<{ code: string; map: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandardDecoratorsOptions {
|
||||||
|
/**
|
||||||
|
* Decides which modules to transform. Receives the resolved module id.
|
||||||
|
*
|
||||||
|
* The default takes `.ts`, `.mts` and `.cts` outside `node_modules`. `.tsx` is excluded
|
||||||
|
* because lowering decorators there means also deciding what happens to the JSX, and
|
||||||
|
* getting that wrong is worse than not handling it; pass an `include` of your own if you
|
||||||
|
* declare decorated classes in `.tsx` files.
|
||||||
|
*/
|
||||||
|
include?: (id: string) => boolean;
|
||||||
|
/** ECMAScript target for the emitted code. Defaults to `es2022`, the first with class fields. */
|
||||||
|
target?: string;
|
||||||
|
/**
|
||||||
|
* Which tool does the transform. `'auto'` (the default) prefers esbuild for speed and falls
|
||||||
|
* back to the TypeScript compiler; name one explicitly to keep a build reproducible, or to
|
||||||
|
* fail loudly rather than silently switch if the preferred one is not installed.
|
||||||
|
*/
|
||||||
|
transformer?: 'auto' | 'esbuild' | 'typescript';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INCLUDE = (id: string): boolean =>
|
||||||
|
/\.[cm]?ts(\?.*)?$/.test(id) && !id.includes('/node_modules/');
|
||||||
|
|
||||||
|
type Transformer = (code: string, id: string, target: string) => Promise<{ code: string; map: string }>;
|
||||||
|
|
||||||
|
function isMissingModule(error: unknown): boolean {
|
||||||
|
const code = (error as { code?: unknown } | null)?.code;
|
||||||
|
return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function esbuildTransformer(): Promise<Transformer | null> {
|
||||||
|
let esbuild: typeof import('esbuild');
|
||||||
|
try {
|
||||||
|
esbuild = await import('esbuild');
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingModule(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return async (code, id, target) => {
|
||||||
|
const result = await esbuild.transform(code, {
|
||||||
|
loader: 'ts',
|
||||||
|
target,
|
||||||
|
sourcefile: id,
|
||||||
|
sourcemap: true,
|
||||||
|
// Standard semantics, not the legacy ones: cereale records into `context.metadata`.
|
||||||
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||||
|
});
|
||||||
|
return { code: result.code, map: result.map };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typescriptTransformer(): Promise<Transformer | null> {
|
||||||
|
let ts: typeof import('typescript');
|
||||||
|
try {
|
||||||
|
ts = await import('typescript');
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingModule(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// `ScriptTarget` members are spelled `ES2022`, `ESNext`; esbuild-style targets are lower
|
||||||
|
// case. Matched case-insensitively rather than upper-casing, which would miss `ESNext`.
|
||||||
|
const targetKey = (target: string) =>
|
||||||
|
Object.keys(ts.ScriptTarget).find(key => key.toLowerCase() === target.toLowerCase());
|
||||||
|
|
||||||
|
return async (code, id, target) => {
|
||||||
|
const key = targetKey(target);
|
||||||
|
if (key === undefined) {
|
||||||
|
throw new Error(`cereale/vite: ${JSON.stringify(target)} is not a target the TypeScript compiler knows.`);
|
||||||
|
}
|
||||||
|
const result = ts.transpileModule(code, {
|
||||||
|
fileName: id.replace(/\?.*$/, ''),
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget[key as keyof typeof ts.ScriptTarget],
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
experimentalDecorators: false,
|
||||||
|
useDefineForClassFields: true,
|
||||||
|
sourceMap: true,
|
||||||
|
isolatedModules: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// tsc appends `//# sourceMappingURL=<file>.map` even though the map is handed back
|
||||||
|
// separately. Vite would follow that comment and fail to read a file nobody wrote.
|
||||||
|
const output = result.outputText.replace(/\r?\n?\/\/# sourceMappingURL=\S*[ \t]*$/, '');
|
||||||
|
return { code: output, map: result.sourceMapText ?? '' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTORIES = { esbuild: esbuildTransformer, typescript: typescriptTransformer };
|
||||||
|
|
||||||
|
// Resolution is memoized per choice: the transform hook runs once per module, and neither
|
||||||
|
// `import('esbuild')` nor `import('typescript')` is cheap enough to repeat.
|
||||||
|
const resolved = new Map<string, Promise<Transformer>>();
|
||||||
|
|
||||||
|
function resolveTransformer(choice: 'auto' | 'esbuild' | 'typescript'): Promise<Transformer> {
|
||||||
|
let pending = resolved.get(choice);
|
||||||
|
if (!pending) {
|
||||||
|
pending = (async () => {
|
||||||
|
if (choice !== 'auto') {
|
||||||
|
const only = await FACTORIES[choice]();
|
||||||
|
if (only) return only;
|
||||||
|
throw new Error(
|
||||||
|
`cereale/vite was asked to transform with ${choice}, which is not installed. ` +
|
||||||
|
`Install it (\`npm i -D ${choice}\`) or drop the \`transformer\` option to let the ` +
|
||||||
|
'plugin pick whichever is available.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const best = (await esbuildTransformer()) ?? (await typescriptTransformer());
|
||||||
|
if (best) return best;
|
||||||
|
throw new Error(
|
||||||
|
'cereale/vite needs a transformer that understands TC39 standard decorators, and found ' +
|
||||||
|
'neither esbuild nor typescript. Install one of them as a dev dependency: ' +
|
||||||
|
'`npm i -D esbuild`.'
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
resolved.set(choice, pending);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms TypeScript sources with esbuild (or tsc) before Vite's own oxc pass sees them.
|
||||||
|
*
|
||||||
|
* `enforce: 'pre'` is what makes this work: the hook runs ahead of Vite's transform, hands
|
||||||
|
* back plain JavaScript, and oxc is then left with nothing it cannot parse.
|
||||||
|
*/
|
||||||
|
export function standardDecorators(options: StandardDecoratorsOptions = {}): StandardDecoratorsPlugin {
|
||||||
|
const include = options.include ?? DEFAULT_INCLUDE;
|
||||||
|
const target = options.target ?? 'es2022';
|
||||||
|
const choice = options.transformer ?? 'auto';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'cereale:standard-decorators',
|
||||||
|
enforce: 'pre',
|
||||||
|
async transform(code: string, id: string) {
|
||||||
|
if (!include(id)) return null;
|
||||||
|
return (await resolveTransformer(choice))(code, id, target);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-27
@@ -1,34 +1,10 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { transform } from 'esbuild';
|
import { standardDecorators } from './src/vite.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transpiles test sources with esbuild instead of oxc.
|
* The library's own tests run through the plugin the library ships, so that `cereale/vite`
|
||||||
*
|
* is exercised by every test run rather than only by the one test that asserts it exists.
|
||||||
* Vitest 4 transforms with oxc, which does not yet implement the TC39 standard decorator
|
|
||||||
* transform — it leaves the syntax in place and Node then fails to parse it, reporting
|
|
||||||
* "0 test" rather than an error. esbuild and tsc both implement it, so the library's own
|
|
||||||
* build (`tsc`) and consumers bundling with esbuild or Vite are unaffected; only the test
|
|
||||||
* runner needs this. Remove it once oxc gains standard-decorator support.
|
|
||||||
*/
|
*/
|
||||||
function standardDecorators() {
|
|
||||||
return {
|
|
||||||
name: 'cereale:standard-decorators',
|
|
||||||
enforce: 'pre' as const,
|
|
||||||
async transform(code: string, id: string) {
|
|
||||||
if (!/\.ts$/.test(id) || id.includes('node_modules')) return null;
|
|
||||||
const result = await transform(code, {
|
|
||||||
loader: 'ts',
|
|
||||||
target: 'es2022',
|
|
||||||
sourcefile: id,
|
|
||||||
sourcemap: true,
|
|
||||||
// Standard semantics, not the legacy ones: the library reads context.metadata.
|
|
||||||
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
|
||||||
});
|
|
||||||
return { code: result.code, map: result.map };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [standardDecorators()],
|
plugins: [standardDecorators()],
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user