@jarenjs/emit 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +97 -0
- package/README.md +300 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/markdown.d.ts +102 -0
- package/dist/types/model.d.ts +170 -0
- package/dist/types/typescript.d.ts +156 -0
- package/docs/EMIT-FORMAT.md +273 -0
- package/package.json +75 -0
- package/schemas/jaren-emit-model.schema.json +171 -0
- package/src/cli.js +191 -0
- package/src/index.js +21 -0
- package/src/markdown.js +105 -0
- package/src/model.js +993 -0
- package/src/typescript.js +276 -0
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @jarenjs/emit — architecture
|
|
2
|
+
|
|
3
|
+
Two stages, one published contract between them.
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
flowchart LR
|
|
7
|
+
A["schema"] --> B["model.js"]
|
|
8
|
+
B --> C["TYPE MODEL"]
|
|
9
|
+
C --> D["typescript.js<br/>markdown.js"]
|
|
10
|
+
D --> E["artifact"]
|
|
11
|
+
N1["stage 1: the flattening<br/>no template can do"]
|
|
12
|
+
N2["a published format,<br/>not private state"]
|
|
13
|
+
N3["stage 2: one stylesheet<br/>per target language"]
|
|
14
|
+
B -.- N1
|
|
15
|
+
C -.- N2
|
|
16
|
+
D -.- N3
|
|
17
|
+
class N1,N2,N3 note
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Stage 1 — `model.js`
|
|
21
|
+
|
|
22
|
+
A single recursive descent over the schema, producing a flat declaration list.
|
|
23
|
+
The decisions that live here, and nowhere else:
|
|
24
|
+
|
|
25
|
+
- **Naming.** `$defs` keys and `$ref` pointer tokens are the names a reader
|
|
26
|
+
already uses, so they win. Anything else is derived from the path that
|
|
27
|
+
reached it (`UserAddressStreet`), PascalCased and de-collided by suffix.
|
|
28
|
+
- **Cycles.** A node reached while it is still being built is hoisted to a
|
|
29
|
+
declaration and referenced by name. That is what turns a cyclic graph into
|
|
30
|
+
an acyclic list; the frame already building it finishes the declaration.
|
|
31
|
+
- **Composition.** `allOf` → intersection, `anyOf`/`oneOf` → union, `const`/
|
|
32
|
+
`enum` → literals. Degenerate unions collapse, `never` drops out, and a
|
|
33
|
+
union containing `unknown` becomes `unknown`. A `$ref`'s 2019-09+ siblings
|
|
34
|
+
intersect with the target; the reference itself resolves through the
|
|
35
|
+
helpers `@jarenjs/validate/normalize` exports (`#`, `#/pointer`, plain
|
|
36
|
+
`#anchor`, embedded-`$id` scope boundary), so a ref means here exactly what
|
|
37
|
+
it means to the runtime normalizer.
|
|
38
|
+
- **Honest widening.** Every keyword in `DROPPED_CONSTRAINTS` is recorded on
|
|
39
|
+
the node it came from and flattened into `doc` lines — including
|
|
40
|
+
integer-ness, `not`, active conditionals, dependent/unevaluated keywords
|
|
41
|
+
and `patternProperties` key restrictions. Nothing is discarded. The
|
|
42
|
+
directional half of the rule: the type may be wider than the schema (and
|
|
43
|
+
says where), never narrower. That is why applicators imply no container
|
|
44
|
+
(`properties` without `type` unions the object arm with every other JSON
|
|
45
|
+
kind), why tuples take their required count from `minItems` and stay open
|
|
46
|
+
unless the schema closes them, and why a closed empty object emits
|
|
47
|
+
`Record<string, never>` rather than TypeScript's primitive-swallowing
|
|
48
|
+
empty interface.
|
|
49
|
+
- **The plain universe.** When variants are derived, `anyOf`/`oneOf`
|
|
50
|
+
branches compile in a context with normalization inert, because
|
|
51
|
+
`compileNormalizer` does not descend union branches. A referenced type
|
|
52
|
+
that differs under normalization gets a `Plain`-suffixed declaration for
|
|
53
|
+
its as-declared reading, shared by both passes; everything else shares the
|
|
54
|
+
main declaration. `normalizationChangesType` walks exactly the keywords
|
|
55
|
+
`compileNormalizer` walks — the twin analysis answering differently from
|
|
56
|
+
the runtime is the defect class this package exists to rule out.
|
|
57
|
+
- **Determinism.** Declaration order is discovery order, member order is
|
|
58
|
+
schema order, and names never depend on a traversal counter. Boolean
|
|
59
|
+
schemas memoize by value — every `true` is the same schema — except that
|
|
60
|
+
the `$defs` loop forces one declaration per def name, so every importable
|
|
61
|
+
name exists. The `reserved` option seeds the taken-name list, which is how
|
|
62
|
+
the CLI's `--bundle` keeps independently compiled models in one name space.
|
|
63
|
+
|
|
64
|
+
Absent information is **omitted**, not set to `null` — a missing `rest`,
|
|
65
|
+
`index` or `default` is simply not there. Stage 2 relies on that: a path that
|
|
66
|
+
yields the empty sequence dispatches nothing, which is how a template says
|
|
67
|
+
"only when present" without a conditional.
|
|
68
|
+
|
|
69
|
+
## Stage 2 — the stylesheets
|
|
70
|
+
|
|
71
|
+
Each emitter is a JTLT document plus a thin `render` function. Three things
|
|
72
|
+
about them are worth knowing before editing one:
|
|
73
|
+
|
|
74
|
+
1. **Rules match by SCHEMA, not by path.** `$apply` on the current node
|
|
75
|
+
dispatches *location-less*, so a path match like `$..[?@.kind == 'union']`
|
|
76
|
+
silently never fires for a re-dispatched node. `isKind()` builds the schema
|
|
77
|
+
match instead, which is location-independent — and shape is what the rules
|
|
78
|
+
mean anyway. This is the single easiest way to break these files.
|
|
79
|
+
2. **`$if` is an expression segment.** It interpolates a value; it cannot
|
|
80
|
+
contain a segment list. Conditional *rendering* is done by dispatching a
|
|
81
|
+
path that may be empty, never by `$if`.
|
|
82
|
+
3. **Separators without a position variable.** JTLT has no `position()`, so a
|
|
83
|
+
separated list dispatches `$.options[0]` bare and `$.options[1:]` through a
|
|
84
|
+
mode that prints its own separator first. Ordinary RFC 9535 selectors, no
|
|
85
|
+
help needed from the model.
|
|
86
|
+
|
|
87
|
+
`markdown.js` exists to keep stage 1 honest: it is as unlike TypeScript as a
|
|
88
|
+
target gets, and it required no model change. If a future target does require
|
|
89
|
+
one, that is the signal the model is shaped around a language rather than
|
|
90
|
+
around schemas.
|
|
91
|
+
|
|
92
|
+
## Verification
|
|
93
|
+
|
|
94
|
+
`test/emit/agreement.test.js` is the reason to trust any of this — the cyclic
|
|
95
|
+
check against `@jarenjs/validate` described in the
|
|
96
|
+
[README](./README.md#the-cyclic-verification). It is itself checked by
|
|
97
|
+
breaking the generator on purpose and confirming the suite fails.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# @jarenjs/emit
|
|
2
|
+
|
|
3
|
+
Build-time artifacts from JSON documents. Point it at your JSON Schemas, get
|
|
4
|
+
TypeScript declarations — or Markdown reference docs, or whatever a stylesheet
|
|
5
|
+
emits next.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx jaren-emit --schema ./schemas --out ./src/types
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```javascript
|
|
12
|
+
import { emitTypeScript } from '@jarenjs/emit';
|
|
13
|
+
|
|
14
|
+
emitTypeScript({
|
|
15
|
+
type: 'object',
|
|
16
|
+
description: 'A user',
|
|
17
|
+
properties: {
|
|
18
|
+
id: { type: 'string', format: 'uuid' },
|
|
19
|
+
role: { enum: ['admin', 'user'] },
|
|
20
|
+
},
|
|
21
|
+
required: ['id'],
|
|
22
|
+
}, { name: 'User' });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
/**
|
|
27
|
+
* A user
|
|
28
|
+
*/
|
|
29
|
+
export interface User {
|
|
30
|
+
/**
|
|
31
|
+
* Schema constraints this type cannot express: format="uuid"
|
|
32
|
+
*/
|
|
33
|
+
id: string;
|
|
34
|
+
role?: "admin" | "user";
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why this exists
|
|
39
|
+
|
|
40
|
+
A schema-first codebase has a gap where a Zod codebase has `z.infer`: the
|
|
41
|
+
schema knows the shape, and TypeScript does not. The usual answers are to
|
|
42
|
+
assert the type by hand — which can silently certify something the schema
|
|
43
|
+
never said — or to reach for a type-level library.
|
|
44
|
+
|
|
45
|
+
The observation this package is built on is smaller than either: **a JSON
|
|
46
|
+
Schema is JSON, TypeScript is text, and [JTLT](../json/docs/JTLT-FORMAT.md) is
|
|
47
|
+
JSON-to-text.** Generating a declaration file is a stylesheet, not a new
|
|
48
|
+
engine. That is also why it is called `emit` and not `infer` — swap the
|
|
49
|
+
stylesheet and the same machinery emits documentation, DDL, or anything else
|
|
50
|
+
a target language needs.
|
|
51
|
+
|
|
52
|
+
## The cyclic verification
|
|
53
|
+
|
|
54
|
+
This is the part that matters, and the part a standalone
|
|
55
|
+
schema-to-TypeScript tool structurally cannot do.
|
|
56
|
+
|
|
57
|
+
One schema goes two ways, and the two answers have to correspond:
|
|
58
|
+
|
|
59
|
+
```mermaid
|
|
60
|
+
flowchart TD
|
|
61
|
+
S["JSON Schema"]
|
|
62
|
+
T["TypeScript type"]
|
|
63
|
+
V["compiled validator"]
|
|
64
|
+
C["the SAME instances —<br/>and the two answers must correspond"]
|
|
65
|
+
S -->|"@jarenjs/emit"| T
|
|
66
|
+
S -->|"@jarenjs/validate"| V
|
|
67
|
+
T --> C
|
|
68
|
+
V --> C
|
|
69
|
+
N["owning both sides is what<br/>makes this testable"]
|
|
70
|
+
C -.- N
|
|
71
|
+
classDef answer fill:#dcfce7,stroke:#16a34a
|
|
72
|
+
class C answer
|
|
73
|
+
class N note
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A generator that is merely *plausible* is worthless. It will emit a type that
|
|
77
|
+
says one thing while the validator enforces another, and you find out in
|
|
78
|
+
production — data that passed validation and violates its own declared type,
|
|
79
|
+
or a type so wide it certifies anything.
|
|
80
|
+
|
|
81
|
+
Because Jaren owns **both sides**, that can be tested rather than trusted.
|
|
82
|
+
The suite in `test/emit/agreement.test.js` takes a corpus of schemas plus
|
|
83
|
+
instances, generates the declarations, writes a probe file that assigns every
|
|
84
|
+
instance to its generated type, and runs `tsc` over it. Then it runs the
|
|
85
|
+
compiled validator over the same instances and requires three relationships to
|
|
86
|
+
hold:
|
|
87
|
+
|
|
88
|
+
| Instance | Validator | Generated type | What a failure would mean |
|
|
89
|
+
| --- | --- | --- | --- |
|
|
90
|
+
| valid | accepts | **must** type-check | the type is **narrower** than the schema — it rejects data your service accepts |
|
|
91
|
+
| structurally invalid | rejects | **must not** type-check | the type is **wider** than the schema — it certifies data your service rejects |
|
|
92
|
+
| constraint-invalid | rejects | **does** type-check | the documented widening — see below |
|
|
93
|
+
|
|
94
|
+
The third row is the honest one. TypeScript cannot express `minLength`,
|
|
95
|
+
`pattern` or `format`, so a value can be type-correct and schema-invalid.
|
|
96
|
+
Rather than hide that, the corpus asserts it, and the generator writes the
|
|
97
|
+
dropped constraint into the generated file:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
/**
|
|
101
|
+
* Schema constraints this type cannot express: minLength=3
|
|
102
|
+
*/
|
|
103
|
+
id: string;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The type says `string`. The comment says the schema also demands a minimum
|
|
107
|
+
length that the type does not enforce. A reader learns both. **Widening
|
|
108
|
+
silently would be a lie of omission**, and it is the single most common way a
|
|
109
|
+
generated type misleads the person reading it.
|
|
110
|
+
|
|
111
|
+
The rig is checked against itself, too: deliberately breaking the generator so
|
|
112
|
+
it emits `unknown` everywhere makes the "not wider than the schema" assertion
|
|
113
|
+
fail. A verification suite that passes on a broken generator proves nothing.
|
|
114
|
+
|
|
115
|
+
## Accepted and normalized types
|
|
116
|
+
|
|
117
|
+
`@jarenjs/validate/normalize` makes a contract's input and output shapes
|
|
118
|
+
differ — a defaulted member is optional for the caller and present afterwards,
|
|
119
|
+
and a coerced member arrives in its transport form. Pass the same options to
|
|
120
|
+
the generator and it names both sides:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
jaren-emit --schema ./schemas --out ./types --defaults --coerce
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
export interface Config {
|
|
128
|
+
host: string; // present after normalizing
|
|
129
|
+
port: number;
|
|
130
|
+
name: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Accepted input for Config: the shape before normalization, where defaulted
|
|
135
|
+
* members may be absent and coercible values may still be in their transport form.
|
|
136
|
+
*/
|
|
137
|
+
export interface ConfigInput {
|
|
138
|
+
host?: string | number | boolean; // optional, and widened to what
|
|
139
|
+
port?: number | string; // the normalizer will convert FROM
|
|
140
|
+
name: string | number | boolean;
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
That pair is exactly what a `Contract<Input, Output>` boundary wants: the
|
|
145
|
+
handler signature takes `ConfigInput`, the rest of the program handles
|
|
146
|
+
`Config`, and the normalizer is the transition between them.
|
|
147
|
+
|
|
148
|
+
**A twin appears only where the type actually differs.** The generator works
|
|
149
|
+
that out bottom-up, so a schema with one defaulted field does not double every
|
|
150
|
+
declaration; everything unaffected keeps a single shared name on both sides.
|
|
151
|
+
|
|
152
|
+
**Only two normalizations produce a difference.** `useDefaults` moves a member
|
|
153
|
+
across the optional boundary and `coerceTypes` widens what the input accepts.
|
|
154
|
+
`trimStrings` is string-to-string, and `removeAdditional` removes members no
|
|
155
|
+
type ever declared — neither earns a second declaration.
|
|
156
|
+
|
|
157
|
+
The switch resolution, including per-field predicates, is **imported from the
|
|
158
|
+
normalizer rather than reimplemented**. Two copies of that rule would drift,
|
|
159
|
+
and a variant that disagrees with the normalizer is worse than no variant: it
|
|
160
|
+
is a type certifying an input the normalizer will not take. The agreement
|
|
161
|
+
suite checks the pair the same way it checks everything else — a raw input
|
|
162
|
+
must satisfy `ConfigInput` and not `Config`, and normalizing it at runtime
|
|
163
|
+
must produce something the `Config` side describes.
|
|
164
|
+
|
|
165
|
+
**The variants stop where the normalizer stops.** `compileNormalizer` does
|
|
166
|
+
not descend `anyOf`/`oneOf` — which branch applies is only known after
|
|
167
|
+
validating — so inside a union branch no default materializes and no coercion
|
|
168
|
+
runs, and the generated pair says the same: branch members keep their
|
|
169
|
+
declared optionality and their declared scalar types on *both* sides. When a
|
|
170
|
+
branch references a type that does differ elsewhere, the branch points at a
|
|
171
|
+
`Plain`-suffixed declaration carrying the schema's as-declared reading.
|
|
172
|
+
Literals get the same treatment in the other direction: an integer `enum`
|
|
173
|
+
under `--coerce` accepts its transport string on the input side (`1 | 2 |
|
|
174
|
+
string`), because the normalizer coerces `"2"` to `2` before the enum check
|
|
175
|
+
runs.
|
|
176
|
+
|
|
177
|
+
## Two stages, and why
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
schema ──▶ compileEmitModel ──▶ TYPE MODEL ──▶ stylesheet ──▶ artifact
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The templating is the easy half. The hard half is that a schema graph is not
|
|
184
|
+
shaped like a declaration file: `$ref`s point sideways and in cycles,
|
|
185
|
+
subschemas nest anonymously, `allOf` means intersection while `anyOf` means
|
|
186
|
+
union, and half the vocabulary has no type-level meaning. **Stage one** does
|
|
187
|
+
that flattening once — resolving refs, breaking cycles by name, deriving
|
|
188
|
+
identifiers, widening honestly — into a flat list of named declarations.
|
|
189
|
+
|
|
190
|
+
**Stage two** is a JTLT stylesheet per target. TypeScript and Markdown ship;
|
|
191
|
+
both read the same model, and neither has privileged access to it.
|
|
192
|
+
|
|
193
|
+
The [type model](./docs/EMIT-FORMAT.md) is a **published format** with its own
|
|
194
|
+
[JSON Schema](./schemas/jaren-emit-model.schema.json) — and the test suite
|
|
195
|
+
validates every model the package produces against it, so "published format"
|
|
196
|
+
stays true rather than becoming decoration. A third-party emitter targeting
|
|
197
|
+
the model is exactly as capable as the bundled ones.
|
|
198
|
+
|
|
199
|
+
Markdown exists mainly as evidence for that claim: it is as unlike TypeScript
|
|
200
|
+
as a target gets, and adding it required **no change to stage one**. If the
|
|
201
|
+
model had secretly been the TypeScript printer's private state, writing it
|
|
202
|
+
would have forced one.
|
|
203
|
+
|
|
204
|
+
## CLI
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
jaren-emit --schema <file|dir> --out <dir> [options]
|
|
208
|
+
|
|
209
|
+
--target <name> typescript (default) or markdown
|
|
210
|
+
--name <Name> root declaration name for a single schema
|
|
211
|
+
--bundle <file> one output file instead of one per schema
|
|
212
|
+
--check write nothing; exit 1 if any output is out of date
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`--check` is the CI guard: it fails the build when a schema changed and the
|
|
216
|
+
generated types did not, which is the failure mode that makes generated code
|
|
217
|
+
untrustworthy in the first place.
|
|
218
|
+
|
|
219
|
+
`--bundle` compiles every schema into **one name space**: a `$defs.Id` that
|
|
220
|
+
two schemas both declare comes out as `Id` and `Id2`, deterministically in
|
|
221
|
+
sorted-file order, instead of two colliding declarations. The programmatic
|
|
222
|
+
equivalent is the `reserved` option of `compileEmitModel`.
|
|
223
|
+
|
|
224
|
+
## What it maps
|
|
225
|
+
|
|
226
|
+
| JSON Schema | TypeScript |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `type: 'string' \| 'number' \| 'integer' \| 'boolean' \| 'null'` | the primitive (`integer` → `number`) |
|
|
229
|
+
| `const` / `enum` | a literal / a union of literals |
|
|
230
|
+
| `properties` + `required` | interface members, optional when not required |
|
|
231
|
+
| `additionalProperties` / `patternProperties` | an index signature, widened to cover the declared members |
|
|
232
|
+
| *omitted* `additionalProperties` | `[key: string]: unknown` — the object is **open**, see below |
|
|
233
|
+
| `additionalProperties: false` | a closed interface, with no index signature; with no members at all, `Record<string, never>` (an empty interface would let a primitive through) |
|
|
234
|
+
| `items` | `Array<T>` |
|
|
235
|
+
| `prefixItems`, array-form `items` | a tuple: the first `minItems` positions required, the rest optional, and an **open** rest (`...Array<unknown>`) unless `items: false`/`additionalItems: false` closes it — JSON Schema accepts shorter and longer arrays, so the type does too |
|
|
236
|
+
| `$ref` (same document, including cycles) | a reference to the named declaration — `#/pointer` and plain `#anchor` forms, resolving exactly as `compileNormalizer` resolves them; a root `$ref` aliases its target, and 2019-09+ siblings intersect with it |
|
|
237
|
+
| `allOf` | an intersection |
|
|
238
|
+
| `anyOf`, `oneOf` | a union |
|
|
239
|
+
| `properties`/`items` with **no `type`** | the container shape as one union arm, plus the other JSON kinds — applicators do not imply a container, and the validator accepts a primitive without reading them |
|
|
240
|
+
| `description` | a doc comment |
|
|
241
|
+
| `default` (with `--defaults`) | optional on the accepted side — even when `required` lists it, since the normalizer materializes it before validation — and present on the normalized side |
|
|
242
|
+
|
|
243
|
+
### Objects are open unless the schema closes them
|
|
244
|
+
|
|
245
|
+
A JSON Schema object accepts members it never declared. That is the default,
|
|
246
|
+
and it is easy to forget when reading a schema that lists four properties and
|
|
247
|
+
looks like a struct. So an interface generated from one carries an index
|
|
248
|
+
signature, and only `additionalProperties: false` removes it.
|
|
249
|
+
|
|
250
|
+
This costs something real: with an index signature TypeScript stops flagging a
|
|
251
|
+
misspelled property, because the misspelling is a legal member. The trade is
|
|
252
|
+
deliberate. A type that is **narrower** than its schema rejects a document your
|
|
253
|
+
service accepts — the caller is told their payload is wrong by the very
|
|
254
|
+
artifact that promised to describe it, and no amount of local convenience is
|
|
255
|
+
worth a generated type that lies in that direction. If you want the tighter
|
|
256
|
+
type, say so in the schema with `additionalProperties: false` and get it
|
|
257
|
+
honestly, or pass `openObjects: 'closed'` and own the divergence.
|
|
258
|
+
|
|
259
|
+
Both directions are pinned by the agreement corpus: an open schema's extra
|
|
260
|
+
member must type-check, and a closed schema's must not.
|
|
261
|
+
|
|
262
|
+
**Widened, with the constraint recorded**: `minLength`, `maxLength`,
|
|
263
|
+
`pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`,
|
|
264
|
+
`exclusiveMaximum`, `multipleOf`, `minItems`, `maxItems`, `uniqueItems`,
|
|
265
|
+
`contains`, `minProperties`, `maxProperties`, `propertyNames`,
|
|
266
|
+
`dependentRequired`, `dependentSchemas`/`dependencies`, `not`,
|
|
267
|
+
`if`/`then`/`else`, constraining `unevaluatedProperties`/`unevaluatedItems`,
|
|
268
|
+
integer-ness (`type: "integer"` emits as `number`), the key restrictions of
|
|
269
|
+
`patternProperties`, and the Jaren extension keywords. Nothing on this list
|
|
270
|
+
narrows a type, and nothing on it disappears silently: each is written into
|
|
271
|
+
the generated file's doc comment.
|
|
272
|
+
|
|
273
|
+
**Not mapped**: `if`/`then`/`else` and `not` contribute nothing to the type —
|
|
274
|
+
they have no sound type-level equivalent, so they are recorded (see above)
|
|
275
|
+
rather than guessed at. Cross-document `$ref`s are not followed — a model
|
|
276
|
+
compiles the document it was handed — and an unresolvable reference
|
|
277
|
+
contributes nothing, leaving the node honestly wider.
|
|
278
|
+
|
|
279
|
+
## Honest comparison
|
|
280
|
+
|
|
281
|
+
[`json-schema-to-ts`](https://github.com/ThomasAribart/json-schema-to-ts)
|
|
282
|
+
computes types *in the type system* from schema literals. This generates
|
|
283
|
+
*source*. Different mechanism, different trade:
|
|
284
|
+
|
|
285
|
+
- **Theirs** needs no build step and stays exact as you edit a literal.
|
|
286
|
+
- **Ours** works on schemas that live in `.json` files, produces declarations
|
|
287
|
+
a human can read and review in a diff, emits non-TypeScript targets, and can
|
|
288
|
+
be checked against the validator that will actually run.
|
|
289
|
+
|
|
290
|
+
If your schemas are TypeScript literals and you want zero build steps, use
|
|
291
|
+
theirs. If your schemas are documents — published, shared with other
|
|
292
|
+
languages, or fed to an LLM's structured-output mode — this is the one that
|
|
293
|
+
fits.
|
|
294
|
+
|
|
295
|
+
## Development
|
|
296
|
+
|
|
297
|
+
Tests live in `test/emit/` at the repository root (`npm run test:emit`).
|
|
298
|
+
`agreement.test.js` is the cyclic verification and needs the workspace's
|
|
299
|
+
TypeScript. The internals are described in [ARCHITECTURE.md](./ARCHITECTURE.md);
|
|
300
|
+
the model contract is in [EMIT-FORMAT.md](./docs/EMIT-FORMAT.md).
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Reference documentation for a type model. */
|
|
2
|
+
export declare const MARKDOWN_STYLESHEET: {
|
|
3
|
+
$jtlt: string;
|
|
4
|
+
output: string;
|
|
5
|
+
rules: ({
|
|
6
|
+
match: string;
|
|
7
|
+
body: {
|
|
8
|
+
$apply: string[];
|
|
9
|
+
}[][];
|
|
10
|
+
priority?: undefined;
|
|
11
|
+
mode?: undefined;
|
|
12
|
+
} | {
|
|
13
|
+
match: {
|
|
14
|
+
schema: {
|
|
15
|
+
type: string;
|
|
16
|
+
properties: {
|
|
17
|
+
kind: {
|
|
18
|
+
const: any;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
required: string[];
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
mode: string;
|
|
25
|
+
body: (string | {
|
|
26
|
+
$apply: string[];
|
|
27
|
+
}[] | {
|
|
28
|
+
$raw: string;
|
|
29
|
+
})[];
|
|
30
|
+
priority?: undefined;
|
|
31
|
+
} | {
|
|
32
|
+
match: {
|
|
33
|
+
schema: {
|
|
34
|
+
type: string;
|
|
35
|
+
properties: {
|
|
36
|
+
kind: {
|
|
37
|
+
const: any;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
required: string[];
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
mode: string;
|
|
44
|
+
priority: number;
|
|
45
|
+
body: (string | {
|
|
46
|
+
$apply: string[];
|
|
47
|
+
}[])[];
|
|
48
|
+
} | {
|
|
49
|
+
mode: string;
|
|
50
|
+
priority: number;
|
|
51
|
+
body: (string | {
|
|
52
|
+
$apply: string[];
|
|
53
|
+
}[])[];
|
|
54
|
+
match?: undefined;
|
|
55
|
+
} | {
|
|
56
|
+
priority?: undefined;
|
|
57
|
+
mode: string;
|
|
58
|
+
body: (string | {
|
|
59
|
+
$apply: string[];
|
|
60
|
+
}[] | {
|
|
61
|
+
$raw: string;
|
|
62
|
+
})[];
|
|
63
|
+
match?: undefined;
|
|
64
|
+
} | {
|
|
65
|
+
priority?: undefined;
|
|
66
|
+
mode: string;
|
|
67
|
+
body: {
|
|
68
|
+
$if: string[];
|
|
69
|
+
}[];
|
|
70
|
+
match?: undefined;
|
|
71
|
+
} | {
|
|
72
|
+
priority?: undefined;
|
|
73
|
+
match: {
|
|
74
|
+
schema: {
|
|
75
|
+
type: string;
|
|
76
|
+
properties: {
|
|
77
|
+
kind: {
|
|
78
|
+
const: any;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
required: string[];
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
mode: string;
|
|
85
|
+
body: {
|
|
86
|
+
$json: string;
|
|
87
|
+
}[];
|
|
88
|
+
})[];
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Render a type model as Markdown reference documentation.
|
|
92
|
+
* @param {import('./model.js').EmitModel} model - A type model from {@link compileEmitModel}
|
|
93
|
+
* @returns {string} Markdown
|
|
94
|
+
*/
|
|
95
|
+
export declare function renderMarkdown(model: import('./model.js').EmitModel): string;
|
|
96
|
+
/**
|
|
97
|
+
* Compile a JSON Schema straight to Markdown reference documentation.
|
|
98
|
+
* @param {object|boolean} schema - The schema to document
|
|
99
|
+
* @param {import('./model.js').EmitModelOptions} [options] - Model options
|
|
100
|
+
* @returns {string} Markdown
|
|
101
|
+
*/
|
|
102
|
+
export declare function emitMarkdown(schema: object | boolean, options?: import('./model.js').EmitModelOptions): string;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** The model format version this module produces and consumes. */
|
|
2
|
+
export declare const EMIT_MODEL_VERSION = "0.1";
|
|
3
|
+
export type NormalizeOptions = import('@jarenjs/validate/normalize').NormalizeOptions;
|
|
4
|
+
export type EmitConstraint = {
|
|
5
|
+
/**
|
|
6
|
+
* - The schema keyword
|
|
7
|
+
*/
|
|
8
|
+
keyword: string;
|
|
9
|
+
/**
|
|
10
|
+
* - The keyword's value in the source schema
|
|
11
|
+
*/
|
|
12
|
+
value?: any;
|
|
13
|
+
};
|
|
14
|
+
export type EmitTypeRef = {
|
|
15
|
+
kind: 'unknown' | 'never' | 'primitive' | 'literal' | 'ref' | 'array' | 'tuple' | 'optional' | 'record' | 'union' | 'intersection' | 'object';
|
|
16
|
+
/**
|
|
17
|
+
* - For `primitive`
|
|
18
|
+
*/
|
|
19
|
+
primitive?: 'string' | 'number' | 'boolean' | 'null';
|
|
20
|
+
/**
|
|
21
|
+
* - The JSON value of a `literal`, or the value type of a `record`
|
|
22
|
+
*/
|
|
23
|
+
value?: any;
|
|
24
|
+
/**
|
|
25
|
+
* - For `ref`: the referenced declaration name
|
|
26
|
+
*/
|
|
27
|
+
ref?: string;
|
|
28
|
+
/**
|
|
29
|
+
* - `array` item type, or `tuple` positional items
|
|
30
|
+
*/
|
|
31
|
+
items?: EmitTypeRef | EmitTypeRef[];
|
|
32
|
+
/**
|
|
33
|
+
* - For `tuple`: the rest type, when the tuple is open
|
|
34
|
+
*/
|
|
35
|
+
rest?: EmitTypeRef;
|
|
36
|
+
/**
|
|
37
|
+
* - For `optional`: the wrapped tuple element
|
|
38
|
+
*/
|
|
39
|
+
item?: EmitTypeRef;
|
|
40
|
+
/**
|
|
41
|
+
* - For `union` (at least two)
|
|
42
|
+
*/
|
|
43
|
+
options?: EmitTypeRef[];
|
|
44
|
+
/**
|
|
45
|
+
* - For `intersection` (at least two)
|
|
46
|
+
*/
|
|
47
|
+
parts?: EmitTypeRef[];
|
|
48
|
+
/**
|
|
49
|
+
* - For `object`
|
|
50
|
+
*/
|
|
51
|
+
members?: EmitMember[];
|
|
52
|
+
/**
|
|
53
|
+
* - For `object`: the index-signature value type
|
|
54
|
+
*/
|
|
55
|
+
index?: EmitTypeRef;
|
|
56
|
+
};
|
|
57
|
+
export type EmitMember = {
|
|
58
|
+
kind: 'member';
|
|
59
|
+
/**
|
|
60
|
+
* - The property name, verbatim
|
|
61
|
+
*/
|
|
62
|
+
name: string;
|
|
63
|
+
type: EmitTypeRef;
|
|
64
|
+
required: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* - The schema default, when it declares one
|
|
67
|
+
*/
|
|
68
|
+
default?: any;
|
|
69
|
+
constraints: EmitConstraint[];
|
|
70
|
+
doc: string[];
|
|
71
|
+
};
|
|
72
|
+
export type EmitDeclaration = {
|
|
73
|
+
kind: 'declaration';
|
|
74
|
+
/**
|
|
75
|
+
* - Unique, identifier-safe
|
|
76
|
+
*/
|
|
77
|
+
name: string;
|
|
78
|
+
type: EmitTypeRef;
|
|
79
|
+
constraints: EmitConstraint[];
|
|
80
|
+
doc: string[];
|
|
81
|
+
/**
|
|
82
|
+
* - Which side of normalization this declaration describes
|
|
83
|
+
*/
|
|
84
|
+
variant?: 'accepted' | 'normalized';
|
|
85
|
+
/**
|
|
86
|
+
* - For an accepted variant, its normalized counterpart
|
|
87
|
+
*/
|
|
88
|
+
variantOf?: string;
|
|
89
|
+
};
|
|
90
|
+
export type EmitModel = {
|
|
91
|
+
/**
|
|
92
|
+
* - The model format version
|
|
93
|
+
*/
|
|
94
|
+
$emit: string;
|
|
95
|
+
/**
|
|
96
|
+
* - Where the model came from
|
|
97
|
+
*/
|
|
98
|
+
source: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* - The declaration name of the schema's root
|
|
101
|
+
*/
|
|
102
|
+
root: string | null;
|
|
103
|
+
/**
|
|
104
|
+
* - Present when accepted/normalized pairs were derived
|
|
105
|
+
*/
|
|
106
|
+
variants?: true;
|
|
107
|
+
declarations: EmitDeclaration[];
|
|
108
|
+
};
|
|
109
|
+
export type EmitModelOptions = {
|
|
110
|
+
/**
|
|
111
|
+
* - The name for the root declaration
|
|
112
|
+
*/
|
|
113
|
+
name?: string;
|
|
114
|
+
/**
|
|
115
|
+
* - A source identifier recorded in the model
|
|
116
|
+
*/
|
|
117
|
+
source?: string;
|
|
118
|
+
/**
|
|
119
|
+
* - How to treat an object
|
|
120
|
+
* whose `additionalProperties` is omitted. JSON Schema says such an object
|
|
121
|
+
* is open, so the default emits an index signature; `'closed'` opts into the
|
|
122
|
+
* tighter type, which regains excess-property checking at the cost of
|
|
123
|
+
* rejecting documents the schema accepts.
|
|
124
|
+
*/
|
|
125
|
+
openObjects?: 'open' | 'closed';
|
|
126
|
+
/**
|
|
127
|
+
* - When set, derive
|
|
128
|
+
* accepted/normalized variant pairs with exactly these `compileNormalizer`
|
|
129
|
+
* options
|
|
130
|
+
*/
|
|
131
|
+
normalize?: NormalizeOptions | null;
|
|
132
|
+
/**
|
|
133
|
+
* - The suffix for accepted-variant
|
|
134
|
+
* declaration names
|
|
135
|
+
*/
|
|
136
|
+
variantSuffix?: string;
|
|
137
|
+
/**
|
|
138
|
+
* - Declaration names already taken outside
|
|
139
|
+
* this model. Bundling concatenates models into one file, so each model
|
|
140
|
+
* must be able to avoid the names its predecessors used.
|
|
141
|
+
*/
|
|
142
|
+
reserved?: string[];
|
|
143
|
+
/**
|
|
144
|
+
* - Extension keyword names (typically
|
|
145
|
+
* `x-*`) to PRESERVE: when a property schema carries one of these, the
|
|
146
|
+
* member node gains `extensions: { '<keyword>': value }` with the value
|
|
147
|
+
* copied verbatim. The compiler itself never interprets them — a
|
|
148
|
+
* downstream consumer of the model does. Keywords are read from the
|
|
149
|
+
* property node itself (a vocabulary declares them inline, not through
|
|
150
|
+
* `$ref`). Absent by default, so existing models are byte-identical.
|
|
151
|
+
*/
|
|
152
|
+
extensions?: string[];
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Compile one or more JSON Schemas into a type model.
|
|
156
|
+
*
|
|
157
|
+
* The model is a plain JSON document. It is the contract every emitter reads,
|
|
158
|
+
* and it is published as a schema so a third-party emitter can target it too.
|
|
159
|
+
* @param {object|boolean} schema - The root schema
|
|
160
|
+
* @param {EmitModelOptions} [options] - Compile options
|
|
161
|
+
* @returns {EmitModel} The type model document
|
|
162
|
+
* @example
|
|
163
|
+
* const model = compileEmitModel({
|
|
164
|
+
* $defs: { Id: { type: 'string' } },
|
|
165
|
+
* type: 'object',
|
|
166
|
+
* properties: { id: { $ref: '#/$defs/Id' } },
|
|
167
|
+
* required: ['id'],
|
|
168
|
+
* }, { name: 'User' });
|
|
169
|
+
*/
|
|
170
|
+
export declare function compileEmitModel(schema: object | boolean, options?: EmitModelOptions): EmitModel;
|