@amritk/generate-parsers 0.13.0 → 0.15.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 CHANGED
@@ -9,7 +9,7 @@
9
9
  ![license](https://img.shields.io/badge/license-MIT-22c55e?style=flat-square) 
10
10
  ![JSON Schema](https://img.shields.io/badge/JSON%20Schema-2020--12-f97316?style=flat-square) 
11
11
  ![node](https://img.shields.io/badge/node-%E2%89%A520-339933?style=flat-square&logo=node.js&logoColor=white) 
12
- ![vibe coded](https://img.shields.io/badge/vibe%20coded-84%25-a855f7?style=flat-square)
12
+ ![vibe coded](https://img.shields.io/badge/vibe-coded-a855f7?style=flat-square)
13
13
 
14
14
  </div>
15
15
 
@@ -1,5 +1,6 @@
1
1
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
2
  import type { HelpersMode } from '../helpers/collect-helpers.js';
3
+ import type { ImportExtension } from '../helpers/collect-imports.js';
3
4
  import type { SchemaExtensions } from '../types/schema-extensions.js';
4
5
  /**
5
6
  * Represents a generated file with its filename and content.
@@ -45,6 +46,13 @@ export type GeneratedFile = {
45
46
  * @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
46
47
  * (e.g. `'Object'` → `ContactObject`). Defaults to `''` (no suffix). The root type name
47
48
  * is used verbatim and is not affected by this suffix.
49
+ * @param importExt - Extension used on every relative import specifier in the generated
50
+ * output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
51
+ * `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
52
+ * literal on-disk paths so the generated sources run directly under Node's type
53
+ * stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
54
+ * under `--build`, where tsc must compile the sources) so generated output runs under
55
+ * Node without a build step; direct callers of `buildSchema` opt in explicitly.
48
56
  * @returns An array of generated TypeScript files
49
57
  *
50
58
  * @example
@@ -77,4 +85,4 @@ export type GeneratedFile = {
77
85
  * });
78
86
  * ```
79
87
  */
80
- export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
88
+ export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string, importExt?: ImportExtension) => Promise<GeneratedFile[]>;
@@ -16,7 +16,7 @@ import { generateFile } from './generate-files.js';
16
16
  * on this lookup), it falls back to the always-published compiled `dist/<helper>.js`
17
17
  * — which tsc already emits with `.js`-extension imports — and writes it verbatim.
18
18
  */
19
- const readHelperSource = async (helper) => {
19
+ const readHelperSource = async (helper, importExt) => {
20
20
  const require = createRequire(import.meta.url);
21
21
  const helpersRoot = dirname(require.resolve('@amritk/helpers/package.json'));
22
22
  try {
@@ -31,10 +31,17 @@ const readHelperSource = async (helper) => {
31
31
  // never matches) and is also semantically correct: an import keyword always
32
32
  // sits at a word boundary. The dist smoke test (scripts/dist-smoke.test.ts)
33
33
  // fails the build if this ever regresses.
34
- return { ext: 'ts', content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, "from '$1.js'") };
34
+ return { ext: 'ts', content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, `from '$1.${importExt}'`) };
35
35
  }
36
36
  catch {
37
37
  const compiled = await readFile(resolvePath(helpersRoot, 'dist', `${helper}.js`), 'utf-8');
38
+ // With `.ts` import specifiers the on-disk file must literally be `.ts`, so
39
+ // ship the compiled JS (valid TS) under that name and retarget its own
40
+ // sibling imports to match. The pattern keeps the same `\bfrom` shape as
41
+ // above so tsc-alias's scanner can't corrupt it (see the comment there).
42
+ if (importExt === 'ts') {
43
+ return { ext: 'ts', content: compiled.replace(/\bfrom '(\.\/[^'"]+)\.js'/g, "from '$1.ts'") };
44
+ }
38
45
  return { ext: 'js', content: compiled };
39
46
  }
40
47
  };
@@ -75,6 +82,13 @@ const readHelperSource = async (helper) => {
75
82
  * @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
76
83
  * (e.g. `'Object'` → `ContactObject`). Defaults to `''` (no suffix). The root type name
77
84
  * is used verbatim and is not affected by this suffix.
85
+ * @param importExt - Extension used on every relative import specifier in the generated
86
+ * output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
87
+ * `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
88
+ * literal on-disk paths so the generated sources run directly under Node's type
89
+ * stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
90
+ * under `--build`, where tsc must compile the sources) so generated output runs under
91
+ * Node without a build step; direct callers of `buildSchema` opt in explicitly.
78
92
  * @returns An array of generated TypeScript files
79
93
  *
80
94
  * @example
@@ -107,7 +121,7 @@ const readHelperSource = async (helper) => {
107
121
  * });
108
122
  * ```
109
123
  */
110
- export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '') => {
124
+ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js') => {
111
125
  const files = [];
112
126
  const usedHelpers = new Set();
113
127
  walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
@@ -125,6 +139,8 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
125
139
  helpersImportPrefix,
126
140
  readonly,
127
141
  typeSuffix,
142
+ importExt,
143
+ isRoot: node.isRoot,
128
144
  ...(node.ref !== undefined ? { selfRef: node.ref } : {}),
129
145
  ...(logWarnings !== undefined ? { logWarnings } : {}),
130
146
  ...(strict !== undefined ? { strict } : {}),
@@ -139,10 +155,13 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
139
155
  // typesOnly skips parser generation entirely, so no runtime helpers are needed.
140
156
  if (helpersMode === 'embedded' && !typesOnly) {
141
157
  for (const helper of usedHelpers) {
142
- const { ext, content } = await readHelperSource(helper);
158
+ const { ext, content } = await readHelperSource(helper, importExt);
143
159
  files.push({ filename: `_helpers/${helper}.${ext}`, content });
144
160
  }
145
161
  }
146
- files.push({ filename: 'index.ts', content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false }) });
162
+ files.push({
163
+ filename: 'index.ts',
164
+ content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false, importExt }),
165
+ });
147
166
  return files;
148
167
  };
@@ -1,5 +1,6 @@
1
1
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
2
  import { type HelpersMode, type RuntimeHelperName } from '../helpers/collect-helpers.js';
3
+ import { type ImportExtension } from '../helpers/collect-imports.js';
3
4
  /**
4
5
  * Options for controlling what gets generated in a file.
5
6
  */
@@ -63,6 +64,20 @@ type GenerateFileOptions = {
63
64
  * Defaults to `''` (no suffix). Set to e.g. `'Object'` to emit `ContactObject`.
64
65
  */
65
66
  readonly typeSuffix?: string;
67
+ /**
68
+ * Extension used on every relative import specifier (cross-file `$ref`
69
+ * imports and embedded-helper imports). Defaults to `'js'` (the TS NodeNext
70
+ * form); `'ts'` makes the generated sources runnable under Node's type
71
+ * stripping.
72
+ */
73
+ readonly importExt?: ImportExtension;
74
+ /**
75
+ * True when this file is the root document rather than a `$ref`-reached
76
+ * definition. Forwarded to the parser generator so the JSON Schema
77
+ * meta-schema special case (type name `Schema`) never applies to a
78
+ * user-named root.
79
+ */
80
+ readonly isRoot?: boolean;
66
81
  };
67
82
  /** Result of generating a single parser file. */
68
83
  export type GeneratedFileContent = {
@@ -1,6 +1,6 @@
1
1
  import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition';
2
2
  import { collectHelpers, } from '../helpers/collect-helpers.js';
3
- import { collectImports } from '../helpers/collect-imports.js';
3
+ import { collectImports, collectImportTypeNames } from '../helpers/collect-imports.js';
4
4
  import { generateParserFunction, generateShapeValidator } from './generate-parser-function.js';
5
5
  /**
6
6
  * Generates a complete TypeScript file from a JSON Schema.
@@ -37,10 +37,11 @@ export const generateFile = (schema, typeName, options) => {
37
37
  const rootSchema = options?.rootSchema;
38
38
  const helpersMode = options?.helpersMode ?? 'package';
39
39
  const typeSuffix = options?.typeSuffix ?? '';
40
+ const importExt = options?.importExt ?? 'js';
40
41
  const typeDefinition = generateTypeDefinition(schema, typeName, { readonly: options?.readonly ?? false, typeSuffix });
41
42
  if (typesOnly) {
42
43
  // In types-only mode, skip the parser function and use type-only imports
43
- const imports = collectImports(schema, { typesOnly: true, selfRef, rootSchema, typeSuffix });
44
+ const imports = collectImports(schema, { typesOnly: true, selfRef, rootSchema, typeSuffix, importExt });
44
45
  let result = '';
45
46
  if (imports.length > 0) {
46
47
  result += imports[0];
@@ -52,18 +53,25 @@ export const generateFile = (schema, typeName, options) => {
52
53
  return { content: result + typeDefinition, usedHelpers: new Set() };
53
54
  }
54
55
  const stripUnknown = options?.stripUnknown ?? false;
56
+ // Names this file imports for its $refs: synthesized private sub-type names
57
+ // dedup against them so they can never shadow an imported identifier. The
58
+ // parser and the shape validator must receive the SAME set — their derived
59
+ // sub-names have to agree.
60
+ const reservedNames = collectImportTypeNames(schema, { selfRef, rootSchema, typeSuffix });
55
61
  const parserFunction = generateParserFunction(schema, typeName, {
56
62
  useRefImports: true,
57
63
  typeSuffix,
64
+ reservedNames,
65
+ ...(options?.isRoot !== undefined ? { isRoot: options.isRoot } : {}),
58
66
  ...(rootSchema !== undefined ? { rootSchema } : {}),
59
67
  ...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
60
68
  ...(options?.strict !== undefined ? { strict: options.strict } : {}),
61
69
  ...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
62
70
  });
63
- const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown);
71
+ const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
64
72
  const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
65
- const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix);
66
- const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix }), ...helpers.imports];
73
+ const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix, importExt);
74
+ const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix, importExt }), ...helpers.imports];
67
75
  // Build file output using string concatenation instead of array join for performance
68
76
  let result = '';
69
77
  if (imports.length > 0) {
@@ -42,6 +42,22 @@ type GenerateParserOptions = {
42
42
  * dispatch to the branch parsers, instead of falling back to a passthrough cast.
43
43
  */
44
44
  readonly rootSchema?: Record<string, unknown>;
45
+ /**
46
+ * Type names the generated file imports for its `$ref`s. Synthesized private
47
+ * sub-type names (nested objects, array items, the root array's `{Type}Item`)
48
+ * dedup against this set so they can never shadow an imported identifier —
49
+ * which would both fail to compile (duplicate declaration) and silently
50
+ * validate against the wrong schema.
51
+ */
52
+ readonly reservedNames?: ReadonlySet<string>;
53
+ /**
54
+ * True when this schema is the root document (not a `$ref`-reached definition).
55
+ * The root type name is user-derived (schema `title`, filename, or `--root-type`),
56
+ * so the JSON Schema meta-schema special case — keyed on the literal name
57
+ * `Schema` — must not apply to it, or a common `schema.json` root would silently
58
+ * generate a validation-free pass-through parser.
59
+ */
60
+ readonly isRoot?: boolean;
45
61
  };
46
62
  /**
47
63
  * Generates a safe parser as an arrow function that never throws.
@@ -71,5 +87,5 @@ export declare const shapeValidatorFunctionName: (typeName: string) => string;
71
87
  * in additionalProperties). The stub is safe: parents calling it will fall
72
88
  * through to the slow path, matching the pre-deep-fast-path behavior.
73
89
  */
74
- export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean) => string;
90
+ export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean, reservedNames?: ReadonlySet<string>) => string;
75
91
  export {};