@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.
- package/dist/generators/build-schema.d.ts +7 -1
- package/dist/generators/generate-files.d.ts +12 -0
- package/dist/helpers/collect-helpers.d.ts +9 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +55 -47
- package/package.json +2 -2
- package/src/generators/build-schema.test.ts +24 -0
- package/src/generators/build-schema.ts +12 -0
- package/src/generators/generate-files.ts +14 -2
- package/src/helpers/collect-helpers.test.ts +12 -0
- package/src/helpers/collect-helpers.ts +27 -12
- package/src/index.ts +1 -1
|
@@ -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
|
-
/**
|
|
16
|
-
|
|
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
|
|
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
|
|
628
|
+
return recordType("string", getBooleanSubSchemaType(schema.additionalProperties), options);
|
|
628
629
|
}
|
|
629
|
-
return
|
|
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 `
|
|
639
|
+
return recordType("`x-${string}`", valueType, options);
|
|
639
640
|
}
|
|
640
|
-
return
|
|
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
|
|
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 `
|
|
727
|
+
return recordType("`x-${string}`", valueType, options);
|
|
726
728
|
}
|
|
727
|
-
return
|
|
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} =
|
|
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
|
|
845
|
-
"is-object": "
|
|
846
|
-
"validate-array": "
|
|
847
|
-
"validate-record": "
|
|
848
|
-
"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
|
|
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
|
|
857
|
+
const importFor = (helper) => mode === "embedded" ? embeddedImport(helper, helpersImportPrefix) : PACKAGE_IMPORTS[helper];
|
|
854
858
|
if (parserFunction.includes("validateArray")) {
|
|
855
|
-
imports.push(
|
|
859
|
+
imports.push(importFor("validate-array"));
|
|
856
860
|
used.add("validate-array");
|
|
857
861
|
}
|
|
858
862
|
if (parserFunction.includes("validateRecord")) {
|
|
859
|
-
imports.push(
|
|
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(
|
|
868
|
+
imports.push(importFor("is-object"));
|
|
865
869
|
used.add("is-object");
|
|
866
870
|
}
|
|
867
871
|
if (parserFunction.includes("hasRef(")) {
|
|
868
|
-
imports.push(
|
|
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.
|
|
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.
|
|
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
|
|
27
|
-
'is-object':
|
|
28
|
-
'validate-array':
|
|
29
|
-
'validate-record':
|
|
30
|
-
'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
|
-
|
|
34
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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'
|