@gabrielbryk/json-schema-to-zod 2.8.0 → 2.9.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cjs/core/analyzeSchema.js +62 -0
  3. package/dist/cjs/core/emitZod.js +141 -0
  4. package/dist/cjs/generators/generateBundle.js +103 -59
  5. package/dist/cjs/index.js +4 -0
  6. package/dist/cjs/jsonSchemaToZod.js +5 -167
  7. package/dist/cjs/parsers/parseSchema.js +124 -24
  8. package/dist/cjs/utils/buildRefRegistry.js +56 -0
  9. package/dist/cjs/utils/resolveUri.js +16 -0
  10. package/dist/esm/Types.js +1 -2
  11. package/dist/esm/cli.js +10 -12
  12. package/dist/esm/core/analyzeSchema.js +58 -0
  13. package/dist/esm/core/emitZod.js +137 -0
  14. package/dist/esm/generators/generateBundle.js +104 -64
  15. package/dist/esm/index.js +34 -46
  16. package/dist/esm/jsonSchemaToZod.js +5 -171
  17. package/dist/esm/parsers/parseAllOf.js +5 -8
  18. package/dist/esm/parsers/parseAnyOf.js +6 -10
  19. package/dist/esm/parsers/parseArray.js +11 -15
  20. package/dist/esm/parsers/parseBoolean.js +1 -5
  21. package/dist/esm/parsers/parseConst.js +1 -5
  22. package/dist/esm/parsers/parseDefault.js +3 -7
  23. package/dist/esm/parsers/parseEnum.js +1 -5
  24. package/dist/esm/parsers/parseIfThenElse.js +5 -9
  25. package/dist/esm/parsers/parseMultipleType.js +3 -7
  26. package/dist/esm/parsers/parseNot.js +4 -8
  27. package/dist/esm/parsers/parseNull.js +1 -5
  28. package/dist/esm/parsers/parseNullable.js +4 -8
  29. package/dist/esm/parsers/parseNumber.js +11 -15
  30. package/dist/esm/parsers/parseObject.js +25 -28
  31. package/dist/esm/parsers/parseOneOf.js +6 -10
  32. package/dist/esm/parsers/parseSchema.js +183 -87
  33. package/dist/esm/parsers/parseSimpleDiscriminatedOneOf.js +6 -10
  34. package/dist/esm/parsers/parseString.js +11 -15
  35. package/dist/esm/utils/anyOrUnknown.js +1 -5
  36. package/dist/esm/utils/buildRefRegistry.js +52 -0
  37. package/dist/esm/utils/cliTools.js +7 -13
  38. package/dist/esm/utils/cycles.js +3 -9
  39. package/dist/esm/utils/half.js +1 -5
  40. package/dist/esm/utils/jsdocs.js +3 -8
  41. package/dist/esm/utils/omit.js +1 -5
  42. package/dist/esm/utils/resolveUri.js +12 -0
  43. package/dist/esm/utils/withMessage.js +1 -4
  44. package/dist/esm/zodToJsonSchema.js +1 -4
  45. package/dist/types/Types.d.ts +28 -0
  46. package/dist/types/core/analyzeSchema.d.ts +24 -0
  47. package/dist/types/core/emitZod.d.ts +2 -0
  48. package/dist/types/generators/generateBundle.d.ts +5 -0
  49. package/dist/types/index.d.ts +4 -0
  50. package/dist/types/jsonSchemaToZod.d.ts +1 -1
  51. package/dist/types/parsers/parseSchema.d.ts +2 -1
  52. package/dist/types/utils/buildRefRegistry.d.ts +12 -0
  53. package/dist/types/utils/resolveUri.d.ts +1 -0
  54. package/docs/proposals/bundle-refactor.md +43 -0
  55. package/docs/proposals/ref-anchor-support.md +65 -0
  56. package/eslint.config.js +26 -0
  57. package/package.json +10 -4
  58. /package/{jest.config.js → jest.config.cjs} +0 -0
  59. /package/{postcjs.js → postcjs.cjs} +0 -0
  60. /package/{postesm.js → postesm.cjs} +0 -0
@@ -1,10 +1,6 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.omit = void 0;
4
- const omit = (obj, ...keys) => Object.keys(obj).reduce((acc, key) => {
1
+ export const omit = (obj, ...keys) => Object.keys(obj).reduce((acc, key) => {
5
2
  if (!keys.includes(key)) {
6
3
  acc[key] = obj[key];
7
4
  }
8
5
  return acc;
9
6
  }, {});
10
- exports.omit = omit;
@@ -0,0 +1,12 @@
1
+ export const resolveUri = (base, ref) => {
2
+ try {
3
+ // If ref is absolute, new URL will accept it; otherwise resolves against base
4
+ return new URL(ref, base).toString();
5
+ }
6
+ catch {
7
+ // Fallback: simple concatenation to avoid throwing; keep ref as-is
8
+ if (ref.startsWith("#"))
9
+ return `${base}${ref}`;
10
+ return ref;
11
+ }
12
+ };
@@ -1,7 +1,4 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.withMessage = withMessage;
4
- function withMessage(schema, key, get) {
1
+ export function withMessage(schema, key, get) {
5
2
  const value = schema[key];
6
3
  let r = "";
7
4
  if (value !== undefined) {
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /**
3
2
  * Post-processor for Zod's z.toJSONSchema() output.
4
3
  *
@@ -15,8 +14,6 @@
15
14
  * - patternProperties (stored in __jsonSchema.patternProperties)
16
15
  * - if/then/else conditionals (stored in __jsonSchema.conditional)
17
16
  */
18
- Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.reconstructJsonSchema = reconstructJsonSchema;
20
17
  /**
21
18
  * Recursively process a JSON Schema to reconstruct original features from __jsonSchema meta.
22
19
  *
@@ -24,7 +21,7 @@ exports.reconstructJsonSchema = reconstructJsonSchema;
24
21
  * - allOf[object, {__jsonSchema: {conditional: ...}}] -> object with if/then/else at top level
25
22
  * - patternProperties meta -> patternProperties at current level
26
23
  */
27
- function reconstructJsonSchema(schema) {
24
+ export function reconstructJsonSchema(schema) {
28
25
  if (typeof schema !== "object" || schema === null) {
29
26
  return schema;
30
27
  }
@@ -99,6 +99,16 @@ export type Options = {
99
99
  * Can be used to log or throw on unknown formats.
100
100
  */
101
101
  onUnknownFormat?: (format: string, path: (string | number)[]) => void;
102
+ /**
103
+ * Called when a $ref/$dynamicRef cannot be resolved.
104
+ * Can be used to log or throw on unknown references.
105
+ */
106
+ onUnresolvedRef?: (ref: string, path: (string | number)[]) => void;
107
+ /**
108
+ * Optional resolver for external $ref URIs.
109
+ * Return a JsonSchema to register, or undefined if not found.
110
+ */
111
+ resolveExternalRef?: (uri: string) => JsonSchema | Promise<JsonSchema> | undefined;
102
112
  };
103
113
  export type Refs = Options & {
104
114
  path: (string | number)[];
@@ -115,6 +125,24 @@ export type Refs = Options & {
115
125
  currentSchemaName?: string;
116
126
  cycleRefNames?: Set<string>;
117
127
  cycleComponentByName?: Map<string, number>;
128
+ /** Base URI in scope while traversing */
129
+ currentBaseUri?: string;
130
+ /** Root/base URI for the document */
131
+ rootBaseUri?: string;
132
+ /** Prebuilt registry of resolved URIs/anchors */
133
+ refRegistry?: Map<string, {
134
+ schema: JsonSchema;
135
+ path: (string | number)[];
136
+ baseUri: string;
137
+ dynamic?: boolean;
138
+ anchor?: string;
139
+ }>;
140
+ /** Stack of active dynamic anchors (nearest last) */
141
+ dynamicAnchors?: {
142
+ name: string;
143
+ uri: string;
144
+ path: (string | number)[];
145
+ }[];
118
146
  };
119
147
  export type SimpleDiscriminatedOneOfSchema<D extends string = string> = JsonSchemaObject & {
120
148
  oneOf: (JsonSchemaObject & {
@@ -0,0 +1,24 @@
1
+ import { Options, JsonSchema } from "../Types.js";
2
+ export type NormalizedOptions = Options & {
3
+ exportRefs: boolean;
4
+ withMeta: boolean;
5
+ };
6
+ export type AnalysisResult = {
7
+ schema: JsonSchema;
8
+ options: NormalizedOptions;
9
+ refNameByPointer: Map<string, string>;
10
+ usedNames: Set<string>;
11
+ declarations: Map<string, string>;
12
+ dependencies: Map<string, Set<string>>;
13
+ cycleRefNames: Set<string>;
14
+ cycleComponentByName: Map<string, number>;
15
+ refRegistry: Map<string, {
16
+ schema: JsonSchema;
17
+ path: (string | number)[];
18
+ baseUri: string;
19
+ dynamic?: boolean;
20
+ anchor?: string;
21
+ }>;
22
+ rootBaseUri: string;
23
+ };
24
+ export declare const analyzeSchema: (schema: JsonSchema, options?: Options) => AnalysisResult;
@@ -0,0 +1,2 @@
1
+ import { AnalysisResult } from "./analyzeSchema.js";
2
+ export declare const emitZod: (analysis: AnalysisResult) => string;
@@ -29,6 +29,11 @@ export type RefResolutionOptions = {
29
29
  path: (string | number)[];
30
30
  isCycle: boolean;
31
31
  }) => RefResolutionResult | undefined;
32
+ /**
33
+ * When true, cross-def references that participate in a cycle are emitted as z.lazy(() => Ref)
34
+ * to avoid TDZ issues across files.
35
+ */
36
+ lazyCrossRefs?: boolean;
32
37
  /** Called for unknown $refs (outside of $defs/definitions) */
33
38
  onUnknownRef?: (ctx: {
34
39
  ref: string;
@@ -1,4 +1,6 @@
1
1
  export * from "./Types.js";
2
+ export * from "./core/analyzeSchema.js";
3
+ export * from "./core/emitZod.js";
2
4
  export * from "./generators/generateBundle.js";
3
5
  export * from "./jsonSchemaToZod.js";
4
6
  export * from "./parsers/parseAllOf.js";
@@ -20,10 +22,12 @@ export * from "./parsers/parseSchema.js";
20
22
  export * from "./parsers/parseSimpleDiscriminatedOneOf.js";
21
23
  export * from "./parsers/parseString.js";
22
24
  export * from "./utils/anyOrUnknown.js";
25
+ export * from "./utils/buildRefRegistry.js";
23
26
  export * from "./utils/cycles.js";
24
27
  export * from "./utils/half.js";
25
28
  export * from "./utils/jsdocs.js";
26
29
  export * from "./utils/omit.js";
30
+ export * from "./utils/resolveUri.js";
27
31
  export * from "./utils/withMessage.js";
28
32
  export * from "./zodToJsonSchema.js";
29
33
  import { jsonSchemaToZod } from "./jsonSchemaToZod.js";
@@ -1,2 +1,2 @@
1
1
  import { Options, JsonSchema } from "./Types.js";
2
- export declare const jsonSchemaToZod: (schema: JsonSchema, { module, name, type, noImport, ...rest }?: Options) => string;
2
+ export declare const jsonSchemaToZod: (schema: JsonSchema, options?: Options) => string;
@@ -29,7 +29,8 @@ export declare const its: {
29
29
  not: JsonSchema;
30
30
  };
31
31
  ref: (x: JsonSchemaObject) => x is JsonSchemaObject & {
32
- $ref: string;
32
+ $ref?: string;
33
+ $dynamicRef?: string;
33
34
  };
34
35
  const: (x: JsonSchemaObject) => x is JsonSchemaObject & {
35
36
  const: Serializable;
@@ -0,0 +1,12 @@
1
+ import { JsonSchema, Options } from "../Types.js";
2
+ export type RefRegistryEntry = {
3
+ schema: JsonSchema;
4
+ path: (string | number)[];
5
+ baseUri: string;
6
+ dynamic?: boolean;
7
+ anchor?: string;
8
+ };
9
+ export declare const buildRefRegistry: (schema: JsonSchema, rootBaseUri?: string, opts?: Options) => {
10
+ registry: Map<string, RefRegistryEntry>;
11
+ rootBaseUri: string;
12
+ };
@@ -0,0 +1 @@
1
+ export declare const resolveUri: (base: string, ref: string) => string;
@@ -0,0 +1,43 @@
1
+ # Schema Bundling Refactor (Analyzer + Emitters)
2
+
3
+ ## Context
4
+ - `generateSchemaBundle` currently recurses via `parserOverride` and can overflow the stack when inline `$defs` are present (root hits immediately). Inline `$defs` inside `$defs` are also overwritten when stitching schemas.
5
+ - The conversion pipeline mixes concerns: parsing/analysis, code emission, and bundling strategy live together in `jsonSchemaToZod` and the bundle generator.
6
+
7
+ ## Goals
8
+ - Single responsibility: analyze JsonSchema once, emit code through pluggable strategies (single file, bundle, nested types).
9
+ - Open for extension: new emitters (e.g., type-only), new ref resolution policies, without touching the analyzer.
10
+ - Safer bundling: no recursive parser overrides; import-aware ref resolution; preserve inline `$defs`.
11
+ - Testable units: analyzer IR and emitters have focused tests; bundle strategy tested with snapshots.
12
+
13
+ ## Proposed Architecture
14
+ - **Analyzer (`analyzeSchema`)**: Convert JsonSchema + options into an intermediate representation (IR) containing symbols, ref pointer map, dependency graph, cycle info, and metadata flags. No code strings.
15
+ - **Emitters**:
16
+ - `emitZod(ir, emitOptions)`: IR → zod code (esm/cjs/none), with naming hooks and export policies.
17
+ - `emitTypes(ir, typeOptions)`: optional type-only exports (for nested types or barrel typing).
18
+ - **Strategies**:
19
+ - `SingleFileStrategy`: analyze root → emit zod once.
20
+ - `BundleStrategy`: analyze root once → slice IR per `$def` + root → emit per-file zod using an import-capable RefResolutionStrategy. Inline `$defs` remain scoped; cross-def `$ref`s become imports; unknown refs handled via policy.
21
+ - `NestedTypesStrategy`: walk IR titles/property paths to emit a dedicated types file.
22
+ - **Public API**:
23
+ - `analyzeSchema(schema, options): AnalysisResult`
24
+ - `emitZod(ir, emitOptions): string`
25
+ - `generateSchemaBundle(schema, bundleOptions): { files }` implemented via BundleStrategy
26
+ - `jsonSchemaToZod(schema, options): string` becomes a thin wrapper (analyze + emit single file).
27
+
28
+ ## SOLID Alignment
29
+ - SRP: analyzer, emitter, strategy are separate modules.
30
+ - OCP: new emitters/strategies plug in without changing analyzer.
31
+ - LSP/ISP: narrow contracts (naming hooks, ref resolution hooks) instead of monolithic option bags.
32
+ - DIP: bundle strategy depends on IR abstractions, not on concrete `jsonSchemaToZod` string output.
33
+
34
+ ## Migration Plan
35
+ 1) **Foundations**: Extract analyzer + zod emitter modules; make `jsonSchemaToZod` call them. Preserve output parity and option validation. Add tests around analyzer/emitter.
36
+ 2) **Bundle Strategy**: Rework `generateSchemaBundle` to use the analyzer IR and an import-aware ref strategy; remove recursive `parserOverride`; preserve inline `$defs` within defs.
37
+ 3) **Nested Types**: Move nested type extraction to IR-based walker; emit via `emitTypes`.
38
+ 4) **Cleanups & API polish**: Reduce option bag coupling; document new APIs; consider default export ergonomics.
39
+
40
+ ## Risks / Mitigations
41
+ - Risk: Output regressions. Mitigation: snapshot tests for single-file and bundle outputs.
42
+ - Risk: Bundle import mapping errors. Mitigation: ref-strategy unit tests (cycles, unknown refs, cross-def).
43
+ - Risk: Incremental refactor churn. Mitigation: keep `jsonSchemaToZod` wrapper stable while internals shift; land in stages with tests.
@@ -0,0 +1,65 @@
1
+ # Proposal: Robust `$ref` / `$id` / `$anchor` / `$dynamicRef` Support
2
+
3
+ ## Goals
4
+ - Resolve `$ref` using full URI semantics (RFC 3986), not just `#/` pointers.
5
+ - Support `$id`/`$anchor`/`$dynamicAnchor`/`$dynamicRef` (and legacy `$recursiveRef/$recursiveAnchor`).
6
+ - Keep resolver logic in the analyzer/IR layer so emitters/strategies stay SOLID (SRP/OCP).
7
+ - Provide hooks for external schema resolution and unresolved-ref handling.
8
+ - Preserve existing `$defs`/JSON Pointer behavior for compatibility.
9
+
10
+ ## Architecture alignment (with bundle refactor)
11
+ - Implement ref/anchor logic in the analyzer; emitters consume IR edges, not URIs.
12
+ - Define a pluggable `RefResolutionStrategy` used by the analyzer:
13
+ - Inputs: `ref`, `contextBaseUri`, `dynamicStack`, `registry`, optional `externalResolver`, `onUnresolvedRef`.
14
+ - Output: resolved IR node (or unresolved marker/fallback).
15
+ - Registry and dynamic stacks are built/maintained during analysis; IR carries resolved targets keyed by URI+fragment.
16
+
17
+ ## Plan
18
+
19
+ ### 1) Build a URI/anchor registry (analyzer prepass)
20
+ - Walk the schema once, tracking base URI (respect `$id`).
21
+ - Register base URI entries, `$anchor` (base#anchor), `$dynamicAnchor` (base#anchor, dynamic flag).
22
+ - Handle relative `$id` resolution per RFC 3986.
23
+ - Attach registry to IR/context.
24
+
25
+ ### 2) URI-based ref resolution
26
+ - `resolveRef(ref, contextBaseUri, registry, dynamicStack)`:
27
+ - Resolve against `contextBaseUri` → absolute URI; split base/fragment.
28
+ - For `$dynamicRef`, search `dynamicStack` top-down for matching anchor; else fallback to registry lookup.
29
+ - For normal `$ref`, look up base+fragment in registry; empty fragment hits base entry.
30
+ - On miss: invoke `onUnresolvedRef` hook and return unresolved marker.
31
+ - Analyzer produces IR references keyed by resolved URI+fragment; name generation uses this key.
32
+
33
+ ### 3) Thread base URI & dynamic stack in analyzer
34
+ - Extend analyzer traversal context (similar to Refs) with `currentBaseUri`, `dynamicAnchors`.
35
+ - On `$id`, compute new base; pass to children.
36
+ - On `$dynamicAnchor`, push onto stack for node scope; pop on exit.
37
+ - Emitters receive IR that already encodes resolved refs.
38
+
39
+ ### 4) Legacy recursive keywords
40
+ - Treat `$recursiveAnchor` as a special dynamic anchor name.
41
+ - Treat `$recursiveRef` like `$dynamicRef` targeting that name.
42
+
43
+ ### 5) External refs (optional, pluggable)
44
+ - Analyzer option `resolveExternalRef(uri)` (sync/async) to fetch external schemas.
45
+ - On external base URI miss, call resolver, prewalk and cache registry for that URI, then resolve.
46
+ - Guard against cycles with in-progress cache.
47
+
48
+ ### 6) Naming & cycles
49
+ - Key ref names by resolved URI+fragment; store map in IR for consistent imports/aliases.
50
+ - Preserve cycle detection using these names.
51
+
52
+ ### 7) Error/warning handling
53
+ - Option `onUnresolvedRef(uri, path)` for logging/throwing.
54
+ - Policy for fallback (`z.any()`/`z.unknown()` or error) lives in emitter/strategy but is driven by analyzer’s unresolved marker.
55
+
56
+ ### 8) Tests
57
+ - Analyzer-level tests: `$id`/`$anchor` resolution (absolute/relative), `$dynamicAnchor`/`$dynamicRef` scoping, legacy recursive, external resolver stub, cycles, backward-compatible `#/` refs.
58
+ - Strategy/emitter tests: bundle imports for cross-file refs, naming stability with URI keys.
59
+
60
+ ### 9) Migration steps
61
+ - Add registry prepass and URI resolver in analyzer.
62
+ - Thread `currentBaseUri`/`dynamicAnchors` through analysis context.
63
+ - Produce IR refs keyed by resolved URI; update naming map/cycle tracking.
64
+ - Add resolver hooks and unresolved handling.
65
+ - Add tests/fixtures; keep emitters unchanged except to consume new IR ref keys.
@@ -0,0 +1,26 @@
1
+ import parser from "@typescript-eslint/parser";
2
+ import pluginTs from "@typescript-eslint/eslint-plugin";
3
+
4
+ export default [
5
+ {
6
+ ignores: ["dist", "node_modules"],
7
+ },
8
+ {
9
+ files: ["**/*.ts", "**/*.tsx"],
10
+ languageOptions: {
11
+ parser,
12
+ parserOptions: {
13
+ ecmaVersion: "latest",
14
+ sourceType: "module",
15
+ },
16
+ },
17
+ plugins: {
18
+ "@typescript-eslint": pluginTs,
19
+ },
20
+ rules: {
21
+ ...pluginTs.configs.recommended.rules,
22
+ "@typescript-eslint/no-require-imports": "error",
23
+ "@typescript-eslint/no-var-requires": "error",
24
+ },
25
+ },
26
+ ];
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@gabrielbryk/json-schema-to-zod",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Converts JSON schema objects or files into Zod schemas",
5
+ "type": "module",
5
6
  "types": "./dist/types/index.d.ts",
6
7
  "bin": "./dist/cjs/cli.js",
7
8
  "main": "./dist/cjs/index.js",
@@ -62,9 +63,12 @@
62
63
  },
63
64
  "devDependencies": {
64
65
  "@changesets/cli": "^2.29.8",
66
+ "@typescript-eslint/eslint-plugin": "^8.49.0",
67
+ "@typescript-eslint/parser": "^8.49.0",
65
68
  "@types/json-schema": "^7.0.15",
66
69
  "@types/node": "^20.9.0",
67
70
  "fast-diff": "^1.3.0",
71
+ "eslint": "^9.39.1",
68
72
  "js-yaml": "^4.1.0",
69
73
  "rimraf": "^5.0.5",
70
74
  "tsx": "^4.1.1",
@@ -73,12 +77,14 @@
73
77
  },
74
78
  "scripts": {
75
79
  "build:types": "tsc -p tsconfig.types.json",
76
- "build:cjs": "tsc -p tsconfig.cjs.json && node postcjs.js",
77
- "build:esm": "tsc -p tsconfig.esm.json && node postesm.js",
80
+ "build:cjs": "tsc -p tsconfig.cjs.json && node postcjs.cjs",
81
+ "build:esm": "tsc -p tsconfig.esm.json && node postesm.cjs",
78
82
  "build": "pnpm gen && pnpm test && rimraf ./dist && pnpm build:types && pnpm build:cjs && pnpm build:esm",
79
83
  "dry": "pnpm build && pnpm publish --dry-run",
80
84
  "dev": "tsx watch test/index.ts",
81
85
  "gen": "tsx ./createIndex.ts",
82
- "test": "tsx test/index.ts"
86
+ "test": "tsx test/index.ts",
87
+ "lint": "eslint \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
88
+ "smoke:esm": "pnpm build:esm && node --input-type=module -e \"import { jsonSchemaToZod } from './dist/esm/index.js'; console.log(jsonSchemaToZod({type:'string'}));\""
83
89
  }
84
90
  }
File without changes
File without changes
File without changes