@amritk/mjst 0.19.0 → 0.21.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/AI.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @amritk/mjst (the `mjst` CLI) — notes for AI coding agents
2
2
 
3
3
  The command-line entry point to mjst: generate TypeScript parsers, validators,
4
- and types from JSON Schema, plus `lint` and `compile-api` subcommands. Full
4
+ and types from JSON Schema, plus `lint`, `compile-api`, and `markdown`
5
+ subcommands. Full
5
6
  reference is [README.md](./README.md); config options are in
6
7
  [config.schema.json](./config.schema.json).
7
8
 
@@ -9,7 +10,7 @@ reference is [README.md](./README.md); config options are in
9
10
  > package ships **only a `mjst` binary** — there are no JS exports. For the
10
11
  > programmatic API import `@amritk/generate-parsers` etc. instead.
11
12
 
12
- ## The three commands
13
+ ## The four commands
13
14
 
14
15
  ```bash
15
16
  # 1. Codegen (default command)
@@ -20,6 +21,10 @@ mjst lint "**/*.{yaml,json}" -r .lint.yaml
20
21
 
21
22
  # 3. Compile an @amritk/api routes module to a fused, eval-free handler
22
23
  mjst compile-api ./routes.ts --out ./dist/handler.ts
24
+
25
+ # 4. Render a schema as documentation (pages by default, --table for one table)
26
+ mjst markdown ./config.schema.json --out-dir ./docs
27
+ mjst markdown ./config.schema.json --table --readme ./README.md
23
28
  ```
24
29
 
25
30
  ## Codegen gotchas
package/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  Options can be supplied via CLI flags or a JSON config file. **CLI flags always take precedence over config file values.**
23
23
 
24
- It also carries a `lint` subcommand — `mjst lint <files>` — a format-agnostic JSON/YAML style-guide linter powered by [`@amritk/lint`](../lint) (see [Linting](#linting)), and a `compile-api` subcommand — `mjst compile-api <routes-module> --out <file>` — that compiles [`@amritk/api`](../api) route contracts into a production fetch-handler module (see [Compiling an API](#compiling-an-api)).
24
+ It also carries a `lint` subcommand — `mjst lint <files>` — a format-agnostic JSON/YAML style-guide linter powered by [`@amritk/lint`](../lint) (see [Linting](#linting)), a `compile-api` subcommand — `mjst compile-api <routes-module> --out <file>` — that compiles [`@amritk/api`](../api) route contracts into a production fetch-handler module (see [Compiling an API](#compiling-an-api)), and a `markdown` subcommand — `mjst markdown <schema>` — that renders a schema as documentation with [`@amritk/generate-markdown`](../generate-markdown) (see [Generating markdown docs](#generating-markdown-docs)).
25
25
 
26
26
  ---
27
27
 
@@ -217,6 +217,19 @@ config/service.yaml:2:7 error config-schema /port must be integer
217
217
 
218
218
  With no `-r`, mjst discovers a `.lint.{yaml,yml,json,js,mjs}` ruleset by walking up from each linted file. A ruleset maps a JSONPath (`given`) to a function (`then`) — `schema` (JSON Schema), `casing`, `pattern`, `truthy`, and more, plus your own. The [`@amritk/lint` README](../lint#ruleset-format) documents the ruleset format and built-in functions. For machine-readable output (JSON, SARIF, …), call the [`@amritk/lint`](../lint) library's `lintDocument`, which returns structured findings.
219
219
 
220
+ Your own ruleset can build on a preset: `extends: [asyncapi]` (or `[oas]`) layers your rules on top of the preset's, with the preset's custom functions and format detection intact — through `-r <file>` and `.lint.*` discovery alike. Relative `extends` targets and custom `functions` still resolve next to your ruleset file. Extend at most one preset per ruleset: the two bring different formats and functions and neither can resolve the other, so a ruleset naming both is refused up front. Lint AsyncAPI and OpenAPI documents in separate runs.
221
+
222
+ ```yaml
223
+ # .lint.yaml — the AsyncAPI preset plus a house rule
224
+ extends:
225
+ - asyncapi
226
+ rules:
227
+ needs-terms-of-service:
228
+ given: "$.info"
229
+ severity: error
230
+ then: { field: termsOfService, function: truthy }
231
+ ```
232
+
220
233
  | Flag | Description |
221
234
  | --- | --- |
222
235
  | `<documents..>` | Files or globs to lint (positional). |
@@ -266,6 +279,330 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
266
279
 
267
280
  ---
268
281
 
282
+ ## Generating markdown docs
283
+
284
+ `mjst markdown` renders a JSON Schema as documentation with
285
+ [`@amritk/generate-markdown`](../generate-markdown), so a config's docs cannot
286
+ drift from the schema that validates it. Like `lint` and `compile-api`, it has
287
+ its own flags (and its own `--help`), independent of the generation flags above.
288
+
289
+ ```bash
290
+ # The prose reference: a heading, a type, the description and an example per
291
+ # property, across as many pages as the schema's x-doc keyword declares
292
+ npx mjst markdown ./config.schema.json --out-dir ./docs
293
+
294
+ # The other shape: one HTML table, spliced into an existing markdown file
295
+ npx mjst markdown ./config.schema.json --table --readme ./README.md
296
+ ```
297
+
298
+ | Flag | Description |
299
+ | --- | --- |
300
+ | `<schema>` | The JSON Schema to document (positional, required). |
301
+ | `--out-dir` | Directory the prose reference pages are written to (default: the current directory). |
302
+ | `--file` | Output path of the index page (default: the schema's `x-doc.file`, then `index.md`). |
303
+ | `--title` | Page title (default: the schema's `title`). |
304
+ | `--language` | Fence language for derived examples and literals (default: `json`). |
305
+ | `--layout` | Default layout for nested properties: `headings` (default), `table`, or `none`. |
306
+ | `--sort` | Property order: `schema` (default) or `alphabetical`. |
307
+ | `--heading-level` | Heading level of the page title, 1-6 (default: `1`). |
308
+ | `--table` | Render the HTML config table instead of the prose pages. |
309
+ | `--readme` | Markdown file the table is spliced into (default: `README.md`). Requires `--table`. |
310
+
311
+ Under `--table` only the content between `<!-- config-table-start -->
312
+ <table>
313
+ <thead>
314
+ <tr>
315
+ <th>Property</th>
316
+ <th>CLI Flag</th>
317
+ <th>Type</th>
318
+ <th align="center">Default</th>
319
+ </tr>
320
+ </thead>
321
+ <tbody>
322
+ <tr>
323
+ <td>📄 <code>schema</code></td>
324
+ <td><code>--schema &lt;path&gt;</code></td>
325
+ <td><code>string</code></td>
326
+ <td align="center"></td>
327
+ </tr>
328
+ <tr>
329
+ <td colspan="4">Path to the schema to process. With the default 'json' input this is a JSON Schema file that is read and parsed. With any other input format it is a JS/TS module that exports a schema, which is loaded and converted to JSON Schema via the matching adapter. Either 'schema' or 'schemaDir' is required.<br><strong>Examples:</strong> <code>"./schema.json"</code></td>
330
+ </tr>
331
+ <tr>
332
+ <td>🗂️ <code>schemaDir</code></td>
333
+ <td><code>--schema-dir &lt;dir&gt;</code></td>
334
+ <td><code>string</code></td>
335
+ <td align="center"></td>
336
+ </tr>
337
+ <tr>
338
+ <td colspan="4">Path to a directory of JSON Schema files. When set, the CLI walks the directory recursively, generates parsers for every '*.json' schema it finds, and mirrors the directory layout under outDir (each schema lands in its own subdirectory). The runtime helpers are emitted once into a shared outDir/_helpers/ that every nested parser imports from. Mutually exclusive with 'schema'; when both are present 'schemaDir' wins. Only JSON Schema input is supported in this mode.<br><strong>Examples:</strong> <code>"./schemas"</code></td>
339
+ </tr>
340
+ <tr>
341
+ <td>🔌 <code>input</code></td>
342
+ <td><code>--input &lt;format&gt;</code></td>
343
+ <td><code>string</code></td>
344
+ <td align="center"><code>"json"</code></td>
345
+ </tr>
346
+ <tr>
347
+ <td colspan="4">Source format of the schema. 'json' (default) reads a JSON Schema file directly. 'avro' reads an Apache Avro '.avsc' JSON document and converts it, needing no extra library. 'asyncapi' reads an AsyncAPI 2.x/3.0 document (JSON or YAML) and generates from every message payload/headers schema it declares. Any other format loads 'schema' as a module and converts it to JSON Schema with the matching adapter. Supported: 'typebox', 'zod' (zod v4+), 'valibot' (with @valibot/to-json-schema), and 'effect' — each requires the corresponding library installed in your project.<br><strong>Allowed:</strong> <code>"json"</code>, <code>"typebox"</code>, <code>"zod"</code>, <code>"valibot"</code>, <code>"effect"</code>, <code>"avro"</code>, <code>"asyncapi"</code></td>
348
+ </tr>
349
+ <tr>
350
+ <td>📦 <code>export</code></td>
351
+ <td><code>--export &lt;name&gt;</code></td>
352
+ <td><code>string</code></td>
353
+ <td align="center"></td>
354
+ </tr>
355
+ <tr>
356
+ <td colspan="4">Which export of the schema module to use when 'input' names a module format (anything but 'json', 'avro', and 'asyncapi', which are read as data). Defaults to the default export, or the sole named export when the module has exactly one.</td>
357
+ </tr>
358
+ <tr>
359
+ <td>📁 <code>outDir</code></td>
360
+ <td><code>--out-dir &lt;dir&gt;</code></td>
361
+ <td><code>string</code></td>
362
+ <td align="center"></td>
363
+ </tr>
364
+ <tr>
365
+ <td colspan="4">Output directory for generated TypeScript files. The directory is created automatically if it does not exist. Subdirectories are created as needed when a generated file includes a nested path. Mutually exclusive with 'outFile'.<br><strong>Examples:</strong> <code>"./generated"</code></td>
366
+ </tr>
367
+ <tr>
368
+ <td>📄 <code>outFile</code></td>
369
+ <td><code>--out-file &lt;file&gt;</code></td>
370
+ <td><code>string</code></td>
371
+ <td align="center"></td>
372
+ </tr>
373
+ <tr>
374
+ <td colspan="4">Output everything to a single TypeScript file instead of a directory. Every generated definition is concatenated into one self-contained file and the cross-file imports are dropped. Mutually exclusive with 'outDir'. Currently supported only together with 'typesOnly'.<br><strong>Examples:</strong> <code>"./generated/schema.ts"</code></td>
375
+ </tr>
376
+ <tr>
377
+ <td>🏷️ <code>typesOnly</code></td>
378
+ <td><code>--types-only</code></td>
379
+ <td><code>boolean</code></td>
380
+ <td align="center"><code>false</code></td>
381
+ </tr>
382
+ <tr>
383
+ <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>
384
+ </tr>
385
+ <tr>
386
+ <td>✅ <code>validators</code></td>
387
+ <td><code>--validators</code></td>
388
+ <td><code>boolean</code></td>
389
+ <td align="center"><code>false</code></td>
390
+ </tr>
391
+ <tr>
392
+ <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>
393
+ </tr>
394
+ <tr>
395
+ <td>🎲 <code>examples</code></td>
396
+ <td><code>--examples</code></td>
397
+ <td><code>boolean</code></td>
398
+ <td align="center"><code>false</code></td>
399
+ </tr>
400
+ <tr>
401
+ <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>
402
+ </tr>
403
+ <tr>
404
+ <td>🔌 <code>messageContracts</code></td>
405
+ <td><code>--message-contracts</code></td>
406
+ <td><code>boolean</code></td>
407
+ <td align="center"><code>false</code></td>
408
+ </tr>
409
+ <tr>
410
+ <td colspan="4">Also emit a @amritk/api channel contract per AsyncAPI channel into a contracts/ subdirectory: one defineMessages({ ... }) module naming every message that channel carries — keyed by the value its discriminator property carries on the wire, with that property stripped out of each payload because the runtime removes it before validating — plus a barrel re-exporting them. Requires input 'asyncapi', which is the only input that has channels and operations to project from. Incompatible with typesOnly: a contract is a runtime value, not a type. The generated modules import @amritk/api, which the consuming project must install; it is not a dependency of the generated parsers.</td>
411
+ </tr>
412
+ <tr>
413
+ <td>🏷️ <code>discriminator</code></td>
414
+ <td><code>--discriminator &lt;prop&gt;</code></td>
415
+ <td><code>string</code></td>
416
+ <td align="center"><code>"type"</code></td>
417
+ </tr>
418
+ <tr>
419
+ <td colspan="4">Property name that says which message a frame is, for the contracts messageContracts emits. Defaults to 'type', matching @amritk/api. A channel that declares its own with 'x-mjst': { 'discriminator': '...' } keeps it — one flag covers a whole run, and a run may span channels that disagree. Only meaningful together with messageContracts.<br><strong>Examples:</strong> <code>"event"</code></td>
420
+ </tr>
421
+ <tr>
422
+ <td>🔨 <code>build</code></td>
423
+ <td><code>--build</code></td>
424
+ <td><code>boolean</code></td>
425
+ <td align="center"><code>false</code></td>
426
+ </tr>
427
+ <tr>
428
+ <td colspan="4">Compile the generated TypeScript files to .js and .d.ts output. A temporary tsconfig is written to the output directory, tsc is invoked, and the intermediate .ts source files are removed when compilation succeeds.</td>
429
+ </tr>
430
+ <tr>
431
+ <td>💥 <code>force</code></td>
432
+ <td><code>--force</code></td>
433
+ <td><code>boolean</code></td>
434
+ <td align="center"><code>false</code></td>
435
+ </tr>
436
+ <tr>
437
+ <td colspan="4">Deprecated and ignored. Generated files always replace whatever is at their path, so this flag no longer does anything; it is still accepted so existing scripts and config files keep running. Reverting an unwanted replacement is version control's job. Compilation under build still only removes the intermediate sources the run itself generated.</td>
438
+ </tr>
439
+ <tr>
440
+ <td>⚠️ <code>logWarnings</code></td>
441
+ <td><code>--log-warnings</code></td>
442
+ <td><code>boolean</code></td>
443
+ <td align="center"><code>false</code></td>
444
+ </tr>
445
+ <tr>
446
+ <td colspan="4">Emit a console.warn in the generated parsers for every input key that is not declared in the schema's properties. Useful for detecting schema drift or unexpected data shapes at runtime.</td>
447
+ </tr>
448
+ <tr>
449
+ <td>🚫 <code>strict</code></td>
450
+ <td><code>--strict</code></td>
451
+ <td><code>boolean</code></td>
452
+ <td align="center"><code>false</code></td>
453
+ </tr>
454
+ <tr>
455
+ <td colspan="4">Generate parsers that throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. When a schema sets additionalProperties: false, undeclared keys throw too; otherwise they are still allowed.</td>
456
+ </tr>
457
+ <tr>
458
+ <td>🧹 <code>stripUnknown</code></td>
459
+ <td><code>--strip-unknown</code></td>
460
+ <td><code>boolean</code></td>
461
+ <td align="center"><code>false</code></td>
462
+ </tr>
463
+ <tr>
464
+ <td colspan="4">Build each parser's result from the schema's declared properties only, silently dropping undeclared input keys at every nesting level (zod's .strip()). Extras are never a validation error, so this composes with strict (which still throws on wrong types and missing required properties) and yields to additionalProperties: false, which rejects rather than strips in strict mode.</td>
465
+ </tr>
466
+ <tr>
467
+ <td>🔡 <code>caseInsensitive</code></td>
468
+ <td><code>--case-insensitive</code></td>
469
+ <td><code>boolean</code></td>
470
+ <td align="center"><code>false</code></td>
471
+ </tr>
472
+ <tr>
473
+ <td colspan="4">Normalize a mis-cased string to the exact casing of a declared enum/const member it matches case-insensitively (e.g. hElLo → hello) instead of coercing to the default. Coerce mode only — strict parsers still reject a casing mismatch. Correctly-cased input keeps the exact-match fast path, so the hot path is unaffected.</td>
474
+ </tr>
475
+ <tr>
476
+ <td>🔒 <code>readonly</code></td>
477
+ <td><code>--readonly</code></td>
478
+ <td><code>boolean</code></td>
479
+ <td align="center"><code>false</code></td>
480
+ </tr>
481
+ <tr>
482
+ <td colspan="4">Emit every property, array, and record in the generated type definitions as readonly, producing deeply immutable types. Affects type definitions only; the generated parsers still build and return plain objects.</td>
483
+ </tr>
484
+ <tr>
485
+ <td>🔢 <code>unknownKeys</code></td>
486
+ <td><code>--unknown-keys &lt;strategy&gt;</code></td>
487
+ <td><code>string</code></td>
488
+ <td align="center"><code>"count-keys"</code></td>
489
+ </tr>
490
+ <tr>
491
+ <td colspan="4">How generated fast paths (parsers and validators alike) prove a closed object — additionalProperties: false, or a stripUnknown build — carries no undeclared key. 'count-keys' compares Object.keys(obj).length and is the faster form on JavaScriptCore (Bun), where a for…in over a non-extensible object takes the engine's slow path and halves strict-parse throughput. 'count-enumerable' counts with for…in and allocates nothing; it was the faster form on Node 22, but on Node 26 'count-keys' ties or wins there too. Keep the default unless you are pinned to an older V8 and have measured your own shapes; the generated code never detects its runtime.<br><strong>Allowed:</strong> <code>"count-enumerable"</code>, <code>"count-keys"</code></td>
492
+ </tr>
493
+ <tr>
494
+ <td>🧪 <code>formats</code></td>
495
+ <td><code>--formats &lt;list&gt;</code></td>
496
+ <td><code>string | array</code></td>
497
+ <td align="center"></td>
498
+ </tr>
499
+ <tr>
500
+ <td colspan="4">String formats the generated validators enforce: "all", or a comma-separated list (e.g. uuid,date-time,email). Omitted, format stays an annotation — JSON Schema's own reading, and what @amritk/runtime-validators does when given no formats. Set it to whatever validates the same schemas at runtime (@amritk/lint and createApi({ formats }) run the interpreter with formats on) so the build-time and runtime answers agree. Both validateX and the flat isX check them, against a formats.ts emitted alongside the validators.</td>
501
+ </tr>
502
+ <tr>
503
+ <td>🧰 <code>helpers</code></td>
504
+ <td><code>--helpers &lt;mode&gt;</code></td>
505
+ <td><code>string</code></td>
506
+ <td align="center"></td>
507
+ </tr>
508
+ <tr>
509
+ <td colspan="4">Controls how generated parsers reference their runtime helpers. 'package' emits imports from @amritk/helpers (requires it to be installed in the consumer project). 'embedded' ships the helper source under outDir/_helpers/ so the output is self-contained. When omitted, the CLI auto-detects: it picks 'package' only when @amritk/helpers is a declared dependency (dependencies/devDependencies) of the nearest package.json above outDir, otherwise 'embedded'. Requiring a declaration (rather than mere resolvability) keeps auto-detected 'package' output portable across pnpm/isolated installs, where an undeclared transitive @amritk/helpers is not reachable at runtime.<br><strong>Allowed:</strong> <code>"package"</code>, <code>"embedded"</code></td>
510
+ </tr>
511
+ <tr>
512
+ <td>🏷️ <code>typeSuffix</code></td>
513
+ <td><code>--type-suffix &lt;suffix&gt;</code></td>
514
+ <td><code>string</code></td>
515
+ <td align="center"><code>""</code></td>
516
+ </tr>
517
+ <tr>
518
+ <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>
519
+ </tr>
520
+ <tr>
521
+ <td>🪧 <code>banner</code></td>
522
+ <td><code>--banner [text]</code></td>
523
+ <td><code>boolean | string</code></td>
524
+ <td align="center"><code>false</code></td>
525
+ </tr>
526
+ <tr>
527
+ <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 &lt;text&gt; sets a custom one.</td>
528
+ </tr>
529
+ <tr>
530
+ <td>🔗 <code>importExt</code></td>
531
+ <td><code>--import-ext &lt;ext&gt;</code></td>
532
+ <td><code>string</code></td>
533
+ <td align="center"><code>"ts"</code></td>
534
+ </tr>
535
+ <tr>
536
+ <td colspan="4">Extension emitted on every relative import specifier in the generated output (cross-file $ref imports, the index barrel, and embedded-helper imports). 'ts' (default) emits the literal on-disk paths so the generated .ts sources load directly under Bun, Node's type stripping (Node 22.6+ with --experimental-strip-types, unflagged from 22.18/23), and tsc when the consumer sets allowImportingTsExtensions — no build step required. 'js' is the standard TS NodeNext form ('./x.js' resolving to a sibling x.ts) for output you compile; it is selected automatically under 'build' since tsc refuses to emit from .ts specifiers. Passing --import-ext ts together with --build is an error.<br><strong>Allowed:</strong> <code>"js"</code>, <code>"ts"</code></td>
537
+ </tr>
538
+ <tr>
539
+ <td>🌱 <code>rootType</code></td>
540
+ <td><code>--root-type &lt;name&gt;</code></td>
541
+ <td><code>string</code></td>
542
+ <td align="center"></td>
543
+ </tr>
544
+ <tr>
545
+ <td colspan="4">Name for the root type of a single --schema run (e.g. 'Program' yields parseProgram / validateProgram). 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>
546
+ </tr>
547
+ <tr>
548
+ <td>🌐 <code>resolveRemote</code></td>
549
+ <td><code>--resolve-remote</code></td>
550
+ <td><code>boolean</code></td>
551
+ <td align="center"><code>false</code></td>
552
+ </tr>
553
+ <tr>
554
+ <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>
555
+ </tr>
556
+ <tr>
557
+ <td>🛡️ <code>allowedHosts</code></td>
558
+ <td><code>--allowed-hosts &lt;hosts&gt;</code></td>
559
+ <td><code>array</code></td>
560
+ <td align="center"></td>
561
+ </tr>
562
+ <tr>
563
+ <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>
564
+ </tr>
565
+ <tr>
566
+ <td>🔓 <code>allowPrivateHosts</code></td>
567
+ <td><code>--allow-private-hosts</code></td>
568
+ <td><code>boolean</code></td>
569
+ <td align="center"><code>false</code></td>
570
+ </tr>
571
+ <tr>
572
+ <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>
573
+ </tr>
574
+ <tr>
575
+ <td>📂 <code>allowedRoots</code></td>
576
+ <td><code>--allowed-roots &lt;dirs&gt;</code></td>
577
+ <td><code>array</code></td>
578
+ <td align="center"></td>
579
+ </tr>
580
+ <tr>
581
+ <td colspan="4">Extra directories a local (cross-file) $ref may resolve into. By default a local $ref is confined to the schema's own directory, so '{"$ref": "../../../secrets.env"}' (or an absolute path) reads nothing. List a root here for the ordinary split-spec layout where v1/api.json refers to a shared common/user.json one level up. The schema's own directory is always allowed, so these roots widen the default rather than replacing it, and a $ref that still lands outside every root is refused. Accepts a comma-separated list or the flag repeated; relative entries resolve against the current working directory, from a config file as well as from the flag.<br><strong>Examples:</strong> <code>["./specs"]</code></td>
582
+ </tr>
583
+ <tr>
584
+ <td>⚙️ <code>config</code></td>
585
+ <td><code>--config &lt;path&gt;</code></td>
586
+ <td><code>string</code></td>
587
+ <td align="center"></td>
588
+ </tr>
589
+ <tr>
590
+ <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, force, logWarnings, strict, stripUnknown, caseInsensitive, readonly, unknownKeys, helpers, typeSuffix, banner, importExt, rootType, resolveRemote, allowedHosts, allowPrivateHosts, allowedRoots) and are validated: an unknown key or a wrong value type fails the run instead of being ignored. CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
591
+ </tr>
592
+ </tbody>
593
+ </table>
594
+ <!-- config-table-end -->` is replaced; a file that exists without both markers
595
+ is left untouched and the run fails, rather than clobbering hand-written prose.
596
+ The prose pages have no such deal — the generator owns them and overwrites them
597
+ wholesale. The table and page flags are mutually exclusive: passing a page flag
598
+ with `--table` is a usage error rather than a silently ignored option.
599
+
600
+ The exit code is `0` on success, `1` when generation fails (malformed schema, a
601
+ readme without markers, a page declaration the schema contradicts), and `2` on a
602
+ usage error. Run `mjst markdown --help` for details.
603
+
604
+ ---
605
+
269
606
  ## Configuration reference
270
607
 
271
608
  <!-- config-table-start -->
@@ -451,6 +788,15 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
451
788
  <td colspan="4">How generated fast paths (parsers and validators alike) prove a closed object — additionalProperties: false, or a stripUnknown build — carries no undeclared key. 'count-keys' compares Object.keys(obj).length and is the faster form on JavaScriptCore (Bun), where a for…in over a non-extensible object takes the engine's slow path and halves strict-parse throughput. 'count-enumerable' counts with for…in and allocates nothing; it was the faster form on Node 22, but on Node 26 'count-keys' ties or wins there too. Keep the default unless you are pinned to an older V8 and have measured your own shapes; the generated code never detects its runtime.<br><strong>Allowed:</strong> <code>"count-enumerable"</code>, <code>"count-keys"</code></td>
452
789
  </tr>
453
790
  <tr>
791
+ <td>🧪 <code>formats</code></td>
792
+ <td><code>--formats &lt;list&gt;</code></td>
793
+ <td><code>string | array</code></td>
794
+ <td align="center"></td>
795
+ </tr>
796
+ <tr>
797
+ <td colspan="4">String formats the generated validators enforce: "all", or a comma-separated list (e.g. uuid,date-time,email). Omitted, format stays an annotation — JSON Schema's own reading, and what @amritk/runtime-validators does when given no formats. Set it to whatever validates the same schemas at runtime (@amritk/lint and createApi({ formats }) run the interpreter with formats on) so the build-time and runtime answers agree. Both validateX and the flat isX check them, against a formats.ts emitted alongside the validators.</td>
798
+ </tr>
799
+ <tr>
454
800
  <td>🧰 <code>helpers</code></td>
455
801
  <td><code>--helpers &lt;mode&gt;</code></td>
456
802
  <td><code>string</code></td>
@@ -129,6 +129,15 @@ export type CliConfig = {
129
129
  * (Node). Applies to parsers and validators alike.
130
130
  */
131
131
  readonly unknownKeys?: UnknownKeysStrategy;
132
+ /**
133
+ * String `format`s the generated validators enforce — `'all'`, or the names to
134
+ * check. Left unset, `format` stays an annotation, which is 2020-12's own
135
+ * reading and what `@amritk/runtime-validators` does when given no formats.
136
+ *
137
+ * Set it to whatever the code validating the same schemas at runtime uses, so
138
+ * the build-time and runtime answers agree.
139
+ */
140
+ readonly formats?: 'all' | readonly string[];
132
141
  /**
133
142
  * Controls how generated parsers reference their runtime helpers.
134
143
  * - `'package'`: emit `import ... from '@amritk/helpers/...'`.
package/dist/cli.js CHANGED
@@ -139,7 +139,7 @@ const findJsonSchemas = async (dir) => {
139
139
  return results;
140
140
  };
141
141
  const runValidators = async (config, schema, rootTypeName, writer, validatorsSubDir) => {
142
- const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix, void 0, config.unknownKeys);
142
+ const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix, void 0, config.unknownKeys, config.formats);
143
143
  const staged = [];
144
144
  for (const file of files) {
145
145
  const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
@@ -368,6 +368,15 @@ const run = async () => {
368
368
  process.stderr.write(result.stderr);
369
369
  process.exit(result.code);
370
370
  }
371
+ if (args[0] === "markdown") {
372
+ const { run: markdownRun } = await import("./markdown/run.js");
373
+ const result = await markdownRun(args.slice(1));
374
+ if (result.stdout)
375
+ process.stdout.write(result.stdout);
376
+ if (result.stderr)
377
+ process.stderr.write(result.stderr);
378
+ process.exit(result.code);
379
+ }
371
380
  const metaRequest = parseMetaRequest(args);
372
381
  if (metaRequest === "version") {
373
382
  console.log(await readVersion());
@@ -38,6 +38,12 @@ export type EmitMessageContractsResult = {
38
38
  * accepts no frame in either direction, which is a trap rather than a document.
39
39
  * The reasons come back in {@link EmitMessageContractsResult.issues}.
40
40
  *
41
+ * Only payloads are projected. A message's `headers` schema is generated as its
42
+ * own tree by the parser run, but has no slot in a messages contract — the
43
+ * contract describes WebSocket frames, and headers are the broker's layer. Each
44
+ * module says so in its header when the channel declares any, rather than
45
+ * leaving the omission to be discovered.
46
+ *
41
47
  * The barrel's specifiers are always `.ts`, ignoring `--import-ext`: these
42
48
  * modules are the one part of the output `--build` leaves uncompiled (they
43
49
  * import the `@amritk/api` peer, and handing tsc an import the output's project
@@ -11,12 +11,19 @@ ${indent} `);
11
11
  ${entries.join("\n")}
12
12
  ${indent}},`;
13
13
  };
14
- const renderChannelModule = (contract, channel) => {
14
+ const renderChannelModule = (contract, channel, headerMessages) => {
15
15
  const directions = [
16
16
  Object.keys(contract.clientToServer).length > 0 ? renderDirection("clientToServer", contract.clientToServer, " ") : void 0,
17
17
  Object.keys(contract.serverToClient).length > 0 ? renderDirection("serverToClient", contract.serverToClient, " ") : void 0
18
18
  ].filter((entry) => entry !== void 0);
19
19
  const address = channel.address !== void 0 && channel.address !== channel.key ? ` (${channel.address})` : "";
20
+ const headerNote = headerMessages.length === 0 ? [] : [
21
+ " *",
22
+ " * Payloads only: a message contract describes WebSocket frames, which carry",
23
+ " * no headers of their own. These messages declare a headers schema, and its",
24
+ ` * types were generated alongside the payload's, under \`<message>-headers/\`:`,
25
+ ` * ${headerMessages.join(", ")}.`
26
+ ];
20
27
  return [
21
28
  "/**",
22
29
  ` * Message contract for the AsyncAPI channel \`${channel.key}\`${address}.`,
@@ -29,6 +36,7 @@ const renderChannelModule = (contract, channel) => {
29
36
  ` * \`{ "${contract.discriminator}": "<key>", ... }\` selects its schema by that key, and the`,
30
37
  " * tag is removed before the payload below is validated \u2014 which is why no",
31
38
  " * payload here declares it.",
39
+ ...headerNote,
32
40
  " */",
33
41
  `import { defineMessages } from '${CONTRACTS_PEER}'`,
34
42
  "",
@@ -70,7 +78,8 @@ const emitMessageContracts = async (options) => {
70
78
  });
71
79
  }
72
80
  const filename = `${CONTRACTS_DIR}/${token}.ts`;
73
- await writer.stage(filename, bannerPrefix + renderChannelModule({ ...contract, exportName }, channel));
81
+ const headerMessages = channel.messages.filter((message) => message.headers !== void 0).map(({ name }) => name);
82
+ await writer.stage(filename, bannerPrefix + renderChannelModule({ ...contract, exportName }, channel, headerMessages));
74
83
  files.push(filename);
75
84
  barrel.push(`export { ${exportName} } from './${token}.ts'`);
76
85
  }
@@ -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\nSubcommands (each with its own flags and --help):\n mjst lint <files> Lint JSON/YAML documents against a ruleset\n mjst compile-api <module> --out <file> Compile @amritk/api route contracts into a fetch-handler module\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, avro,\n or asyncapi (an AsyncAPI 2.x/3.0 document, JSON or YAML)\n --export <name> Which export of the schema module to use when --input names a module format\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 --message-contracts Also emit @amritk/api channel contracts under contracts/ (--input asyncapi only)\n --discriminator <p> Property naming which message a frame is, for --message-contracts (default: type)\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --force Deprecated; ignored (generated files always overwrite)\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 --unknown-keys <s> How closed objects are proven free of undeclared keys:\n count-keys (default, fastest on Bun) or count-enumerable (fastest on Node)\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 --allowed-roots <d> Extra directories a local $ref may resolve into, on top of the schema's own\n (comma-separated; relative to the current directory)\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\nSubcommands (each with its own flags and --help):\n mjst lint <files> Lint JSON/YAML documents against a ruleset\n mjst compile-api <module> --out <file> Compile @amritk/api route contracts into a fetch-handler module\n mjst markdown <schema> --out-dir <dir> Generate markdown documentation from a JSON Schema\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, avro,\n or asyncapi (an AsyncAPI 2.x/3.0 document, JSON or YAML)\n --export <name> Which export of the schema module to use when --input names a module format\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 --message-contracts Also emit @amritk/api channel contracts under contracts/ (--input asyncapi only)\n --discriminator <p> Property naming which message a frame is, for --message-contracts (default: type)\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --force Deprecated; ignored (generated files always overwrite)\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 --unknown-keys <s> How closed objects are proven free of undeclared keys:\n count-keys (default, fastest on Bun) or count-enumerable (fastest on Node)\n --formats <f> String formats the generated validators enforce: \"all\", or a\n comma-separated list (e.g. uuid,date-time,email). Off by default,\n which leaves \"format\" an annotation as JSON Schema reads it.\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 --allowed-roots <d> Extra directories a local $ref may resolve into, on top of the schema's own\n (comma-separated; relative to the current directory)\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
@@ -8,6 +8,7 @@ Usage:
8
8
  Subcommands (each with its own flags and --help):
9
9
  mjst lint <files> Lint JSON/YAML documents against a ruleset
10
10
  mjst compile-api <module> --out <file> Compile @amritk/api route contracts into a fetch-handler module
11
+ mjst markdown <schema> --out-dir <dir> Generate markdown documentation from a JSON Schema
11
12
 
12
13
  Input:
13
14
  --schema <path> Schema to process: a JSON Schema file, or a module when --input is set
@@ -40,6 +41,9 @@ Validation:
40
41
  --log-warnings console.warn on input keys not declared in the schema
41
42
  --unknown-keys <s> How closed objects are proven free of undeclared keys:
42
43
  count-keys (default, fastest on Bun) or count-enumerable (fastest on Node)
44
+ --formats <f> String formats the generated validators enforce: "all", or a
45
+ comma-separated list (e.g. uuid,date-time,email). Off by default,
46
+ which leaves "format" an annotation as JSON Schema reads it.
43
47
 
44
48
  Reference resolution:
45
49
  --resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)
@@ -0,0 +1,61 @@
1
+ import type { Ruleset } from '@amritk/lint';
2
+ import type { RulesetDefinition } from '@amritk/lint/types';
3
+ /**
4
+ * Routing a ruleset to the right builder: which names mean a built-in preset,
5
+ * whether a ruleset the CLI loaded off disk names one, and how to build with it.
6
+ * The three live together rather than one per file because they are a single
7
+ * concern — the name table is the whole question — and because the two entry
8
+ * points must agree on it: `--ruleset <name>` and a loaded ruleset's `extends`
9
+ * accept exactly the same preset names.
10
+ */
11
+ /**
12
+ * The two built-in presets the CLI can build a ruleset with. They are mutually
13
+ * exclusive: each preset only knows how to resolve its *own* name inside
14
+ * `extends`, and each brings a different format registry, so one ruleset cannot
15
+ * be built with both.
16
+ */
17
+ export type PresetName = 'asyncapi' | 'oas';
18
+ /**
19
+ * Every name that resolves to a built-in preset — as `--ruleset <name>` and
20
+ * inside a ruleset's `extends` — mapped to the preset it names. The aliases
21
+ * mirror what the presets' own `extends` resolution accepts, including the
22
+ * legacy Spectral ones.
23
+ *
24
+ * A Map, not a record: the key comes straight from user input, and a record
25
+ * lookup on `constructor` would find `Object.prototype`'s.
26
+ */
27
+ export declare const PRESET_RULESET_NAMES: ReadonlyMap<string, PresetName>;
28
+ /**
29
+ * Builds a *runnable* ruleset with a preset's builder. Built rather than handed
30
+ * to `lintDocument` as data, deliberately: a preset brings its own custom
31
+ * functions and format detectors, which a definition cannot carry — as data,
32
+ * every one of its rules would be silently skipped (unknown functions, a
33
+ * `formats` gate matching nothing).
34
+ *
35
+ * With no `definition` this is the bare `--ruleset asyncapi` case: the preset's
36
+ * recommended rules and nothing else. With one, the caller's ruleset is built by
37
+ * the preset, whose `extends` resolution knows the preset names and whose
38
+ * `basePath` keeps the user's relative `extends` and `functions` resolving next
39
+ * to their own file.
40
+ *
41
+ * No `restrictTo` is passed, which matches what the core path does for a
42
+ * discovered or `--ruleset` file today: the CLI treats a ruleset on the local
43
+ * disk as trusted configuration. (`--allowed-roots` fences `$ref` resolution in
44
+ * the *document*, a separate concern.) See the "Trust boundary" section of
45
+ * `@amritk/lint`'s README.
46
+ */
47
+ export declare const buildPresetRuleset: (preset: PresetName, definition?: RulesetDefinition, basePath?: string) => Promise<Ruleset>;
48
+ /**
49
+ * Decides what `lintDocument` should run for a ruleset just loaded off disk —
50
+ * whether it came from `--ruleset <file>` or `.lint.*` discovery.
51
+ *
52
+ * A ruleset that extends a built-in preset has to be built *here*, by that
53
+ * preset's builder: handed over as a definition, `lintDocument` builds it with
54
+ * the core `createRuleset`, which knows neither the preset names nor the
55
+ * functions and format detectors a preset brings. Anything else stays a
56
+ * definition and takes the core path exactly as it did before.
57
+ *
58
+ * @param basePath - The ruleset file's own directory, so its relative `extends` and `functions` still resolve there.
59
+ * @param label - How to name the ruleset in an error, e.g. its path.
60
+ */
61
+ export declare const buildLoadedRuleset: (definition: RulesetDefinition, basePath: string, label: string) => Promise<RulesetDefinition | Ruleset>;
@@ -0,0 +1,55 @@
1
+ const PRESET_RULESET_NAMES = /* @__PURE__ */ new Map([
2
+ ["asyncapi", "asyncapi"],
3
+ ["loupe:asyncapi", "asyncapi"],
4
+ ["spectral:asyncapi", "asyncapi"],
5
+ ["oas", "oas"],
6
+ ["loupe:oas", "oas"],
7
+ ["spectral:oas", "oas"]
8
+ ]);
9
+ const collectPresets = (definition, into, seen) => {
10
+ if (typeof definition !== "object" || definition === null)
11
+ return;
12
+ if (seen.has(definition))
13
+ return;
14
+ seen.add(definition);
15
+ if (!definition.extends)
16
+ return;
17
+ const entries = Array.isArray(definition.extends) ? definition.extends : [definition.extends];
18
+ for (const entry of entries) {
19
+ const target = Array.isArray(entry) ? entry[0] : entry;
20
+ if (typeof target === "string") {
21
+ const preset = PRESET_RULESET_NAMES.get(target);
22
+ if (preset && !into.has(preset))
23
+ into.set(preset, target);
24
+ } else {
25
+ collectPresets(target, into, seen);
26
+ }
27
+ }
28
+ };
29
+ const findExtendedPreset = (definition, label) => {
30
+ const found = /* @__PURE__ */ new Map();
31
+ collectPresets(definition, found, /* @__PURE__ */ new Set());
32
+ const asyncapi = found.get("asyncapi");
33
+ const oas = found.get("oas");
34
+ if (asyncapi && oas) {
35
+ throw new Error(`Ruleset ${label} extends both built-in presets ("${asyncapi}" and "${oas}"), which cannot be combined: each preset brings its own functions and format detectors, and neither can resolve the other. Extend at most one preset per ruleset, and lint AsyncAPI and OpenAPI documents in separate runs.`);
36
+ }
37
+ return asyncapi ? "asyncapi" : oas ? "oas" : void 0;
38
+ };
39
+ const buildPresetRuleset = async (preset, definition, basePath) => {
40
+ if (preset === "asyncapi") {
41
+ const { createAsyncApiRuleset } = await import("@amritk/lint/rules/asyncapi");
42
+ return createAsyncApiRuleset(definition, basePath);
43
+ }
44
+ const { createOpenApiRuleset } = await import("@amritk/lint/rules/openapi");
45
+ return createOpenApiRuleset(definition, basePath);
46
+ };
47
+ const buildLoadedRuleset = async (definition, basePath, label) => {
48
+ const preset = findExtendedPreset(definition, label);
49
+ return preset ? await buildPresetRuleset(preset, definition, basePath) : definition;
50
+ };
51
+ export {
52
+ PRESET_RULESET_NAMES,
53
+ buildLoadedRuleset,
54
+ buildPresetRuleset
55
+ };
package/dist/lint/run.js CHANGED
@@ -4,6 +4,7 @@ import { lintDocument, validateRuleset } from "@amritk/lint";
4
4
  import { DiagnosticSeverity } from "@amritk/lint/types";
5
5
  import fg from "fast-glob";
6
6
  import yargs from "yargs";
7
+ import { buildLoadedRuleset, buildPresetRuleset, PRESET_RULESET_NAMES } from "./preset-ruleset.js";
7
8
  import { createLintResolver } from "./resolver.js";
8
9
  import { discoverRuleset, loadRuleset } from "./ruleset-loader.js";
9
10
  const SEVERITY_BY_NAME = {
@@ -13,16 +14,6 @@ const SEVERITY_BY_NAME = {
13
14
  hint: DiagnosticSeverity.Hint
14
15
  };
15
16
  const SEVERITY_LABEL = ["error", "warning", "info", "hint"];
16
- const buildAsyncApiPreset = async () => (await import("@amritk/lint/rules/asyncapi")).createAsyncApiRuleset();
17
- const buildOpenApiPreset = async () => (await import("@amritk/lint/rules/openapi")).createOpenApiRuleset();
18
- const PRESET_RULESETS = /* @__PURE__ */ new Map([
19
- ["asyncapi", buildAsyncApiPreset],
20
- ["loupe:asyncapi", buildAsyncApiPreset],
21
- ["spectral:asyncapi", buildAsyncApiPreset],
22
- ["oas", buildOpenApiPreset],
23
- ["loupe:oas", buildOpenApiPreset],
24
- ["spectral:oas", buildOpenApiPreset]
25
- ]);
26
17
  const formatReport = (findings) => {
27
18
  if (findings.length === 0)
28
19
  return "No problems found\n";
@@ -136,19 +127,20 @@ Run \`mjst lint --help\` for the supported flags.
136
127
  return void 0;
137
128
  const definition = await loadRuleset(discovered);
138
129
  reportRulesetProblems(definition, discovered);
139
- return { definition, basePath: dirname(discovered) };
130
+ const basePath = dirname(discovered);
131
+ return { definition: await buildLoadedRuleset(definition, basePath, discovered), basePath };
140
132
  };
141
133
  let rulesetDefinition;
142
134
  let rulesetBasePath;
143
135
  if (parsed.ruleset) {
144
- const preset = PRESET_RULESETS.get(parsed.ruleset);
136
+ const preset = PRESET_RULESET_NAMES.get(parsed.ruleset);
145
137
  if (preset) {
146
- rulesetDefinition = await preset();
138
+ rulesetDefinition = await buildPresetRuleset(preset);
147
139
  } else {
148
140
  const definition = await loadRuleset(parsed.ruleset);
149
- rulesetDefinition = definition;
150
141
  rulesetBasePath = dirname(isAbsolute(parsed.ruleset) ? parsed.ruleset : resolve(process.cwd(), parsed.ruleset));
151
142
  reportRulesetProblems(definition, parsed.ruleset);
143
+ rulesetDefinition = await buildLoadedRuleset(definition, rulesetBasePath, parsed.ruleset);
152
144
  }
153
145
  }
154
146
  const documents = parsed.documents ?? [];
@@ -1,10 +1,2 @@
1
1
  import type { CliConfig } from './cli-config.js';
2
- /**
3
- * Loads a JSON config file and returns the relevant CLI config properties.
4
- * The config file should have the same keys as the CLI flags (schema, outDir).
5
- *
6
- * Every key is validated up front (see {@link validateConfig}), so the `typeof`
7
- * guards below are only here to narrow the types for TypeScript — by the time we
8
- * reach them, nothing can fail them.
9
- */
10
2
  export declare const loadConfig: (configPath: string) => Promise<Partial<CliConfig>>;
@@ -7,6 +7,7 @@ const isSourceFormat = (value) => typeof value === "string" && SOURCE_FORMATS.in
7
7
  const isHelpersMode = (value) => value === "package" || value === "embedded";
8
8
  const isImportExt = (value) => value === "js" || value === "ts";
9
9
  const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
10
+ const isFormatsOption = (value) => value === "all" || Array.isArray(value) && value.every((name) => typeof name === "string");
10
11
  const loadConfig = async (configPath) => {
11
12
  const absolutePath = resolve(configPath);
12
13
  const raw = await readFile(absolutePath, "utf-8").catch((error) => {
@@ -40,6 +41,7 @@ const loadConfig = async (configPath) => {
40
41
  ...typeof obj["caseInsensitive"] === "boolean" && { caseInsensitive: obj["caseInsensitive"] },
41
42
  ...typeof obj["readonly"] === "boolean" && { readonly: obj["readonly"] },
42
43
  ...isUnknownKeysStrategy(obj["unknownKeys"]) && { unknownKeys: obj["unknownKeys"] },
44
+ ...isFormatsOption(obj["formats"]) && { formats: obj["formats"] },
43
45
  ...isHelpersMode(obj["helpers"]) && { helpers: obj["helpers"] },
44
46
  ...isImportExt(obj["importExt"]) && { importExt: obj["importExt"] },
45
47
  ...typeof obj["rootType"] === "string" && { rootType: obj["rootType"] },
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Usage text for `mjst markdown --help`. Hand-written like the top-level
3
+ * HELP_TEXT: the flag list is small and stable, and the test cross-checks that
4
+ * every flag is listed.
5
+ */
6
+ export declare const MARKDOWN_HELP_TEXT = "mjst markdown \u2014 generate markdown documentation from a JSON Schema\n\nUsage:\n mjst markdown <schema> --out-dir <dir> [options]\n mjst markdown <schema> --table --readme <file>\n\nTwo shapes come out of the same schema. By default it writes the prose\nreference \u2014 a heading, a type, the description and an example per property,\nacross as many pages as the schema's x-doc keyword declares. With --table it\nrenders one HTML table instead and splices it into an existing markdown file\nbetween <!-- config-table-start --> and <!-- config-table-end -->, leaving the\nrest of that file alone.\n\nProse reference options:\n --out-dir <dir> Directory the pages are written to (default: the current directory)\n --file <path> Output path of the index page (default: the schema's x-doc.file, then index.md)\n --title <text> Page title (default: the schema's title)\n --language <lang> Fence language for derived examples and literals (default: json)\n --layout <mode> Default layout for nested properties: headings (default), table, or none\n --sort <order> Property order: schema (default) or alphabetical\n --heading-level <n> Heading level of the page title, 1-6 (default: 1)\n\nTable options:\n --table Render the HTML config table instead of the prose pages\n --readme <path> Markdown file the table is spliced into (default: README.md)\n\nMisc:\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
@@ -0,0 +1,34 @@
1
+ const MARKDOWN_HELP_TEXT = `mjst markdown \u2014 generate markdown documentation from a JSON Schema
2
+
3
+ Usage:
4
+ mjst markdown <schema> --out-dir <dir> [options]
5
+ mjst markdown <schema> --table --readme <file>
6
+
7
+ Two shapes come out of the same schema. By default it writes the prose
8
+ reference \u2014 a heading, a type, the description and an example per property,
9
+ across as many pages as the schema's x-doc keyword declares. With --table it
10
+ renders one HTML table instead and splices it into an existing markdown file
11
+ between <!-- config-table-start --> and <!-- config-table-end -->, leaving the
12
+ rest of that file alone.
13
+
14
+ Prose reference options:
15
+ --out-dir <dir> Directory the pages are written to (default: the current directory)
16
+ --file <path> Output path of the index page (default: the schema's x-doc.file, then index.md)
17
+ --title <text> Page title (default: the schema's title)
18
+ --language <lang> Fence language for derived examples and literals (default: json)
19
+ --layout <mode> Default layout for nested properties: headings (default), table, or none
20
+ --sort <order> Property order: schema (default) or alphabetical
21
+ --heading-level <n> Heading level of the page title, 1-6 (default: 1)
22
+
23
+ Table options:
24
+ --table Render the HTML config table instead of the prose pages
25
+ --readme <path> Markdown file the table is spliced into (default: README.md)
26
+
27
+ Misc:
28
+ --help, -h Print this help
29
+
30
+ Docs: https://github.com/amritk/mjst/tree/main/packages/cli#readme
31
+ `;
32
+ export {
33
+ MARKDOWN_HELP_TEXT
34
+ };
@@ -0,0 +1,34 @@
1
+ import type { DocLayout, DocSort } from '@amritk/generate-markdown';
2
+ /** The flags and positional the `markdown` subcommand understands. */
3
+ export type MarkdownArgs = {
4
+ /** Path to the JSON Schema to document (the sole positional). */
5
+ schema?: string;
6
+ /** Directory the prose reference pages are written to (`--out-dir`). */
7
+ outDir?: string;
8
+ /** Output path of the index page (`--file`). */
9
+ file?: string;
10
+ /** Page title override (`--title`). */
11
+ title?: string;
12
+ /** Fence language for derived examples (`--language`). */
13
+ language?: string;
14
+ /** Default layout for nested properties (`--layout`). */
15
+ layout?: DocLayout;
16
+ /** Property order (`--sort`). */
17
+ sort?: DocSort;
18
+ /** Heading level of the page title (`--heading-level`). */
19
+ headingLevel?: number;
20
+ /** True when `--table` was passed: render the HTML table instead of the pages. */
21
+ table?: boolean;
22
+ /** Markdown file the table is spliced into (`--readme`). */
23
+ readme?: string;
24
+ /** True when `--help`/`-h` was passed. */
25
+ help?: boolean;
26
+ };
27
+ /**
28
+ * Parses the `markdown` argv into a typed args object. Mirrors the other
29
+ * subcommand parsers: kebab-case and camelCase flag spellings, both
30
+ * `--flag value` and `--flag=value`, and a loud error on unknown flags so a
31
+ * typo cannot silently drop an option. Throws on usage errors; the caller turns
32
+ * that into the exit-code-2 path.
33
+ */
34
+ export declare const parseMarkdownArgs: (argv: readonly string[]) => MarkdownArgs;
@@ -0,0 +1,97 @@
1
+ const VALUE_KEYS = /* @__PURE__ */ new Set(["outDir", "file", "title", "language", "layout", "sort", "headingLevel", "readme"]);
2
+ const BOOLEAN_KEYS = /* @__PURE__ */ new Set(["table"]);
3
+ const LAYOUTS = ["headings", "table", "none"];
4
+ const SORTS = ["schema", "alphabetical"];
5
+ const toCamelCase = (key) => key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
6
+ const parseChoice = (flag, value, choices) => {
7
+ const match = choices.find((choice) => choice === value);
8
+ if (match === void 0) {
9
+ throw new Error(`Invalid --${flag} value "${value}". Expected one of: ${choices.join(", ")}.`);
10
+ }
11
+ return match;
12
+ };
13
+ const parseHeadingLevel = (value) => {
14
+ const parsed = Number(value);
15
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 6) {
16
+ throw new Error(`Invalid --heading-level value "${value}". Expected an integer from 1 to 6.`);
17
+ }
18
+ return parsed;
19
+ };
20
+ const assignValue = (args, key, value) => {
21
+ switch (key) {
22
+ case "outDir":
23
+ args.outDir = value;
24
+ return;
25
+ case "file":
26
+ args.file = value;
27
+ return;
28
+ case "title":
29
+ args.title = value;
30
+ return;
31
+ case "language":
32
+ args.language = value;
33
+ return;
34
+ case "layout":
35
+ args.layout = parseChoice("layout", value, LAYOUTS);
36
+ return;
37
+ case "sort":
38
+ args.sort = parseChoice("sort", value, SORTS);
39
+ return;
40
+ case "headingLevel":
41
+ args.headingLevel = parseHeadingLevel(value);
42
+ return;
43
+ case "readme":
44
+ args.readme = value;
45
+ return;
46
+ default:
47
+ throw new Error(`Unknown flag "--${key}".`);
48
+ }
49
+ };
50
+ const parseMarkdownArgs = (argv) => {
51
+ const args = {};
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const arg = argv[i];
54
+ if (arg === void 0)
55
+ continue;
56
+ if (arg === "--help" || arg === "-h") {
57
+ args.help = true;
58
+ continue;
59
+ }
60
+ if (!arg.startsWith("--")) {
61
+ if (args.schema !== void 0) {
62
+ throw new Error(`Unexpected argument "${arg}". markdown takes a single schema path.`);
63
+ }
64
+ args.schema = arg;
65
+ continue;
66
+ }
67
+ const equalsIndex = arg.indexOf("=");
68
+ if (equalsIndex !== -1) {
69
+ const name = arg.slice(2, equalsIndex);
70
+ const key2 = toCamelCase(name);
71
+ if (BOOLEAN_KEYS.has(key2))
72
+ throw new Error(`Flag "--${name}" is a switch and takes no value.`);
73
+ if (!VALUE_KEYS.has(key2))
74
+ throw new Error(`Unknown flag "--${name}".`);
75
+ assignValue(args, key2, arg.slice(equalsIndex + 1));
76
+ continue;
77
+ }
78
+ const flagName = arg.slice(2);
79
+ const key = toCamelCase(flagName);
80
+ if (BOOLEAN_KEYS.has(key)) {
81
+ args.table = true;
82
+ continue;
83
+ }
84
+ if (!VALUE_KEYS.has(key))
85
+ throw new Error(`Unknown flag "--${flagName}".`);
86
+ const value = argv[i + 1];
87
+ if (value === void 0 || value.startsWith("--")) {
88
+ throw new Error(`Flag "--${flagName}" expects a value.`);
89
+ }
90
+ assignValue(args, key, value);
91
+ i++;
92
+ }
93
+ return args;
94
+ };
95
+ export {
96
+ parseMarkdownArgs
97
+ };
@@ -0,0 +1,9 @@
1
+ import type { RunResult } from '../lint/run.js';
2
+ /**
3
+ * Runs `mjst markdown` over `argv`: renders a JSON Schema as documentation,
4
+ * either as the prose reference pages (the default) or as the HTML config table
5
+ * spliced into an existing markdown file (`--table`). Returns the exit code and
6
+ * the text it would print (rather than writing to the process streams) so tests
7
+ * can drive it in-process — the same shape as the other subcommands' `run`.
8
+ */
9
+ export declare const run: (argv: string[]) => Promise<RunResult>;
@@ -0,0 +1,72 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { generateConfigTable, generateDocs } from "@amritk/generate-markdown";
3
+ import { MARKDOWN_HELP_TEXT } from "./help-text.js";
4
+ import { parseMarkdownArgs } from "./parse-markdown-args.js";
5
+ const usageError = (message) => ({ code: 2, stdout: "", stderr: `Error: ${message}
6
+ ` });
7
+ const failure = (message) => ({ code: 1, stdout: "", stderr: `Error: ${message}
8
+ ` });
9
+ const PAGE_FLAGS = ["outDir", "file", "title", "language", "layout", "sort", "headingLevel"];
10
+ const run = async (argv) => {
11
+ let args;
12
+ try {
13
+ args = parseMarkdownArgs(argv);
14
+ } catch (error) {
15
+ return usageError(error instanceof Error ? error.message : String(error));
16
+ }
17
+ if (args.help || argv.length === 0) {
18
+ return { code: 0, stdout: MARKDOWN_HELP_TEXT, stderr: "" };
19
+ }
20
+ if (args.schema === void 0) {
21
+ return usageError("A schema path is required. Usage: mjst markdown <schema> --out-dir <dir>");
22
+ }
23
+ if (args.table) {
24
+ const stray = PAGE_FLAGS.filter((flag) => args[flag] !== void 0);
25
+ if (stray.length > 0) {
26
+ return usageError(`--table renders one table into a single file, so it cannot be combined with ${stray.map((flag) => `--${flag.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`).join(", ")}. Drop --table to generate the prose reference pages.`);
27
+ }
28
+ } else if (args.readme !== void 0) {
29
+ return usageError("--readme names the file the config table is spliced into, so it requires --table.");
30
+ }
31
+ const schemaPath = resolve(args.schema);
32
+ const outDir = resolve(args.outDir ?? ".");
33
+ if (args.table) {
34
+ let readmePath;
35
+ try {
36
+ readmePath = await generateConfigTable({
37
+ schemaPath,
38
+ ...args.readme !== void 0 ? { readmePath: resolve(args.readme) } : {}
39
+ });
40
+ } catch (error) {
41
+ return failure(error instanceof Error ? error.message : String(error));
42
+ }
43
+ return { code: 0, stdout: `Generated config table: ${relative(process.cwd(), readmePath)}
44
+ `, stderr: "" };
45
+ }
46
+ const options = {
47
+ schemaPath,
48
+ outDir,
49
+ file: args.file,
50
+ title: args.title,
51
+ language: args.language,
52
+ layout: args.layout,
53
+ sort: args.sort,
54
+ headingLevel: args.headingLevel
55
+ };
56
+ let files;
57
+ try {
58
+ files = await generateDocs(options);
59
+ } catch (error) {
60
+ return failure(error instanceof Error ? error.message : String(error));
61
+ }
62
+ const stdout = [
63
+ ...files.map((file) => `Generated: ${relative(process.cwd(), resolve(outDir, file.filename))}`),
64
+ "",
65
+ `Total files generated: ${files.length}`,
66
+ ""
67
+ ].join("\n");
68
+ return { code: 0, stdout, stderr: "" };
69
+ };
70
+ export {
71
+ run
72
+ };
@@ -28,7 +28,8 @@ const VALUE_KEYS = /* @__PURE__ */ new Set([
28
28
  "typeSuffix",
29
29
  "importExt",
30
30
  "rootType",
31
- "unknownKeys"
31
+ "unknownKeys",
32
+ "formats"
32
33
  ]);
33
34
  const EXTERNAL_VALUE_KEYS = /* @__PURE__ */ new Set(["config"]);
34
35
  const toCamelCase = (key) => key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -103,6 +104,10 @@ const assignValue = (config, key, value) => {
103
104
  config.importExt = parsed;
104
105
  return true;
105
106
  }
107
+ case "formats": {
108
+ config.formats = value === "all" ? "all" : value.split(",").map((name) => name.trim()).filter((name) => name !== "");
109
+ return true;
110
+ }
106
111
  case "unknownKeys": {
107
112
  if (!isUnknownKeysStrategy(value)) {
108
113
  throw new Error(`Invalid --unknown-keys value "${value}". Expected one of: ${UNKNOWN_KEYS_STRATEGIES.join(", ")}.`);
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * The JSON type each config key accepts, mirroring `config.schema.json`.
3
- * `boolean|string` is `banner`'s `"type": ["boolean", "string"]`.
3
+ * `boolean|string` is `banner`'s `"type": ["boolean", "string"]`, and
4
+ * `string|string[]` is `formats`' `"type": ["string", "array"]` — a union the
5
+ * label spells by its element type rather than by the bare word `array`, since
6
+ * "expected string|array" tells a reader less than "expected string|string[]".
4
7
  */
5
- type ConfigValueType = 'string' | 'boolean' | 'boolean|string' | 'string[]';
8
+ type ConfigValueType = 'string' | 'boolean' | 'boolean|string' | 'string[]' | 'string|string[]';
6
9
  /** One key's contract: its JSON type, plus the closed value set when it has one. */
7
10
  type ConfigKeySpec = {
8
11
  readonly type: ConfigValueType;
@@ -20,6 +20,7 @@ const CONFIG_KEYS = {
20
20
  caseInsensitive: { type: "boolean" },
21
21
  readonly: { type: "boolean" },
22
22
  unknownKeys: { type: "string", enum: [...UNKNOWN_KEYS_STRATEGIES] },
23
+ formats: { type: "string|string[]" },
23
24
  helpers: { type: "string", enum: ["package", "embedded"] },
24
25
  typeSuffix: { type: "string" },
25
26
  banner: { type: "boolean|string" },
@@ -48,6 +49,8 @@ const matchesType = (value, type) => {
48
49
  return typeof value === "boolean" || typeof value === "string";
49
50
  case "string[]":
50
51
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
52
+ case "string|string[]":
53
+ return typeof value === "string" || Array.isArray(value) && value.every((e) => typeof e === "string");
51
54
  }
52
55
  };
53
56
  const validateConfig = (parsed, configPath) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -49,14 +49,15 @@
49
49
  "./package.json": "./package.json"
50
50
  },
51
51
  "dependencies": {
52
- "@amritk/adapters": "^0.6.1",
53
- "@amritk/api": "^0.16.3",
54
- "@amritk/asyncapi": "^0.2.0",
55
- "@amritk/generate-examples": "^0.8.3",
56
- "@amritk/generate-parsers": "^0.22.0",
57
- "@amritk/generate-validators": "^0.16.1",
58
- "@amritk/helpers": "^0.20.0",
59
- "@amritk/lint": "^0.5.3",
52
+ "@amritk/adapters": "^0.6.2",
53
+ "@amritk/api": "^0.16.4",
54
+ "@amritk/asyncapi": "^0.3.0",
55
+ "@amritk/generate-examples": "^0.8.4",
56
+ "@amritk/generate-markdown": "^0.8.0",
57
+ "@amritk/generate-parsers": "^0.23.0",
58
+ "@amritk/generate-validators": "^0.17.0",
59
+ "@amritk/helpers": "^0.21.0",
60
+ "@amritk/lint": "^0.6.0",
60
61
  "@amritk/resolve-refs": "^0.7.1",
61
62
  "@amritk/yaml": "^0.7.2",
62
63
  "fast-glob": "^3.3.2",
@@ -64,7 +65,6 @@
64
65
  "yargs": "^17.7.2"
65
66
  },
66
67
  "devDependencies": {
67
- "@amritk/generate-markdown": "^0.6.0",
68
68
  "@types/yargs": "^17.0.33"
69
69
  }
70
70
  }