@amritk/mjst 0.11.0 → 0.12.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/README.md +111 -1
- package/dist/cli-config.d.ts +40 -0
- package/dist/cli.js +78 -15
- package/dist/emit-examples.d.ts +36 -0
- package/dist/emit-examples.js +29 -0
- package/dist/has-external-refs.d.ts +8 -0
- package/dist/has-external-refs.js +22 -0
- package/dist/help-text.d.ts +1 -1
- package/dist/help-text.js +7 -0
- package/dist/lint/resolver.js +36 -25
- package/dist/load-config.js +5 -0
- package/dist/load-schema.d.ts +14 -0
- package/dist/load-schema.js +52 -0
- package/dist/parse-cli-args.js +43 -0
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -85,6 +85,30 @@ imports from that single shared location. `--build` compiles the whole tree in p
|
|
|
85
85
|
schema's root type is named after the schema (its `title`, else its filename in PascalCase),
|
|
86
86
|
so `user.json` yields `User` / `parseUser` and `api/order.json` yields `Order` / `parseOrder`.
|
|
87
87
|
|
|
88
|
+
### Validators
|
|
89
|
+
|
|
90
|
+
Pass `--validators` to also emit runtime validation functions beside the parsers. For every
|
|
91
|
+
generated type `X` you get a `validateX(input)` — returning a rich `ValidationResult` with
|
|
92
|
+
JSON-Pointer error paths — and an `isX(input)` boolean type guard:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx mjst --schema ./schema.json --out-dir ./generated --validators
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The validators carry the same schema-derived filenames as the parsers, so they land in a
|
|
99
|
+
`validators/` subdirectory to avoid colliding:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
generated/
|
|
103
|
+
schema.ts, index.ts ← parsers + types
|
|
104
|
+
validators/
|
|
105
|
+
schema.ts, index.ts ← validateX / isX
|
|
106
|
+
validation-result.ts ← ValidationResult / ValidationError contract
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This works with `--schema-dir` too (the `validators/` tree mirrors the parser layout) and with
|
|
110
|
+
`--build`. It cannot be combined with `--types-only` or `--out-file`, which emit no runtime code.
|
|
111
|
+
|
|
88
112
|
### Running the generated output
|
|
89
113
|
|
|
90
114
|
By default mjst emits relative imports with the literal `.ts` extension (e.g.
|
|
@@ -107,6 +131,38 @@ since tsc cannot emit from `.ts` specifiers.
|
|
|
107
131
|
> giving `parseSpecPlan` / `validateSpecPlanShape`). Pass `--root-type <Name>` to override it for
|
|
108
132
|
> a single `--schema` run. Update any imports that referenced `Document` / `parseDocument`.
|
|
109
133
|
|
|
134
|
+
### External and remote `$ref`s
|
|
135
|
+
|
|
136
|
+
A schema can split its definitions across files. When a schema references another
|
|
137
|
+
document — `{ "$ref": "./address.json" }` or `{ "$ref": "./common.json#/$defs/id" }` —
|
|
138
|
+
mjst dereferences those cross-file targets before generating, inlining them into a
|
|
139
|
+
single schema so the generated parsers cover the whole shape:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npx mjst --schema ./order.json --out-dir ./generated
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Same-document refs (`#/$defs/...`) are left untouched — the generator resolves those
|
|
146
|
+
itself into named type files, so nothing changes for schemas that only reference
|
|
147
|
+
themselves. Local cross-file refs resolve from disk with no extra flags. Remote
|
|
148
|
+
(`http(s)`) refs stay **offline by default**: a schema with a remote `$ref` fails
|
|
149
|
+
rather than making a network call unless you opt in.
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# Allow remote $ref fetches
|
|
153
|
+
npx mjst --schema ./order.json --out-dir ./generated --resolve-remote
|
|
154
|
+
|
|
155
|
+
# …restricted to specific hosts (implies --resolve-remote)
|
|
156
|
+
npx mjst --schema ./order.json --out-dir ./generated --allowed-hosts schemas.example.com
|
|
157
|
+
|
|
158
|
+
# …permitting private/loopback hosts (off by default as an SSRF guard)
|
|
159
|
+
npx mjst --schema ./order.json --out-dir ./generated --resolve-remote --allow-private-hosts
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Unresolvable references (a missing file, a refused host, a bad URL) fail the run with
|
|
163
|
+
the underlying reason rather than emitting a half-resolved schema. This works with
|
|
164
|
+
`--schema-dir` too — each schema resolves its own external references.
|
|
165
|
+
|
|
110
166
|
### Config file
|
|
111
167
|
|
|
112
168
|
```bash
|
|
@@ -240,6 +296,24 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
|
|
|
240
296
|
<td colspan="4">Generate only TypeScript type definitions without parser functions. Useful when you only need the type shapes and do not need runtime validation.</td>
|
|
241
297
|
</tr>
|
|
242
298
|
<tr>
|
|
299
|
+
<td>✅ <code>validators</code></td>
|
|
300
|
+
<td><code>--validators</code></td>
|
|
301
|
+
<td><code>boolean</code></td>
|
|
302
|
+
<td align="center"><code>false</code></td>
|
|
303
|
+
</tr>
|
|
304
|
+
<tr>
|
|
305
|
+
<td colspan="4">Also emit validation functions alongside the parsers. For every generated type X the CLI writes a validateX (returning a rich ValidationResult with JSON-Pointer error paths) and an isX boolean type guard. The files land in a validators/ subdirectory of the output so they never collide with the parser files, which share the same schema-derived names. Works with both schema and schemaDir. Incompatible with typesOnly and outFile, which produce no runtime code.</td>
|
|
306
|
+
</tr>
|
|
307
|
+
<tr>
|
|
308
|
+
<td>🎲 <code>examples</code></td>
|
|
309
|
+
<td><code>--examples</code></td>
|
|
310
|
+
<td><code>boolean</code></td>
|
|
311
|
+
<td align="center"><code>false</code></td>
|
|
312
|
+
</tr>
|
|
313
|
+
<tr>
|
|
314
|
+
<td colspan="4">Also emit test-data files for every schema into an examples/ subdirectory of the output destination: a fast-check arbitrary (FooArbitrary) that produces schema-valid values and a concrete fooExample value. Under schemaDir the examples mirror the schema layout so they never collide with the parser output. The generated arbitraries import fast-check, which consumers must install as a (dev) dependency; the static example values have no runtime dependencies.</td>
|
|
315
|
+
</tr>
|
|
316
|
+
<tr>
|
|
243
317
|
<td>🔨 <code>build</code></td>
|
|
244
318
|
<td><code>--build</code></td>
|
|
245
319
|
<td><code>boolean</code></td>
|
|
@@ -312,6 +386,15 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
|
|
|
312
386
|
<td colspan="4">Suffix appended to every generated type name derived from a $ref (e.g. 'Object' turns Contact into ContactObject). Defaults to no suffix. The root type name is used verbatim and is unaffected.</td>
|
|
313
387
|
</tr>
|
|
314
388
|
<tr>
|
|
389
|
+
<td>🪧 <code>banner</code></td>
|
|
390
|
+
<td><code>--banner [text]</code></td>
|
|
391
|
+
<td><code>boolean | string</code></td>
|
|
392
|
+
<td align="center"><code>false</code></td>
|
|
393
|
+
</tr>
|
|
394
|
+
<tr>
|
|
395
|
+
<td colspan="4">Prepend a comment header to every generated file (excluding _helpers/). true uses the default message ('This file was auto-generated by @amritk/mjst…'); a string uses that text as the message body, wrapped in a JSDoc block; false or omitted emits no header. On the CLI, --banner alone enables the default message and --banner <text> sets a custom one.</td>
|
|
396
|
+
</tr>
|
|
397
|
+
<tr>
|
|
315
398
|
<td>🔗 <code>importExt</code></td>
|
|
316
399
|
<td><code>--import-ext <ext></code></td>
|
|
317
400
|
<td><code>string</code></td>
|
|
@@ -330,13 +413,40 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
|
|
|
330
413
|
<td colspan="4">Name for the root type of a single --schema run (e.g. 'Program' yields parseProgram / validateProgramShape). When omitted, the name is derived from the schema's title, falling back to the schema filename in PascalCase (spec-plan.json → SpecPlan) and then to 'Document'. Not supported with --schema-dir, where each schema derives its own root type from its filename.<br><strong>Examples:</strong> <code>"Program"</code></td>
|
|
331
414
|
</tr>
|
|
332
415
|
<tr>
|
|
416
|
+
<td>🌐 <code>resolveRemote</code></td>
|
|
417
|
+
<td><code>--resolve-remote</code></td>
|
|
418
|
+
<td><code>boolean</code></td>
|
|
419
|
+
<td align="center"><code>false</code></td>
|
|
420
|
+
</tr>
|
|
421
|
+
<tr>
|
|
422
|
+
<td colspan="4">Allow fetching http(s) $refs while dereferencing a schema that contains cross-file or remote references. Off by default, so generation stays offline unless asked: a schema with a remote $ref fails rather than making a network call. Local cross-file $refs are always resolved and do not need this flag. A non-empty allowedHosts implies this.</td>
|
|
423
|
+
</tr>
|
|
424
|
+
<tr>
|
|
425
|
+
<td>🛡️ <code>allowedHosts</code></td>
|
|
426
|
+
<td><code>--allowed-hosts <hosts></code></td>
|
|
427
|
+
<td><code>array</code></td>
|
|
428
|
+
<td align="center"></td>
|
|
429
|
+
</tr>
|
|
430
|
+
<tr>
|
|
431
|
+
<td colspan="4">Restrict remote $ref fetches to these hosts (e.g. api.example.com). Accepts a comma-separated list or the flag repeated. Providing an allow-list is itself an opt-in to remote fetching (it implies resolveRemote), and each listed host bypasses the private-host guard.<br><strong>Examples:</strong> <code>["schemas.example.com"]</code></td>
|
|
432
|
+
</tr>
|
|
433
|
+
<tr>
|
|
434
|
+
<td>🔓 <code>allowPrivateHosts</code></td>
|
|
435
|
+
<td><code>--allow-private-hosts</code></td>
|
|
436
|
+
<td><code>boolean</code></td>
|
|
437
|
+
<td align="center"><code>false</code></td>
|
|
438
|
+
</tr>
|
|
439
|
+
<tr>
|
|
440
|
+
<td colspan="4">Permit remote $refs to private, loopback, and link-local hosts. Off by default: such targets are refused as a best-effort SSRF guard (notably the 169.254.169.254 cloud-metadata endpoint). An explicit allowedHosts entry always bypasses this guard.</td>
|
|
441
|
+
</tr>
|
|
442
|
+
<tr>
|
|
333
443
|
<td>⚙️ <code>config</code></td>
|
|
334
444
|
<td><code>--config <path></code></td>
|
|
335
445
|
<td><code>string</code></td>
|
|
336
446
|
<td align="center"></td>
|
|
337
447
|
</tr>
|
|
338
448
|
<tr>
|
|
339
|
-
<td colspan="4">Path to a JSON config file. Keys match the option names in this schema (schema, schemaDir, outDir, outFile, typesOnly, build, logWarnings, strict, readonly, helpers, typeSuffix, importExt, rootType). CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
|
|
449
|
+
<td colspan="4">Path to a JSON config file. Keys match the option names in this schema (schema, schemaDir, input, export, outDir, outFile, typesOnly, validators, examples, build, logWarnings, strict, stripUnknown, caseInsensitive, readonly, helpers, typeSuffix, banner, importExt, rootType, resolveRemote, allowedHosts, allowPrivateHosts). CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
|
|
340
450
|
</tr>
|
|
341
451
|
</tbody>
|
|
342
452
|
</table>
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -36,6 +36,25 @@ export type CliConfig = {
|
|
|
36
36
|
* Useful when you only need the type shapes and do not need runtime validation.
|
|
37
37
|
*/
|
|
38
38
|
readonly typesOnly?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* When true, also emit validation functions alongside the parsers. For every
|
|
41
|
+
* generated type `X` the CLI writes a `validateX` (returning a rich
|
|
42
|
+
* `ValidationResult` with JSON-Pointer error paths) and an `isX` boolean type
|
|
43
|
+
* guard. The files land in a `validators/` subdirectory of the output so they
|
|
44
|
+
* never collide with the parser files, which share the same schema-derived
|
|
45
|
+
* names. Works with both `schema` and `schemaDir`. Incompatible with
|
|
46
|
+
* `typesOnly` and `outFile`, which produce no runtime code.
|
|
47
|
+
*/
|
|
48
|
+
readonly validators?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* When true, also emit test-data files for every schema: a `fast-check`
|
|
51
|
+
* arbitrary (`FooArbitrary`) that produces schema-valid values and a concrete
|
|
52
|
+
* `fooExample` value. The files are written into an `examples/` subdirectory of
|
|
53
|
+
* the output destination (mirroring the schema layout under `--schema-dir`) so
|
|
54
|
+
* they never collide with the parser output. The generated arbitraries import
|
|
55
|
+
* `fast-check`, which consumers must install as a (dev) dependency.
|
|
56
|
+
*/
|
|
57
|
+
readonly examples?: boolean;
|
|
39
58
|
/**
|
|
40
59
|
* When true, compile the generated TypeScript files to .js and .d.ts output.
|
|
41
60
|
* The .ts source files are removed after compilation.
|
|
@@ -120,4 +139,25 @@ export type CliConfig = {
|
|
|
120
139
|
* Not supported with `schemaDir`, where each schema names its own root.
|
|
121
140
|
*/
|
|
122
141
|
readonly rootType?: string;
|
|
142
|
+
/**
|
|
143
|
+
* Allow fetching `http(s)` `$ref`s while dereferencing a schema that contains
|
|
144
|
+
* cross-file or remote references. Defaults to `false`: generation stays
|
|
145
|
+
* offline unless asked, so a schema with a remote `$ref` fails rather than
|
|
146
|
+
* making a network call. A non-empty `allowedHosts` implies this.
|
|
147
|
+
*/
|
|
148
|
+
readonly resolveRemote?: boolean;
|
|
149
|
+
/**
|
|
150
|
+
* If set, only these hosts (e.g. `api.example.com`) may be fetched for remote
|
|
151
|
+
* `$ref`s. Restricting to an allow-list is itself an opt-in to remote fetching
|
|
152
|
+
* (it implies `resolveRemote`), and each listed host bypasses the private-host
|
|
153
|
+
* guard.
|
|
154
|
+
*/
|
|
155
|
+
readonly allowedHosts?: readonly string[];
|
|
156
|
+
/**
|
|
157
|
+
* Permit remote `$ref`s to private, loopback, and link-local hosts. Defaults
|
|
158
|
+
* to `false`: such targets are refused as a best-effort SSRF guard (notably the
|
|
159
|
+
* `169.254.169.254` cloud-metadata endpoint). An explicit `allowedHosts` entry
|
|
160
|
+
* always bypasses this guard.
|
|
161
|
+
*/
|
|
162
|
+
readonly allowPrivateHosts?: boolean;
|
|
123
163
|
};
|
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { mkdir, readdir,
|
|
3
|
+
import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { getAdapter } from '@amritk/adapters/get-adapter';
|
|
7
6
|
import { buildSchema } from '@amritk/generate-parsers';
|
|
7
|
+
import { buildValidatorSchema } from '@amritk/generate-validators';
|
|
8
8
|
import { deriveRootTypeName } from '@amritk/helpers/derive-root-type-name';
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
import { combineGeneratedFiles } from './combine-files.js';
|
|
11
11
|
import { detectHelpersMode } from './detect-helpers-mode.js';
|
|
12
|
+
import { emitExamples } from './emit-examples.js';
|
|
12
13
|
import { HELP_TEXT } from './help-text.js';
|
|
13
14
|
import { loadConfig } from './load-config.js';
|
|
14
|
-
import {
|
|
15
|
+
import { loadSchema } from './load-schema.js';
|
|
15
16
|
import { parseCliArgs } from './parse-cli-args.js';
|
|
16
17
|
import { readVersion } from './read-version.js';
|
|
17
18
|
import { resolveImportExt } from './resolve-import-ext.js';
|
|
@@ -52,16 +53,6 @@ const extractConfigPath = (args) => {
|
|
|
52
53
|
}
|
|
53
54
|
return undefined;
|
|
54
55
|
};
|
|
55
|
-
/** Reads a JSON Schema off disk, or loads a module and converts it via its adapter. */
|
|
56
|
-
const loadSchema = async (config, schemaPath) => {
|
|
57
|
-
const inputFormat = config.input ?? 'json';
|
|
58
|
-
if (inputFormat === 'json') {
|
|
59
|
-
return JSON.parse(await readFile(schemaPath, 'utf-8'));
|
|
60
|
-
}
|
|
61
|
-
console.log(`Input format: ${inputFormat}`);
|
|
62
|
-
const source = await loadSchemaModule(schemaPath, config.export);
|
|
63
|
-
return getAdapter(inputFormat).toJSONSchema(source);
|
|
64
|
-
};
|
|
65
56
|
const DEFAULT_BANNER_TEXT = 'This file was auto-generated by @amritk/mjst.\n * Do not edit it manually — your changes will be overwritten on the next generation.';
|
|
66
57
|
const resolveBanner = (banner) => {
|
|
67
58
|
if (!banner)
|
|
@@ -75,6 +66,30 @@ const writeGeneratedFile = async (baseDir, filename, content) => {
|
|
|
75
66
|
await mkdir(dirname(filePath), { recursive: true });
|
|
76
67
|
await writeFile(filePath, content, 'utf-8');
|
|
77
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Emits fast-check arbitrary + example files for one schema (delegating to
|
|
71
|
+
* {@link emitExamples}) and logs each written path. The files land in an
|
|
72
|
+
* `examples/` subdirectory of `outputDir` so they never collide with the parser
|
|
73
|
+
* files, which share the same `<name>.ts` / `index.ts` names. `subDir` mirrors a
|
|
74
|
+
* schema's nested location under `--schema-dir`. The example sources are left out
|
|
75
|
+
* of `--build`: they import the `fast-check` peer dependency and are meant to be
|
|
76
|
+
* consumed by tests, not compiled alongside the runtime parsers.
|
|
77
|
+
*/
|
|
78
|
+
const runExamples = async (config, schema, rootTypeName, outputDir, subDir = '') => {
|
|
79
|
+
const written = await emitExamples({
|
|
80
|
+
schema: schema,
|
|
81
|
+
rootTypeName,
|
|
82
|
+
outputDir,
|
|
83
|
+
subDir,
|
|
84
|
+
bannerPrefix: config.banner ? resolveBanner(config.banner) : '',
|
|
85
|
+
// Spread conditionally: `exactOptionalPropertyTypes` rejects an explicit
|
|
86
|
+
// `undefined` for the optional `typeSuffix`.
|
|
87
|
+
...(config.typeSuffix !== undefined ? { typeSuffix: config.typeSuffix } : {}),
|
|
88
|
+
});
|
|
89
|
+
for (const path of written) {
|
|
90
|
+
console.log(`Generated example: ${path}`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
78
93
|
/**
|
|
79
94
|
* Compiles the generated `.ts` files to `.js`/`.d.ts` with a temporary tsconfig,
|
|
80
95
|
* then removes the intermediate sources. `tsFiles` are paths relative to `outputDir`.
|
|
@@ -145,6 +160,25 @@ const findJsonSchemas = async (dir) => {
|
|
|
145
160
|
}
|
|
146
161
|
return results;
|
|
147
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* Emits validator files (`validateX` / `isX`) for one schema into `validatorsSubDir`
|
|
165
|
+
* (a path relative to `outputDir`). The validators carry the same schema-derived
|
|
166
|
+
* filenames as the parsers, so they live in their own subdirectory to avoid
|
|
167
|
+
* colliding. Returns the written `.ts` paths relative to `outputDir` so the caller
|
|
168
|
+
* can fold them into a `--build` compilation.
|
|
169
|
+
*/
|
|
170
|
+
const runValidators = async (config, schema, rootTypeName, outputDir, validatorsSubDir) => {
|
|
171
|
+
const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix);
|
|
172
|
+
const written = [];
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
|
|
175
|
+
const relFilename = join(validatorsSubDir, file.filename);
|
|
176
|
+
await writeGeneratedFile(outputDir, relFilename, content);
|
|
177
|
+
written.push(relFilename);
|
|
178
|
+
console.log(`Generated: ${relFilename}`);
|
|
179
|
+
}
|
|
180
|
+
return written;
|
|
181
|
+
};
|
|
148
182
|
/** Generates parsers for a single schema (the original one-schema-in, one-outDir-out flow). */
|
|
149
183
|
const runSingle = async (config, schemaPath, outputDir) => {
|
|
150
184
|
const schema = await loadSchema(config, schemaPath);
|
|
@@ -159,11 +193,17 @@ const runSingle = async (config, schemaPath, outputDir) => {
|
|
|
159
193
|
await writeGeneratedFile(outputDir, file.filename, content);
|
|
160
194
|
console.log(`Generated: ${file.filename}`);
|
|
161
195
|
}
|
|
196
|
+
const validatorFiles = config.validators
|
|
197
|
+
? await runValidators(config, schema, rootTypeName, outputDir, 'validators')
|
|
198
|
+
: [];
|
|
199
|
+
if (config.examples) {
|
|
200
|
+
await runExamples(config, schema, rootTypeName, outputDir);
|
|
201
|
+
}
|
|
162
202
|
if (config.build) {
|
|
163
|
-
await buildOutput(outputDir, files.map((f) => f.filename), config.typesOnly);
|
|
203
|
+
await buildOutput(outputDir, [...files.map((f) => f.filename), ...validatorFiles], config.typesOnly);
|
|
164
204
|
}
|
|
165
205
|
else {
|
|
166
|
-
console.log(`\nTotal files generated: ${files.length}`);
|
|
206
|
+
console.log(`\nTotal files generated: ${files.length + validatorFiles.length}`);
|
|
167
207
|
}
|
|
168
208
|
};
|
|
169
209
|
/**
|
|
@@ -181,6 +221,9 @@ const runSingleFile = async (config, schemaPath, outFilePath) => {
|
|
|
181
221
|
const combined = combineGeneratedFiles(files);
|
|
182
222
|
await writeFile(outFilePath, config.banner ? resolveBanner(config.banner) + combined : combined, 'utf-8');
|
|
183
223
|
console.log(`Generated: ${relative(process.cwd(), outFilePath)}`);
|
|
224
|
+
if (config.examples) {
|
|
225
|
+
await runExamples(config, schema, rootTypeName, outputDir);
|
|
226
|
+
}
|
|
184
227
|
if (config.build) {
|
|
185
228
|
await buildOutput(outputDir, [basename(outFilePath)], config.typesOnly);
|
|
186
229
|
}
|
|
@@ -233,6 +276,16 @@ const runRecursive = async (config, schemaDir, outputDir) => {
|
|
|
233
276
|
writtenTsFiles.push(relativeFilename);
|
|
234
277
|
console.log(`Generated: ${relativeFilename}`);
|
|
235
278
|
}
|
|
279
|
+
if (config.validators) {
|
|
280
|
+
// Mirror the parser layout under a top-level `validators/` tree so each
|
|
281
|
+
// schema's validators sit beside their parser counterparts without sharing
|
|
282
|
+
// a filename.
|
|
283
|
+
const validatorFiles = await runValidators(config, schema, rootTypeName, outputDir, join('validators', relNoExt));
|
|
284
|
+
writtenTsFiles.push(...validatorFiles);
|
|
285
|
+
}
|
|
286
|
+
if (config.examples) {
|
|
287
|
+
await runExamples(config, schema, rootTypeName, outputDir, relNoExt);
|
|
288
|
+
}
|
|
236
289
|
}
|
|
237
290
|
for (const [filename, content] of sharedHelpers) {
|
|
238
291
|
await writeGeneratedFile(outputDir, filename, content);
|
|
@@ -293,6 +346,16 @@ const run = async () => {
|
|
|
293
346
|
console.error('Error: --import-ext ts cannot be combined with --build. Drop --build to keep runnable .ts sources.');
|
|
294
347
|
process.exit(1);
|
|
295
348
|
}
|
|
349
|
+
// Validators are runtime code (validateX / isX), so they have nothing to emit
|
|
350
|
+
// in the type-only flows.
|
|
351
|
+
if (config.validators && config.typesOnly) {
|
|
352
|
+
console.error('Error: --validators cannot be combined with --types-only, which emits no runtime code.');
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
if (config.validators && config.outFile) {
|
|
356
|
+
console.error('Error: --validators cannot be combined with --out-file. Use --out-dir to emit validator files.');
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
296
359
|
// Single-file output is the alternative to a directory of files.
|
|
297
360
|
if (config.outFile) {
|
|
298
361
|
if (!config.typesOnly) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Inputs for {@link emitExamples}. The schema and its root type name are the same
|
|
4
|
+
* ones handed to the parser generator, so the arbitraries/examples line up with
|
|
5
|
+
* the parsers that consume them.
|
|
6
|
+
*/
|
|
7
|
+
export type EmitExamplesOptions = {
|
|
8
|
+
readonly schema: JSONSchema;
|
|
9
|
+
readonly rootTypeName: string;
|
|
10
|
+
/** Output root; example files always land under `<outputDir>/examples/`. */
|
|
11
|
+
readonly outputDir: string;
|
|
12
|
+
/**
|
|
13
|
+
* Nested location of the schema relative to `--schema-dir` (e.g. `api/order`),
|
|
14
|
+
* mirrored beneath `examples/` so each schema's test data sits beside where its
|
|
15
|
+
* parsers were emitted. Defaults to `''` (single-schema output at the root).
|
|
16
|
+
*/
|
|
17
|
+
readonly subDir?: string;
|
|
18
|
+
/** Suffix appended to every `$ref`-derived type/arbitrary name. Defaults to `''`. */
|
|
19
|
+
readonly typeSuffix?: string;
|
|
20
|
+
/** Header comment (already wrapped in a JSDoc block) prepended to each file, or `''`. */
|
|
21
|
+
readonly bannerPrefix?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Emits fast-check arbitrary + concrete example files for one schema into an
|
|
25
|
+
* `examples/` subdirectory of `outputDir`.
|
|
26
|
+
*
|
|
27
|
+
* The dedicated subdirectory keeps the test-data output from colliding with the
|
|
28
|
+
* parser files, which otherwise share the same `<name>.ts` / `index.ts` names.
|
|
29
|
+
* Each generated file exports a `FooArbitrary` (a `fast-check` arbitrary that
|
|
30
|
+
* produces schema-valid values) and a static `fooExample` value; an `index.ts`
|
|
31
|
+
* barrel re-exports them. The arbitraries import `fast-check`, which consumers
|
|
32
|
+
* must install as a (dev) dependency.
|
|
33
|
+
*
|
|
34
|
+
* @returns The written file paths, relative to `outputDir`.
|
|
35
|
+
*/
|
|
36
|
+
export declare const emitExamples: (options: EmitExamplesOptions) => Promise<string[]>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import { buildExampleSchema } from '@amritk/generate-examples';
|
|
4
|
+
/**
|
|
5
|
+
* Emits fast-check arbitrary + concrete example files for one schema into an
|
|
6
|
+
* `examples/` subdirectory of `outputDir`.
|
|
7
|
+
*
|
|
8
|
+
* The dedicated subdirectory keeps the test-data output from colliding with the
|
|
9
|
+
* parser files, which otherwise share the same `<name>.ts` / `index.ts` names.
|
|
10
|
+
* Each generated file exports a `FooArbitrary` (a `fast-check` arbitrary that
|
|
11
|
+
* produces schema-valid values) and a static `fooExample` value; an `index.ts`
|
|
12
|
+
* barrel re-exports them. The arbitraries import `fast-check`, which consumers
|
|
13
|
+
* must install as a (dev) dependency.
|
|
14
|
+
*
|
|
15
|
+
* @returns The written file paths, relative to `outputDir`.
|
|
16
|
+
*/
|
|
17
|
+
export const emitExamples = async (options) => {
|
|
18
|
+
const { schema, rootTypeName, outputDir, subDir = '', typeSuffix, bannerPrefix = '' } = options;
|
|
19
|
+
const files = await buildExampleSchema(schema, rootTypeName, typeSuffix);
|
|
20
|
+
const exampleDir = join(outputDir, 'examples', subDir);
|
|
21
|
+
const written = [];
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
const filePath = join(exampleDir, file.filename);
|
|
24
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
25
|
+
await writeFile(filePath, bannerPrefix + file.content, 'utf-8');
|
|
26
|
+
written.push(relative(outputDir, filePath));
|
|
27
|
+
}
|
|
28
|
+
return written;
|
|
29
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `data` contains a reference into another document (a `$ref` value that
|
|
3
|
+
* is not a same-document `#...` fragment). Same-document references resolve
|
|
4
|
+
* without a disk round-trip, so callers can skip the file-backed resolver — and
|
|
5
|
+
* preserve behavior that depends on those internal refs — unless an external
|
|
6
|
+
* (cross-file or remote) target exists.
|
|
7
|
+
*/
|
|
8
|
+
export declare const hasExternalRefs: (data: unknown) => boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The reference keywords whose value may point at another document (a non-`#` target). */
|
|
2
|
+
const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef'];
|
|
3
|
+
/**
|
|
4
|
+
* Whether `data` contains a reference into another document (a `$ref` value that
|
|
5
|
+
* is not a same-document `#...` fragment). Same-document references resolve
|
|
6
|
+
* without a disk round-trip, so callers can skip the file-backed resolver — and
|
|
7
|
+
* preserve behavior that depends on those internal refs — unless an external
|
|
8
|
+
* (cross-file or remote) target exists.
|
|
9
|
+
*/
|
|
10
|
+
export const hasExternalRefs = (data) => {
|
|
11
|
+
if (data === null || typeof data !== 'object')
|
|
12
|
+
return false;
|
|
13
|
+
if (Array.isArray(data))
|
|
14
|
+
return data.some(hasExternalRefs);
|
|
15
|
+
const obj = data;
|
|
16
|
+
for (const keyword of REF_KEYWORDS) {
|
|
17
|
+
const value = obj[keyword];
|
|
18
|
+
if (typeof value === 'string' && !value.startsWith('#'))
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return Object.values(obj).some(hasExternalRefs);
|
|
22
|
+
};
|
package/dist/help-text.d.ts
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
* documents these flags (config.schema.json) is not shipped in the published
|
|
5
5
|
* package; the help-text test cross-checks that every flag is listed.
|
|
6
6
|
*/
|
|
7
|
-
export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --case-insensitive Normalize mis-cased enum/const strings to their exact casing\n --log-warnings console.warn on input keys not declared in the schema\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
|
|
7
|
+
export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --validators Also emit validateX/isX validators into a validators/ subdir\n --examples Also emit fast-check arbitraries + example values under examples/\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --case-insensitive Normalize mis-cased enum/const strings to their exact casing\n --log-warnings console.warn on input keys not declared in the schema\n\nReference resolution:\n --resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)\n --allowed-hosts <h> Restrict remote $ref fetches to these hosts (comma-separated; implies --resolve-remote)\n --allow-private-hosts Permit remote $refs to private/loopback hosts (SSRF guard, off by default)\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
|
package/dist/help-text.js
CHANGED
|
@@ -21,6 +21,8 @@ Output:
|
|
|
21
21
|
--out-dir <dir> Output directory for the generated files
|
|
22
22
|
--out-file <file> Single-file output instead of a directory (requires --types-only)
|
|
23
23
|
--types-only Generate type definitions only, without parser functions
|
|
24
|
+
--validators Also emit validateX/isX validators into a validators/ subdir
|
|
25
|
+
--examples Also emit fast-check arbitraries + example values under examples/
|
|
24
26
|
--build Compile the generated files to .js/.d.ts (implies --import-ext js)
|
|
25
27
|
--import-ext <ext> Extension on generated relative imports: ts (default) or js
|
|
26
28
|
--helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)
|
|
@@ -35,6 +37,11 @@ Validation:
|
|
|
35
37
|
--case-insensitive Normalize mis-cased enum/const strings to their exact casing
|
|
36
38
|
--log-warnings console.warn on input keys not declared in the schema
|
|
37
39
|
|
|
40
|
+
Reference resolution:
|
|
41
|
+
--resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)
|
|
42
|
+
--allowed-hosts <h> Restrict remote $ref fetches to these hosts (comma-separated; implies --resolve-remote)
|
|
43
|
+
--allow-private-hosts Permit remote $refs to private/loopback hosts (SSRF guard, off by default)
|
|
44
|
+
|
|
38
45
|
Misc:
|
|
39
46
|
--config <path> JSON config file with the same keys; CLI flags take precedence
|
|
40
47
|
--version, -v Print the CLI version
|
package/dist/lint/resolver.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve as resolvePath } from 'node:path';
|
|
3
|
-
import { createDocument, resolveSourceOriginFromMap, } from '@amritk/lint';
|
|
3
|
+
import { createDocument, DiagnosticSeverity, resolveSourceOriginFromMap, } from '@amritk/lint';
|
|
4
4
|
import { resolveRefs, resolveRefsFromFile } from '@amritk/resolve-refs';
|
|
5
5
|
import { parse as parseYaml } from '@amritk/yaml';
|
|
6
|
+
import { hasExternalRefs } from '../has-external-refs.js';
|
|
6
7
|
const isRemote = (location) => /^https?:\/\//i.test(location);
|
|
7
8
|
/**
|
|
8
9
|
* Parses a referenced document by extension: YAML for `.yaml`/`.yml`, JSON for
|
|
@@ -21,26 +22,6 @@ const parseDoc = (content, location) => {
|
|
|
21
22
|
return parseYaml(content);
|
|
22
23
|
}
|
|
23
24
|
};
|
|
24
|
-
/** The reference keywords whose value may point at another document (a non-`#` target). */
|
|
25
|
-
const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef'];
|
|
26
|
-
/**
|
|
27
|
-
* Whether `data` contains a reference into another document (a `$ref` value that
|
|
28
|
-
* is not a same-document `#...` fragment). Internal-only documents resolve fully
|
|
29
|
-
* in memory, so we avoid the disk round-trip unless an external target exists.
|
|
30
|
-
*/
|
|
31
|
-
const hasExternalRefs = (data) => {
|
|
32
|
-
if (data === null || typeof data !== 'object')
|
|
33
|
-
return false;
|
|
34
|
-
if (Array.isArray(data))
|
|
35
|
-
return data.some(hasExternalRefs);
|
|
36
|
-
const obj = data;
|
|
37
|
-
for (const keyword of REF_KEYWORDS) {
|
|
38
|
-
const value = obj[keyword];
|
|
39
|
-
if (typeof value === 'string' && !value.startsWith('#'))
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
return Object.values(obj).some(hasExternalRefs);
|
|
43
|
-
};
|
|
44
25
|
/** Whether the file at `absolute` reads back byte-for-byte as `input` (the document we linted). */
|
|
45
26
|
const readsBackAs = (absolute, input) => {
|
|
46
27
|
try {
|
|
@@ -82,6 +63,28 @@ const buildSourceSet = (rootDocument, resolved, origins, rootLocation) => {
|
|
|
82
63
|
origin: (path) => resolveSourceOriginFromMap(resolved, originMap, rootLocation, path),
|
|
83
64
|
};
|
|
84
65
|
};
|
|
66
|
+
/** A zero-width range at the document start, for resolve errors with no recoverable position. */
|
|
67
|
+
const DOCUMENT_START = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
68
|
+
/**
|
|
69
|
+
* Maps resolver failures ({@link ResolveError}) into lint findings so an
|
|
70
|
+
* unresolvable `$ref` — a typo'd pointer, a missing file, a refused/failed
|
|
71
|
+
* remote fetch — surfaces as a diagnostic instead of being silently dropped. An
|
|
72
|
+
* error carrying a JSON path is anchored to that node's position in the root
|
|
73
|
+
* document (best effort, `closest`); otherwise the finding is document-level.
|
|
74
|
+
*/
|
|
75
|
+
const toFindings = (errors, root) => errors.map((error) => {
|
|
76
|
+
const location = error.path.length > 0 ? root.getLocationForJsonPath(error.path, true) : undefined;
|
|
77
|
+
const finding = {
|
|
78
|
+
code: 'unresolved-ref',
|
|
79
|
+
message: error.message,
|
|
80
|
+
path: error.path,
|
|
81
|
+
severity: DiagnosticSeverity.Error,
|
|
82
|
+
range: location?.range ?? DOCUMENT_START,
|
|
83
|
+
};
|
|
84
|
+
if (root.source !== undefined)
|
|
85
|
+
finding.source = root.source;
|
|
86
|
+
return finding;
|
|
87
|
+
});
|
|
85
88
|
/**
|
|
86
89
|
* Builds a {@link LintResolver} backed by `@amritk/resolve-refs`, dereferencing
|
|
87
90
|
* `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor`
|
|
@@ -105,11 +108,19 @@ export const createLintResolver = (options = {}) => {
|
|
|
105
108
|
if (source && hasExternalRefs(document.data)) {
|
|
106
109
|
const absolute = resolvePath(source);
|
|
107
110
|
if (readsBackAs(absolute, input)) {
|
|
108
|
-
const { resolved, origins } = await resolveRefsFromFile(absolute, fromFileOptions);
|
|
109
|
-
return {
|
|
111
|
+
const { resolved, origins, errors } = await resolveRefsFromFile(absolute, fromFileOptions);
|
|
112
|
+
return {
|
|
113
|
+
resolved,
|
|
114
|
+
sources: buildSourceSet(document, resolved, origins, absolute),
|
|
115
|
+
...(errors.length > 0 ? { diagnostics: toFindings(errors, document) } : {}),
|
|
116
|
+
};
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
|
-
const { resolved, origins } = resolveRefs(document.data, { trackOrigins: true });
|
|
113
|
-
return {
|
|
119
|
+
const { resolved, origins, errors } = resolveRefs(document.data, { trackOrigins: true });
|
|
120
|
+
return {
|
|
121
|
+
resolved,
|
|
122
|
+
sources: buildSourceSet(document, resolved, origins, ''),
|
|
123
|
+
...(errors.length > 0 ? { diagnostics: toFindings(errors, document) } : {}),
|
|
124
|
+
};
|
|
114
125
|
};
|
|
115
126
|
};
|
package/dist/load-config.js
CHANGED
|
@@ -4,6 +4,7 @@ const SOURCE_FORMATS = ['json', 'typebox', 'zod', 'valibot', 'effect'];
|
|
|
4
4
|
const isSourceFormat = (value) => typeof value === 'string' && SOURCE_FORMATS.includes(value);
|
|
5
5
|
const isHelpersMode = (value) => value === 'package' || value === 'embedded';
|
|
6
6
|
const isImportExt = (value) => value === 'js' || value === 'ts';
|
|
7
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
|
7
8
|
/**
|
|
8
9
|
* Loads a JSON config file and returns the relevant CLI config properties.
|
|
9
10
|
* The config file should have the same keys as the CLI flags (schema, outDir).
|
|
@@ -24,6 +25,7 @@ export const loadConfig = async (configPath) => {
|
|
|
24
25
|
...(isSourceFormat(obj['input']) && { input: obj['input'] }),
|
|
25
26
|
...(typeof obj['export'] === 'string' && { export: obj['export'] }),
|
|
26
27
|
...(typeof obj['typesOnly'] === 'boolean' && { typesOnly: obj['typesOnly'] }),
|
|
28
|
+
...(typeof obj['examples'] === 'boolean' && { examples: obj['examples'] }),
|
|
27
29
|
...(typeof obj['build'] === 'boolean' && { build: obj['build'] }),
|
|
28
30
|
...(typeof obj['logWarnings'] === 'boolean' && { logWarnings: obj['logWarnings'] }),
|
|
29
31
|
...(typeof obj['strict'] === 'boolean' && { strict: obj['strict'] }),
|
|
@@ -36,5 +38,8 @@ export const loadConfig = async (configPath) => {
|
|
|
36
38
|
...(typeof obj['typeSuffix'] === 'string' && { typeSuffix: obj['typeSuffix'] }),
|
|
37
39
|
// `banner` is a boolean toggle or a custom header string, mirroring the CLI flag.
|
|
38
40
|
...((typeof obj['banner'] === 'boolean' || typeof obj['banner'] === 'string') && { banner: obj['banner'] }),
|
|
41
|
+
...(typeof obj['resolveRemote'] === 'boolean' && { resolveRemote: obj['resolveRemote'] }),
|
|
42
|
+
...(isStringArray(obj['allowedHosts']) && { allowedHosts: obj['allowedHosts'] }),
|
|
43
|
+
...(typeof obj['allowPrivateHosts'] === 'boolean' && { allowPrivateHosts: obj['allowPrivateHosts'] }),
|
|
39
44
|
};
|
|
40
45
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CliConfig } from './cli-config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reads a JSON Schema off disk, or loads a module and converts it via its adapter.
|
|
4
|
+
*
|
|
5
|
+
* For JSON input, a schema whose only references are same-document `#/...`
|
|
6
|
+
* pointers is parsed as-is — the generator resolves those internal `$ref`s
|
|
7
|
+
* itself into named type files, so inlining them here would collapse that
|
|
8
|
+
* structure. When a cross-file or remote `$ref` is present (which the generator
|
|
9
|
+
* cannot follow on its own), the schema is dereferenced with
|
|
10
|
+
* `@amritk/resolve-refs`, inlining every external target into a single document.
|
|
11
|
+
* Any resolve failures are surfaced as a CLI error rather than silently yielding
|
|
12
|
+
* a half-resolved schema.
|
|
13
|
+
*/
|
|
14
|
+
export declare const loadSchema: (config: Partial<CliConfig>, schemaPath: string) => Promise<unknown>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { getAdapter } from '@amritk/adapters/get-adapter';
|
|
3
|
+
import { resolveRefsFromFile } from '@amritk/resolve-refs';
|
|
4
|
+
import { hasExternalRefs } from './has-external-refs.js';
|
|
5
|
+
import { loadSchemaModule } from './load-schema-module.js';
|
|
6
|
+
/**
|
|
7
|
+
* Builds the `@amritk/resolve-refs` options from the CLI config, mirroring the
|
|
8
|
+
* safety posture of `mjst lint`: remote (`http(s)`) `$ref`s are refused unless
|
|
9
|
+
* opted into, a non-empty `allowedHosts` implies remote fetching, and private/
|
|
10
|
+
* loopback hosts stay blocked as an SSRF guard unless explicitly permitted.
|
|
11
|
+
*/
|
|
12
|
+
const buildResolveOptions = (config) => {
|
|
13
|
+
// A non-empty allow-list is itself an opt-in to remote fetching.
|
|
14
|
+
const remote = (config.resolveRemote ?? false) || (config.allowedHosts?.length ?? 0) > 0;
|
|
15
|
+
return {
|
|
16
|
+
remote,
|
|
17
|
+
...(config.allowedHosts ? { allowedHosts: [...config.allowedHosts] } : {}),
|
|
18
|
+
...(config.allowPrivateHosts ? { allowPrivateHosts: config.allowPrivateHosts } : {}),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/** Renders resolver failures into a single CLI error message. */
|
|
22
|
+
const formatResolveErrors = (schemaPath, errors) => {
|
|
23
|
+
const details = errors.map((error) => ` - ${error.message}`).join('\n');
|
|
24
|
+
return `Failed to resolve $refs in ${schemaPath}:\n${details}`;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Reads a JSON Schema off disk, or loads a module and converts it via its adapter.
|
|
28
|
+
*
|
|
29
|
+
* For JSON input, a schema whose only references are same-document `#/...`
|
|
30
|
+
* pointers is parsed as-is — the generator resolves those internal `$ref`s
|
|
31
|
+
* itself into named type files, so inlining them here would collapse that
|
|
32
|
+
* structure. When a cross-file or remote `$ref` is present (which the generator
|
|
33
|
+
* cannot follow on its own), the schema is dereferenced with
|
|
34
|
+
* `@amritk/resolve-refs`, inlining every external target into a single document.
|
|
35
|
+
* Any resolve failures are surfaced as a CLI error rather than silently yielding
|
|
36
|
+
* a half-resolved schema.
|
|
37
|
+
*/
|
|
38
|
+
export const loadSchema = async (config, schemaPath) => {
|
|
39
|
+
const inputFormat = config.input ?? 'json';
|
|
40
|
+
if (inputFormat === 'json') {
|
|
41
|
+
const data = JSON.parse(await readFile(schemaPath, 'utf-8'));
|
|
42
|
+
if (!hasExternalRefs(data))
|
|
43
|
+
return data;
|
|
44
|
+
const { resolved, errors } = await resolveRefsFromFile(schemaPath, buildResolveOptions(config));
|
|
45
|
+
if (errors.length > 0)
|
|
46
|
+
throw new Error(formatResolveErrors(schemaPath, errors));
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
49
|
+
console.log(`Input format: ${inputFormat}`);
|
|
50
|
+
const source = await loadSchemaModule(schemaPath, config.export);
|
|
51
|
+
return getAdapter(inputFormat).toJSONSchema(source);
|
|
52
|
+
};
|
package/dist/parse-cli-args.js
CHANGED
|
@@ -2,12 +2,16 @@ const SOURCE_FORMATS = ['json', 'typebox', 'zod', 'valibot', 'effect'];
|
|
|
2
2
|
// Boolean flags toggle on by presence and accept `--flag=false` to opt out.
|
|
3
3
|
const BOOLEAN_KEYS = new Set([
|
|
4
4
|
'typesOnly',
|
|
5
|
+
'validators',
|
|
6
|
+
'examples',
|
|
5
7
|
'build',
|
|
6
8
|
'logWarnings',
|
|
7
9
|
'strict',
|
|
8
10
|
'stripUnknown',
|
|
9
11
|
'caseInsensitive',
|
|
10
12
|
'readonly',
|
|
13
|
+
'resolveRemote',
|
|
14
|
+
'allowPrivateHosts',
|
|
11
15
|
]);
|
|
12
16
|
// Value flags consume the following argument (or `--flag=value`).
|
|
13
17
|
const VALUE_KEYS = new Set([
|
|
@@ -40,6 +44,20 @@ const parseImportExtValue = (value) => {
|
|
|
40
44
|
return undefined;
|
|
41
45
|
};
|
|
42
46
|
const parseInputValue = (value) => SOURCE_FORMATS.includes(value) ? value : undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Appends the hosts in `value` (comma-separated) onto `allowedHosts`. Repeating
|
|
49
|
+
* `--allowed-hosts` accumulates, so `--allowed-hosts a.com --allowed-hosts b.com`
|
|
50
|
+
* and `--allowed-hosts a.com,b.com` are equivalent. Blank entries are dropped.
|
|
51
|
+
*/
|
|
52
|
+
const appendAllowedHosts = (config, value) => {
|
|
53
|
+
const hosts = value
|
|
54
|
+
.split(',')
|
|
55
|
+
.map((host) => host.trim())
|
|
56
|
+
.filter((host) => host.length > 0);
|
|
57
|
+
if (hosts.length === 0)
|
|
58
|
+
return;
|
|
59
|
+
config.allowedHosts = [...(config.allowedHosts ?? []), ...hosts];
|
|
60
|
+
};
|
|
43
61
|
/** Assigns a value-flag onto the config. Returns false for unknown keys. */
|
|
44
62
|
const assignValue = (config, key, value) => {
|
|
45
63
|
switch (key) {
|
|
@@ -98,6 +116,12 @@ const assignBoolean = (config, key, value) => {
|
|
|
98
116
|
case 'typesOnly':
|
|
99
117
|
config.typesOnly = value;
|
|
100
118
|
return true;
|
|
119
|
+
case 'validators':
|
|
120
|
+
config.validators = value;
|
|
121
|
+
return true;
|
|
122
|
+
case 'examples':
|
|
123
|
+
config.examples = value;
|
|
124
|
+
return true;
|
|
101
125
|
case 'build':
|
|
102
126
|
config.build = value;
|
|
103
127
|
return true;
|
|
@@ -116,6 +140,12 @@ const assignBoolean = (config, key, value) => {
|
|
|
116
140
|
case 'readonly':
|
|
117
141
|
config.readonly = value;
|
|
118
142
|
return true;
|
|
143
|
+
case 'resolveRemote':
|
|
144
|
+
config.resolveRemote = value;
|
|
145
|
+
return true;
|
|
146
|
+
case 'allowPrivateHosts':
|
|
147
|
+
config.allowPrivateHosts = value;
|
|
148
|
+
return true;
|
|
119
149
|
default:
|
|
120
150
|
return false;
|
|
121
151
|
}
|
|
@@ -143,6 +173,9 @@ export const parseCliArgs = (args) => {
|
|
|
143
173
|
// --banner=false → false, --banner=true → true, --banner=<text> → custom string
|
|
144
174
|
config.banner = value === 'false' ? false : value === 'true' ? true : value;
|
|
145
175
|
}
|
|
176
|
+
else if (key === 'allowedHosts') {
|
|
177
|
+
appendAllowedHosts(config, value);
|
|
178
|
+
}
|
|
146
179
|
else if (BOOLEAN_KEYS.has(key)) {
|
|
147
180
|
assignBoolean(config, key, value !== 'false');
|
|
148
181
|
}
|
|
@@ -172,6 +205,16 @@ export const parseCliArgs = (args) => {
|
|
|
172
205
|
}
|
|
173
206
|
continue;
|
|
174
207
|
}
|
|
208
|
+
// --allowed-hosts takes a value (comma-separated) and accumulates when repeated.
|
|
209
|
+
if (key === 'allowedHosts') {
|
|
210
|
+
const value = args[i + 1];
|
|
211
|
+
if (value === undefined || value.startsWith('--')) {
|
|
212
|
+
throw new Error(`Flag "--${flagName}" expects a value.`);
|
|
213
|
+
}
|
|
214
|
+
appendAllowedHosts(config, value);
|
|
215
|
+
i++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
175
218
|
// Boolean flag: presence alone enables it, no value needed
|
|
176
219
|
if (BOOLEAN_KEYS.has(key)) {
|
|
177
220
|
assignBoolean(config, key, true);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/mjst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,13 +41,15 @@
|
|
|
41
41
|
"generate-readme": "bun run ./scripts/generate-readme.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@amritk/adapters": "0.
|
|
45
|
-
"@amritk/generate-
|
|
44
|
+
"@amritk/adapters": "0.3.0",
|
|
45
|
+
"@amritk/generate-examples": "0.5.0",
|
|
46
|
+
"@amritk/generate-parsers": "0.16.1",
|
|
47
|
+
"@amritk/generate-validators": "0.11.6",
|
|
46
48
|
"@amritk/generate-markdown": "0.4.1",
|
|
47
|
-
"@amritk/helpers": "0.
|
|
48
|
-
"@amritk/lint": "0.
|
|
49
|
-
"@amritk/resolve-refs": "0.
|
|
50
|
-
"@amritk/yaml": "0.
|
|
49
|
+
"@amritk/helpers": "0.13.0",
|
|
50
|
+
"@amritk/lint": "0.3.0",
|
|
51
|
+
"@amritk/resolve-refs": "0.4.0",
|
|
52
|
+
"@amritk/yaml": "0.3.0",
|
|
51
53
|
"fast-glob": "^3.3.2",
|
|
52
54
|
"yargs": "^17.7.2"
|
|
53
55
|
},
|