@amritk/generate-parsers 0.4.0 → 0.5.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.
@@ -34,6 +34,12 @@ export type GeneratedFile = {
34
34
  * @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
35
35
  * `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
36
36
  * as additional `GeneratedFile` entries so the output directory is self-contained.
37
+ * @param helpersImportPrefix - Relative path prefix to the `_helpers/` directory in
38
+ * embedded mode. Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
39
+ * `'../../'`, etc. so nested parsers can import from a single shared `_helpers/`
40
+ * directory while the helper sources are emitted once at the output root.
41
+ * @param readonly - When true, every property, array, and record in the generated type
42
+ * definitions is emitted as `readonly`.
37
43
  * @returns An array of generated TypeScript files
38
44
  *
39
45
  * @example
@@ -66,4 +72,4 @@ export type GeneratedFile = {
66
72
  * });
67
73
  * ```
68
74
  */
69
- export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode) => Promise<GeneratedFile[]>;
75
+ export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean) => Promise<GeneratedFile[]>;
@@ -37,6 +37,18 @@ type GenerateFileOptions = {
37
37
  * be shipped alongside the generated files.
38
38
  */
39
39
  readonly helpersMode?: HelpersMode;
40
+ /**
41
+ * Relative path prefix to the shared `_helpers/` directory in embedded mode.
42
+ * Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
43
+ * `'../../'`, etc. so a nested parser can reach a single `_helpers/` directory
44
+ * at the output root.
45
+ */
46
+ readonly helpersImportPrefix?: string;
47
+ /**
48
+ * When true, every property, array, and record in the generated type
49
+ * definitions is emitted as `readonly`.
50
+ */
51
+ readonly readonly?: boolean;
40
52
  };
41
53
  /** Result of generating a single parser file. */
42
54
  export type GeneratedFileContent = {
@@ -12,5 +12,12 @@ export type CollectedHelpers = {
12
12
  */
13
13
  readonly used: Set<RuntimeHelperName>;
14
14
  };
15
- /** Detects which runtime helpers a generated parser body references. */
16
- export declare const collectHelpers: (parserFunction: string, mode: HelpersMode) => CollectedHelpers;
15
+ /**
16
+ * Detects which runtime helpers a generated parser body references.
17
+ *
18
+ * @param helpersImportPrefix - Relative path prefix to the shared `_helpers/`
19
+ * directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
20
+ * build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
21
+ * `_helpers/` directory at the output root.
22
+ */
23
+ export declare const collectHelpers: (parserFunction: string, mode: HelpersMode, helpersImportPrefix?: string) => CollectedHelpers;
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { buildSchema } from './generators/build-schema';
1
+ export { buildSchema, type GeneratedFile } from './generators/build-schema';
package/dist/index.js CHANGED
@@ -540,12 +540,13 @@ var buildJsDocBlock = (title, description, commentUrl) => {
540
540
  `;
541
541
  return block;
542
542
  };
543
- var getTypeScriptType = (schema) => {
544
- const base = getUnbrandedType(schema);
543
+ var getTypeScriptType = (schema, options = {}) => {
544
+ const base = getUnbrandedType(schema, options);
545
545
  const brand = getMjstBrand(schema);
546
546
  return brand ? `(${base} & { readonly __brand: '${brand}' })` : base;
547
547
  };
548
- var getUnbrandedType = (schema) => {
548
+ var recordType = (keyType, valueType, options) => options.readonly ? `Readonly<Record<${keyType}, ${valueType}>>` : `Record<${keyType}, ${valueType}>`;
549
+ var getUnbrandedType = (schema, options = {}) => {
549
550
  if (typeof schema === "boolean") {
550
551
  return getBooleanSubSchemaType(schema);
551
552
  }
@@ -593,29 +594,29 @@ var getUnbrandedType = (schema) => {
593
594
  return multiEnumUnion;
594
595
  }
595
596
  if (schema.oneOf && Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
596
- let oneOfUnion = getTypeScriptType(schema.oneOf[0]);
597
+ let oneOfUnion = getTypeScriptType(schema.oneOf[0], options);
597
598
  for (let i = 1;i < schema.oneOf.length; i++) {
598
- oneOfUnion += " | " + getTypeScriptType(schema.oneOf[i]);
599
+ oneOfUnion += " | " + getTypeScriptType(schema.oneOf[i], options);
599
600
  }
600
601
  return oneOfUnion;
601
602
  }
602
603
  if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
603
- let anyOfUnion = getTypeScriptType(schema.anyOf[0]);
604
+ let anyOfUnion = getTypeScriptType(schema.anyOf[0], options);
604
605
  for (let i = 1;i < schema.anyOf.length; i++) {
605
- anyOfUnion += " | " + getTypeScriptType(schema.anyOf[i]);
606
+ anyOfUnion += " | " + getTypeScriptType(schema.anyOf[i], options);
606
607
  }
607
608
  return anyOfUnion;
608
609
  }
609
610
  if (schema.allOf && Array.isArray(schema.allOf) && schema.allOf.length > 0) {
610
- let intersectionTypes = getTypeScriptType(schema.allOf[0]);
611
+ let intersectionTypes = getTypeScriptType(schema.allOf[0], options);
611
612
  for (let i = 1;i < schema.allOf.length; i++) {
612
- intersectionTypes += " & " + getTypeScriptType(schema.allOf[i]);
613
+ intersectionTypes += " & " + getTypeScriptType(schema.allOf[i], options);
613
614
  }
614
615
  return intersectionTypes;
615
616
  }
616
617
  const conditionalResult = getConditionalObjectSchema(schema);
617
618
  if (conditionalResult) {
618
- const baseType = getTypeScriptType(conditionalResult.schema);
619
+ const baseType = getTypeScriptType(conditionalResult.schema, options);
619
620
  if (conditionalResult.thenRef) {
620
621
  return `(${baseType}) & ${refToName2(conditionalResult.thenRef)}`;
621
622
  }
@@ -624,20 +625,20 @@ var getUnbrandedType = (schema) => {
624
625
  if (!schema.type) {
625
626
  if (schema.additionalProperties !== undefined) {
626
627
  if (typeof schema.additionalProperties === "boolean") {
627
- return `Record<string, ${getBooleanSubSchemaType(schema.additionalProperties)}>`;
628
+ return recordType("string", getBooleanSubSchemaType(schema.additionalProperties), options);
628
629
  }
629
- return `Record<string, ${getTypeScriptType(schema.additionalProperties)}>`;
630
+ return recordType("string", getTypeScriptType(schema.additionalProperties, options), options);
630
631
  }
631
632
  if (schema.patternProperties && typeof schema.patternProperties === "object") {
632
633
  const firstEntry = Object.entries(schema.patternProperties)[0];
633
634
  if (firstEntry) {
634
635
  const [pattern, value] = firstEntry;
635
636
  if (value !== undefined) {
636
- const valueType = typeof value === "boolean" ? getBooleanSubSchemaType(value) : getTypeScriptType(value);
637
+ const valueType = typeof value === "boolean" ? getBooleanSubSchemaType(value) : getTypeScriptType(value, options);
637
638
  if (pattern === "^x-") {
638
- return `Record<\`x-\${string}\`, ${valueType}>`;
639
+ return recordType("`x-${string}`", valueType, options);
639
640
  }
640
- return `Record<string, ${valueType}>`;
641
+ return recordType("string", valueType, options);
641
642
  }
642
643
  }
643
644
  }
@@ -690,41 +691,42 @@ var getUnbrandedType = (schema) => {
690
691
  return "boolean";
691
692
  case "array":
692
693
  if (schema.items) {
693
- const itemType = getTypeScriptType(schema.items);
694
+ const itemType = getTypeScriptType(schema.items, options);
694
695
  const wrappedItemType = itemType.includes(" | ") ? `(${itemType})` : itemType;
695
- return `${wrappedItemType}[]`;
696
+ return options.readonly ? `readonly ${wrappedItemType}[]` : `${wrappedItemType}[]`;
696
697
  }
697
- return "unknown[]";
698
+ return options.readonly ? "readonly unknown[]" : "unknown[]";
698
699
  case "object":
699
700
  if (schema.properties) {
701
+ const readonlyPrefix = options.readonly ? "readonly " : "";
700
702
  let properties = "";
701
703
  let first = true;
702
704
  for (const key in schema.properties) {
703
705
  const propSchema = schema.properties[key];
704
706
  const isRequired = schema.required?.includes(key) ?? false;
705
707
  const optional = isRequired ? "" : "?";
706
- const propType = getTypeScriptType(propSchema);
708
+ const propType = getTypeScriptType(propSchema, options);
707
709
  if (!first)
708
710
  properties += "; ";
709
- properties += safeKey(key) + optional + ": " + propType;
711
+ properties += readonlyPrefix + safeKey(key) + optional + ": " + propType;
710
712
  first = false;
711
713
  }
712
714
  return "{ " + properties + " }";
713
715
  }
714
716
  if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
715
- const additionalPropType = getTypeScriptType(schema.additionalProperties);
716
- return `Record<string, ${additionalPropType}>`;
717
+ const additionalPropType = getTypeScriptType(schema.additionalProperties, options);
718
+ return recordType("string", additionalPropType, options);
717
719
  }
718
720
  if (schema.patternProperties && typeof schema.patternProperties === "object") {
719
721
  const firstEntry = Object.entries(schema.patternProperties)[0];
720
722
  if (firstEntry) {
721
723
  const [pattern, patternVal] = firstEntry;
722
724
  if (patternVal) {
723
- const valueType = getTypeScriptType(patternVal);
725
+ const valueType = getTypeScriptType(patternVal, options);
724
726
  if (pattern === "^x-") {
725
- return `Record<\`x-\${string}\`, ${valueType}>`;
727
+ return recordType("`x-${string}`", valueType, options);
726
728
  }
727
- return `Record<string, ${valueType}>`;
729
+ return recordType("string", valueType, options);
728
730
  }
729
731
  }
730
732
  }
@@ -733,9 +735,10 @@ var getUnbrandedType = (schema) => {
733
735
  return "unknown";
734
736
  }
735
737
  };
736
- var generateTypeDefinition = (schema, typeName) => {
738
+ var generateTypeDefinition = (schema, typeName, options = {}) => {
739
+ const readonlyPrefix = options.readonly ? "readonly " : "";
737
740
  if (!isObjectLikeSchema(schema)) {
738
- const tsType = getTypeScriptType(schema);
741
+ const tsType = getTypeScriptType(schema, options);
739
742
  let result = "";
740
743
  if (isSchemaObject2(schema) && schema.$comment && typeof schema.$comment === "string") {
741
744
  result += buildJsDocBlock(typeName, schema.$comment);
@@ -763,23 +766,23 @@ var generateTypeDefinition = (schema, typeName) => {
763
766
  if (firstPatternProperty === undefined) {
764
767
  return `export type ${typeName} = Record<string, unknown>;`;
765
768
  }
766
- const patternPropType = typeof firstPatternProperty === "boolean" ? getBooleanSubSchemaType(firstPatternProperty) : getTypeScriptType(firstPatternProperty);
769
+ const patternPropType = typeof firstPatternProperty === "boolean" ? getBooleanSubSchemaType(firstPatternProperty) : getTypeScriptType(firstPatternProperty, options);
767
770
  const keyType = firstPattern === "^x-" ? "`x-${string}`" : "string";
768
771
  let result2 = "";
769
772
  if (jsDocTitle && jsDocDescription) {
770
773
  result2 += buildJsDocBlock(jsDocTitle, jsDocDescription);
771
774
  }
772
- result2 += `export type ${typeName} = Record<${keyType}, ${patternPropType}>;`;
775
+ result2 += `export type ${typeName} = ${recordType(keyType, patternPropType, options)};`;
773
776
  return result2;
774
777
  }
775
778
  if (!hasProperties2 && hasAdditionalProperties2 && normalizedSchema.additionalProperties) {
776
- const additionalPropType = getTypeScriptType(normalizedSchema.additionalProperties);
779
+ const additionalPropType = getTypeScriptType(normalizedSchema.additionalProperties, options);
777
780
  let result2 = "";
778
781
  if (jsDocTitle && jsDocDescription) {
779
782
  result2 += buildJsDocBlock(jsDocTitle, jsDocDescription);
780
783
  }
781
784
  result2 += `export type ${typeName} = {
782
- [key: string]: ${additionalPropType};
785
+ ${readonlyPrefix}[key: string]: ${additionalPropType};
783
786
  };`;
784
787
  return result2;
785
788
  }
@@ -790,8 +793,8 @@ var generateTypeDefinition = (schema, typeName) => {
790
793
  const propSchema = schemaProps[key];
791
794
  const isRequired = normalizedSchema.required?.includes(key) ?? false;
792
795
  const optional = isRequired ? "" : "?";
793
- const propType = getTypeScriptType(propSchema);
794
- const quotedKey = safeKey(key);
796
+ const propType = getTypeScriptType(propSchema, options);
797
+ const quotedKey = readonlyPrefix + safeKey(key);
795
798
  if (!isFirstProp)
796
799
  properties += `
797
800
  `;
@@ -841,31 +844,32 @@ var PACKAGE_IMPORTS = {
841
844
  "validate-record": "import { validateRecord } from '@amritk/helpers/validate-record';",
842
845
  "has-ref": "import { hasRef } from '@amritk/helpers/schema-guards';"
843
846
  };
844
- var EMBEDDED_IMPORTS = {
845
- "is-object": "import { isObject } from './_helpers/is-object';",
846
- "validate-array": "import { validateArray } from './_helpers/validate-array';",
847
- "validate-record": "import { validateRecord } from './_helpers/validate-record';",
848
- "has-ref": "import { hasRef } from './_helpers/has-ref';"
847
+ var EMBEDDED_NAMED_EXPORTS = {
848
+ "is-object": "isObject",
849
+ "validate-array": "validateArray",
850
+ "validate-record": "validateRecord",
851
+ "has-ref": "hasRef"
849
852
  };
850
- var collectHelpers = (parserFunction, mode) => {
853
+ var embeddedImport = (helper, prefix) => `import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}';`;
854
+ var collectHelpers = (parserFunction, mode, helpersImportPrefix = "./") => {
851
855
  const imports = [];
852
856
  const used = new Set;
853
- const table = mode === "embedded" ? EMBEDDED_IMPORTS : PACKAGE_IMPORTS;
857
+ const importFor = (helper) => mode === "embedded" ? embeddedImport(helper, helpersImportPrefix) : PACKAGE_IMPORTS[helper];
854
858
  if (parserFunction.includes("validateArray")) {
855
- imports.push(table["validate-array"]);
859
+ imports.push(importFor("validate-array"));
856
860
  used.add("validate-array");
857
861
  }
858
862
  if (parserFunction.includes("validateRecord")) {
859
- imports.push(table["validate-record"]);
863
+ imports.push(importFor("validate-record"));
860
864
  used.add("validate-record");
861
865
  used.add("is-object");
862
866
  }
863
867
  if (parserFunction.includes("isObject")) {
864
- imports.push(table["is-object"]);
868
+ imports.push(importFor("is-object"));
865
869
  used.add("is-object");
866
870
  }
867
871
  if (parserFunction.includes("hasRef(")) {
868
- imports.push(table["has-ref"]);
872
+ imports.push(importFor("has-ref"));
869
873
  used.add("has-ref");
870
874
  }
871
875
  return { imports, used };
@@ -2562,7 +2566,7 @@ var generateFile = (schema, typeName, options) => {
2562
2566
  const selfRef = options?.selfRef;
2563
2567
  const rootSchema = options?.rootSchema;
2564
2568
  const helpersMode = options?.helpersMode ?? "package";
2565
- const typeDefinition = generateTypeDefinition(schema, typeName);
2569
+ const typeDefinition = generateTypeDefinition(schema, typeName, { readonly: options?.readonly ?? false });
2566
2570
  if (typesOnly) {
2567
2571
  const imports2 = collectImports(schema, { typesOnly: true, selfRef, rootSchema });
2568
2572
  let result2 = "";
@@ -2587,7 +2591,7 @@ var generateFile = (schema, typeName, options) => {
2587
2591
  const combinedFunctions = `${shapeValidator}
2588
2592
 
2589
2593
  ${parserFunction}`;
2590
- const helpers = collectHelpers(combinedFunctions, helpersMode);
2594
+ const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix);
2591
2595
  const imports = [...collectImports(schema, { selfRef, rootSchema }), ...helpers.imports];
2592
2596
  let result = "";
2593
2597
  if (imports.length > 0) {
@@ -2631,7 +2635,7 @@ var extractDynamicAnchorDefs = (schema) => {
2631
2635
  }
2632
2636
  return refs;
2633
2637
  };
2634
- var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package") => {
2638
+ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false) => {
2635
2639
  rootSchema = upgradeDraft07Schema(rootSchema);
2636
2640
  const files = [];
2637
2641
  const processedRefs = new Set;
@@ -2645,6 +2649,8 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
2645
2649
  typesOnly: typesOnly ?? false,
2646
2650
  rootSchema,
2647
2651
  helpersMode,
2652
+ helpersImportPrefix,
2653
+ readonly,
2648
2654
  ...logWarnings !== undefined ? { logWarnings } : {},
2649
2655
  ...strict !== undefined ? { strict } : {}
2650
2656
  });
@@ -2680,6 +2686,8 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
2680
2686
  selfRef: ref,
2681
2687
  rootSchema,
2682
2688
  helpersMode,
2689
+ helpersImportPrefix,
2690
+ readonly,
2683
2691
  ...logWarnings !== undefined ? { logWarnings } : {},
2684
2692
  ...strict !== undefined ? { strict } : {}
2685
2693
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "json-schema-typed": "^8.0.1",
52
- "@amritk/helpers": "0.4.0",
52
+ "@amritk/helpers": "0.5.0",
53
53
  "@amritk/generate-markdown": "0.2.1"
54
54
  }
55
55
  }
@@ -681,6 +681,30 @@ describe('build-schema', () => {
681
681
  expect(filenames).toContain('_helpers/is-object.ts')
682
682
  })
683
683
 
684
+ it('honours a custom helpersImportPrefix so nested parsers import from a shared root _helpers/', async () => {
685
+ const schema: JSONSchema = {
686
+ type: 'object',
687
+ properties: { name: { type: 'string' } },
688
+ }
689
+
690
+ const result = await buildSchema(
691
+ schema,
692
+ 'Document',
693
+ undefined,
694
+ undefined,
695
+ undefined,
696
+ undefined,
697
+ 'embedded',
698
+ '../../',
699
+ )
700
+ const root = result.find((f) => f.filename === 'document.ts')
701
+ const filenames = result.map((f) => f.filename)
702
+
703
+ expect(root?.content).toContain("import { isObject } from '../../_helpers/is-object';")
704
+ // Helper sources keep their canonical filenames; the orchestrator relocates them to the root.
705
+ expect(filenames).toContain('_helpers/is-object.ts')
706
+ })
707
+
684
708
  it('ships validate-array.ts (plus its transitive is-object.ts) for array schemas', async () => {
685
709
  const schema: JSONSchema = {
686
710
  type: 'object',
@@ -90,6 +90,12 @@ const extractDynamicAnchorDefs = (schema: JSONSchema): string[] => {
90
90
  * @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
91
91
  * `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
92
92
  * as additional `GeneratedFile` entries so the output directory is self-contained.
93
+ * @param helpersImportPrefix - Relative path prefix to the `_helpers/` directory in
94
+ * embedded mode. Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
95
+ * `'../../'`, etc. so nested parsers can import from a single shared `_helpers/`
96
+ * directory while the helper sources are emitted once at the output root.
97
+ * @param readonly - When true, every property, array, and record in the generated type
98
+ * definitions is emitted as `readonly`.
93
99
  * @returns An array of generated TypeScript files
94
100
  *
95
101
  * @example
@@ -130,6 +136,8 @@ export const buildSchema = async (
130
136
  logWarnings?: boolean,
131
137
  strict?: boolean,
132
138
  helpersMode: HelpersMode = 'package',
139
+ helpersImportPrefix = './',
140
+ readonly = false,
133
141
  ): Promise<GeneratedFile[]> => {
134
142
  // Upgrade draft-07 schemas to 2020-12 conventions before processing.
135
143
  // This renames `definitions` → `$defs` recursively so the rest of the
@@ -155,6 +163,8 @@ export const buildSchema = async (
155
163
  typesOnly: typesOnly ?? false,
156
164
  rootSchema: rootSchema as Record<string, unknown>,
157
165
  helpersMode,
166
+ helpersImportPrefix,
167
+ readonly,
158
168
  ...(logWarnings !== undefined ? { logWarnings } : {}),
159
169
  ...(strict !== undefined ? { strict } : {}),
160
170
  })
@@ -205,6 +215,8 @@ export const buildSchema = async (
205
215
  selfRef: ref,
206
216
  rootSchema: rootSchema as Record<string, unknown>,
207
217
  helpersMode,
218
+ helpersImportPrefix,
219
+ readonly,
208
220
  ...(logWarnings !== undefined ? { logWarnings } : {}),
209
221
  ...(strict !== undefined ? { strict } : {}),
210
222
  })
@@ -47,6 +47,18 @@ type GenerateFileOptions = {
47
47
  * be shipped alongside the generated files.
48
48
  */
49
49
  readonly helpersMode?: HelpersMode
50
+ /**
51
+ * Relative path prefix to the shared `_helpers/` directory in embedded mode.
52
+ * Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
53
+ * `'../../'`, etc. so a nested parser can reach a single `_helpers/` directory
54
+ * at the output root.
55
+ */
56
+ readonly helpersImportPrefix?: string
57
+ /**
58
+ * When true, every property, array, and record in the generated type
59
+ * definitions is emitted as `readonly`.
60
+ */
61
+ readonly readonly?: boolean
50
62
  }
51
63
 
52
64
  /** Result of generating a single parser file. */
@@ -93,7 +105,7 @@ export const generateFile = (
93
105
  const selfRef = options?.selfRef
94
106
  const rootSchema = options?.rootSchema
95
107
  const helpersMode: HelpersMode = options?.helpersMode ?? 'package'
96
- const typeDefinition = generateTypeDefinition(schema, typeName)
108
+ const typeDefinition = generateTypeDefinition(schema, typeName, { readonly: options?.readonly ?? false })
97
109
 
98
110
  if (typesOnly) {
99
111
  // In types-only mode, skip the parser function and use type-only imports
@@ -118,7 +130,7 @@ export const generateFile = (
118
130
  })
119
131
  const shapeValidator = generateShapeValidator(schema, typeName, true)
120
132
  const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`
121
- const helpers: CollectedHelpers = collectHelpers(combinedFunctions, helpersMode)
133
+ const helpers: CollectedHelpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix)
122
134
  const imports = [...collectImports(schema, { selfRef, rootSchema }), ...helpers.imports]
123
135
 
124
136
  // Build file output using string concatenation instead of array join for performance
@@ -99,5 +99,17 @@ describe('collect-helpers', () => {
99
99
  expect(result.imports).toContain("import { validateRecord } from './_helpers/validate-record';")
100
100
  expect(result.imports).toContain("import { isObject } from './_helpers/is-object';")
101
101
  })
102
+
103
+ it('applies a custom import prefix so nested parsers can reach a shared _helpers/ at the root', () => {
104
+ const parser = 'if (isObject(input)) { const arr = validateArray(input.items, parseItem) }'
105
+ const result = collectHelpers(parser, 'embedded', '../../')
106
+ expect(result.imports).toContain("import { isObject } from '../../_helpers/is-object';")
107
+ expect(result.imports).toContain("import { validateArray } from '../../_helpers/validate-array';")
108
+ })
109
+
110
+ it('ignores the import prefix in package mode', () => {
111
+ const result = collectHelpers('if (isObject(input)) {', 'package', '../')
112
+ expect(result.imports).toEqual(["import { isObject } from '@amritk/helpers/is-object';"])
113
+ })
102
114
  })
103
115
  })
@@ -23,26 +23,41 @@ const PACKAGE_IMPORTS: Record<RuntimeHelperName, string> = {
23
23
  'has-ref': "import { hasRef } from '@amritk/helpers/schema-guards';",
24
24
  }
25
25
 
26
- const EMBEDDED_IMPORTS: Record<RuntimeHelperName, string> = {
27
- 'is-object': "import { isObject } from './_helpers/is-object';",
28
- 'validate-array': "import { validateArray } from './_helpers/validate-array';",
29
- 'validate-record': "import { validateRecord } from './_helpers/validate-record';",
30
- 'has-ref': "import { hasRef } from './_helpers/has-ref';",
26
+ const EMBEDDED_NAMED_EXPORTS: Record<RuntimeHelperName, string> = {
27
+ 'is-object': 'isObject',
28
+ 'validate-array': 'validateArray',
29
+ 'validate-record': 'validateRecord',
30
+ 'has-ref': 'hasRef',
31
31
  }
32
32
 
33
- /** Detects which runtime helpers a generated parser body references. */
34
- export const collectHelpers = (parserFunction: string, mode: HelpersMode): CollectedHelpers => {
33
+ const embeddedImport = (helper: RuntimeHelperName, prefix: string): string =>
34
+ `import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}';`
35
+
36
+ /**
37
+ * Detects which runtime helpers a generated parser body references.
38
+ *
39
+ * @param helpersImportPrefix - Relative path prefix to the shared `_helpers/`
40
+ * directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
41
+ * build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
42
+ * `_helpers/` directory at the output root.
43
+ */
44
+ export const collectHelpers = (
45
+ parserFunction: string,
46
+ mode: HelpersMode,
47
+ helpersImportPrefix = './',
48
+ ): CollectedHelpers => {
35
49
  const imports: string[] = []
36
50
  const used = new Set<RuntimeHelperName>()
37
- const table = mode === 'embedded' ? EMBEDDED_IMPORTS : PACKAGE_IMPORTS
51
+ const importFor = (helper: RuntimeHelperName): string =>
52
+ mode === 'embedded' ? embeddedImport(helper, helpersImportPrefix) : PACKAGE_IMPORTS[helper]
38
53
 
39
54
  if (parserFunction.includes('validateArray')) {
40
- imports.push(table['validate-array'])
55
+ imports.push(importFor('validate-array'))
41
56
  used.add('validate-array')
42
57
  }
43
58
 
44
59
  if (parserFunction.includes('validateRecord')) {
45
- imports.push(table['validate-record'])
60
+ imports.push(importFor('validate-record'))
46
61
  used.add('validate-record')
47
62
  // The embedded validate-record.ts imports is-object, so it must be shipped too —
48
63
  // but only the `validate-record` line is needed in the parser file unless the
@@ -51,12 +66,12 @@ export const collectHelpers = (parserFunction: string, mode: HelpersMode): Colle
51
66
  }
52
67
 
53
68
  if (parserFunction.includes('isObject')) {
54
- imports.push(table['is-object'])
69
+ imports.push(importFor('is-object'))
55
70
  used.add('is-object')
56
71
  }
57
72
 
58
73
  if (parserFunction.includes('hasRef(')) {
59
- imports.push(table['has-ref'])
74
+ imports.push(importFor('has-ref'))
60
75
  used.add('has-ref')
61
76
  }
62
77
 
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { buildSchema } from './generators/build-schema'
1
+ export { buildSchema, type GeneratedFile } from './generators/build-schema'