schema-components 2.1.0 → 2.1.1
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 +35 -0
- package/dist/core/renderField.mjs +41 -7
- package/dist/html/streamRenderers.d.mts +12 -3
- package/dist/html/streamRenderers.mjs +56 -10
- package/package.json +5 -3
- package/src/vue/SchemaComponent.vue +274 -0
- package/src/vue/SchemaErrorBoundary.vue +60 -0
- package/src/vue/SchemaField.vue +178 -0
- package/src/vue/SchemaProvider.vue +39 -0
- package/src/vue/SchemaView.vue +198 -0
- package/src/vue/VNodeHost.ts +32 -0
- package/src/vue/contexts.ts +116 -0
- package/src/vue/eventTargets.ts +35 -0
- package/src/vue/headless.ts +61 -0
- package/src/vue/idPrefix.ts +79 -0
- package/src/vue/renderField.ts +182 -0
- package/src/vue/renderers.ts +1297 -0
- package/src/vue/resolver.ts +45 -0
- package/src/vue/types.ts +140 -0
- package/src/vue/vue-shim.d.ts +25 -0
- package/src/vue/widget.ts +51 -0
package/README.md
CHANGED
|
@@ -117,6 +117,41 @@ For yarn, use the `resolutions` field with the same value.
|
|
|
117
117
|
|
|
118
118
|
The test suite is parametrised with a `unit-preact` Vitest project that runs the same files under `preact/compat` aliasing. Run it via `pnpm test:preact` from the repo root, or directly with `pnpm exec vitest run --project=unit-preact` from `packages/core`. A small set of tests (the ones bound to the three known limitations above) fail intentionally; the remaining ~99% pass under both runtimes and establish the cross-runtime regression boundary.
|
|
119
119
|
|
|
120
|
+
### Vue support
|
|
121
|
+
|
|
122
|
+
Vue 3.5+ is supported as an optional peer dependency. The Vue adapter ships as **source** under `schema-components/vue/*` — the `.vue` Single File Components are not pre-compiled into the published tarball. Consumers need a Vite or webpack toolchain with `@vitejs/plugin-vue` (or equivalent) to compile the SFCs at their own build step.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// vite.config.ts
|
|
126
|
+
import { defineConfig } from "vite";
|
|
127
|
+
import vue from "@vitejs/plugin-vue";
|
|
128
|
+
|
|
129
|
+
export default defineConfig({
|
|
130
|
+
plugins: [vue()],
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```vue
|
|
135
|
+
<script setup lang="ts">
|
|
136
|
+
import { ref } from "vue";
|
|
137
|
+
import SchemaComponent from "schema-components/vue/SchemaComponent.vue";
|
|
138
|
+
import { z } from "zod";
|
|
139
|
+
|
|
140
|
+
const userSchema = z.object({
|
|
141
|
+
name: z.string(),
|
|
142
|
+
email: z.email(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const user = ref({ name: "Ada", email: "ada@example.com" });
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
<template>
|
|
149
|
+
<SchemaComponent :schema="userSchema" v-model="user" />
|
|
150
|
+
</template>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The `./vue/*` export subpath resolves to the source tree (`src/vue/*.ts`) and `./vue/*.vue` resolves to the `.vue` SFCs under `src/vue/`. The same pattern is used by the Svelte adapter — both rely on the consumer's bundler to handle the framework-specific compilation step.
|
|
154
|
+
|
|
120
155
|
## `SchemaComponent`
|
|
121
156
|
|
|
122
157
|
The single entry point. Accepts Zod schemas, JSON Schema objects, or OpenAPI documents:
|
|
@@ -6,13 +6,14 @@ import { SchemaRenderError } from "./errors.mjs";
|
|
|
6
6
|
*
|
|
7
7
|
* Centralises the dispatch loop shared by the React `SchemaComponent` /
|
|
8
8
|
* `SchemaView` renderers, the synchronous HTML renderer in
|
|
9
|
-
* `renderToHtml`,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* `renderToHtml`, the streaming HTML renderer in `streamRenderers.ts`
|
|
10
|
+
* (for its leaf path — see "Streaming integration" below), and (in
|
|
11
|
+
* the future) Vue / Solid / Svelte / Lit adapters. The dispatcher is
|
|
12
|
+
* intentionally framework-agnostic: it neither imports React nor
|
|
13
|
+
* produces HTML strings directly. Each adapter supplies a small
|
|
14
|
+
* {@link DispatchConfig} describing how to build per-field props, how
|
|
15
|
+
* to handle a successful or absent resolver lookup, and (optionally)
|
|
16
|
+
* how to handle widget overrides and the recursion-depth cap.
|
|
16
17
|
*
|
|
17
18
|
* The dispatch order is fixed and matches the historic React-side
|
|
18
19
|
* behaviour so the React, HTML, and future adapters all observe the
|
|
@@ -32,6 +33,39 @@ import { SchemaRenderError } from "./errors.mjs";
|
|
|
32
33
|
* The helpers that find render functions, merge resolvers, and build
|
|
33
34
|
* the per-field props live in {@link "./renderer.ts"} and are reused
|
|
34
35
|
* here — `core/renderField.ts` is purely the dispatch shell.
|
|
36
|
+
*
|
|
37
|
+
* # Streaming integration (design choice B)
|
|
38
|
+
*
|
|
39
|
+
* The streaming HTML renderer (`html/streamRenderers.ts` +
|
|
40
|
+
* `html/renderToHtmlStream.ts`) consumes this dispatcher for leaf
|
|
41
|
+
* field types — `string`, `number`, `boolean`, `enum`, `literal`,
|
|
42
|
+
* `file`, `unknown` — and for variants without a dedicated streaming
|
|
43
|
+
* generator (`null`, `tuple`, `conditional`, `negation`, `never`).
|
|
44
|
+
* Container types (`object`, `array`, `record`, `union`,
|
|
45
|
+
* `discriminatedUnion`) keep bespoke generator implementations
|
|
46
|
+
* because the dispatcher's single-output contract cannot express the
|
|
47
|
+
* "yield opening tag → recurse into children → yield closing tag"
|
|
48
|
+
* chunk-boundary semantics that streaming depends on.
|
|
49
|
+
*
|
|
50
|
+
* We deliberately chose this approach (the Phase 1 agent's "option
|
|
51
|
+
* B" — leaves dispatch through the shared loop, containers keep their
|
|
52
|
+
* own iteration) over the alternative of building a generator-output
|
|
53
|
+
* mode into the dispatcher itself. Approach B preserves the existing
|
|
54
|
+
* chunk boundaries byte-for-byte while still eliminating the duplicate
|
|
55
|
+
* resolver-lookup logic that previously lived in `renderLeaf`. A
|
|
56
|
+
* generator-aware dispatcher would require either a parallel "stream
|
|
57
|
+
* resolver" shape or a unified return type wide enough to cover both
|
|
58
|
+
* single-output and iterable cases — neither of which is justified by
|
|
59
|
+
* the small amount of dispatch logic the leaf path needs.
|
|
60
|
+
*
|
|
61
|
+
* The streaming `streamField` function performs its own depth check
|
|
62
|
+
* before invoking the dispatcher for leaf paths. The check appears
|
|
63
|
+
* textually in both places (streamField and this dispatcher) but at
|
|
64
|
+
* runtime fires exactly once per recursion step: streamField's guard
|
|
65
|
+
* filters the streaming path, and the dispatcher's guard remains in
|
|
66
|
+
* place for the sync HTML and React callers that do not pre-filter
|
|
67
|
+
* depth themselves. See `html/streamRenderers.ts` for the matching
|
|
68
|
+
* commentary.
|
|
35
69
|
*/
|
|
36
70
|
/**
|
|
37
71
|
* Framework-agnostic dispatch loop shared by the React, HTML, and
|
|
@@ -19,10 +19,19 @@ declare function yieldClose(el: HtmlElement): string;
|
|
|
19
19
|
/**
|
|
20
20
|
* Render a leaf {@link WalkedField} entirely as a single HTML chunk.
|
|
21
21
|
* Used inside the streaming generators when descent into containers is
|
|
22
|
-
* complete.
|
|
23
|
-
*
|
|
22
|
+
* complete.
|
|
23
|
+
*
|
|
24
|
+
* Delegates to the framework-agnostic {@link dispatchRenderField}
|
|
25
|
+
* dispatcher so resolver lookup, the (unused) widget step, error
|
|
26
|
+
* wrapping, and the unresolved-type fallback share one implementation
|
|
27
|
+
* with the sync HTML renderer and the React adapter.
|
|
28
|
+
*
|
|
29
|
+
* @param depth - Current recursion depth — defaults to `0` so the
|
|
30
|
+
* public signature stays additive. Callers inside the streaming
|
|
31
|
+
* pipeline thread the live `currentDepth` so the dispatcher's
|
|
32
|
+
* internal depth check is consistent with the streamField gate.
|
|
24
33
|
*/
|
|
25
|
-
declare function renderLeaf(tree: WalkedField, value: unknown, mergedResolver: HtmlResolver, path: string): string;
|
|
34
|
+
declare function renderLeaf(tree: WalkedField, value: unknown, mergedResolver: HtmlResolver, path: string, depth?: number): string;
|
|
26
35
|
/**
|
|
27
36
|
* Drain {@link streamField} into a single string. Used when a streamed
|
|
28
37
|
* sub-tree needs to be embedded inside a non-streaming chunk (e.g. as
|
|
@@ -3,6 +3,7 @@ import "../core/limits.mjs";
|
|
|
3
3
|
import { emitDiagnostic } from "../core/diagnostics.mjs";
|
|
4
4
|
import { SC_CLASSES } from "../core/cssClasses.mjs";
|
|
5
5
|
import { panelIdFor, tabIdFor } from "../core/idPath.mjs";
|
|
6
|
+
import { dispatchRenderField } from "../core/renderField.mjs";
|
|
6
7
|
import { getHtmlRenderFn } from "../core/renderer.mjs";
|
|
7
8
|
import { matchUnionOption, resolveDiscriminatedActive } from "../core/unionMatch.mjs";
|
|
8
9
|
import { VOID_ELEMENTS, h, raw, serialize, serializeAttributes } from "./html.mjs";
|
|
@@ -29,14 +30,15 @@ function yieldClose(el) {
|
|
|
29
30
|
return `</${el.tag}>`;
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Build the per-leaf {@link HtmlRenderProps} bundle handed to resolver
|
|
34
|
+
* render functions and (in future) to widget renderers. The streaming
|
|
35
|
+
* pipeline never recurses through `renderChild` for leaves — container
|
|
36
|
+
* recursion happens through the streamField generators — so the
|
|
37
|
+
* supplied `renderChild` is the constant `() => ""` stub matching the
|
|
38
|
+
* historic streaming-leaf shape.
|
|
36
39
|
*/
|
|
37
|
-
function
|
|
38
|
-
const
|
|
39
|
-
if (renderFn !== void 0) return renderFn({
|
|
40
|
+
function buildLeafProps(tree, value, path) {
|
|
41
|
+
const props = {
|
|
40
42
|
value,
|
|
41
43
|
readOnly: tree.editability === "presentation",
|
|
42
44
|
writeOnly: tree.editability === "input",
|
|
@@ -45,11 +47,55 @@ function renderLeaf(tree, value, mergedResolver, path) {
|
|
|
45
47
|
path,
|
|
46
48
|
tree,
|
|
47
49
|
renderChild: () => ""
|
|
48
|
-
}
|
|
50
|
+
};
|
|
51
|
+
if (tree.examples !== void 0) props.examples = tree.examples;
|
|
52
|
+
return props;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the streaming-friendly placeholder a {@link dispatchRenderField}
|
|
56
|
+
* fallback emits when no resolver handled the leaf type. The sync HTML
|
|
57
|
+
* dispatcher throws in this position — streaming must keep producing
|
|
58
|
+
* output, so the streaming adapter renders the same `<span>` shape
|
|
59
|
+
* `renderLeaf` historically produced for unresolved types.
|
|
60
|
+
*/
|
|
61
|
+
function leafFallbackHtml(value) {
|
|
49
62
|
if (value === void 0 || value === null) return serialize(h("span", { class: SC_CLASSES.valueEmpty }, "—"));
|
|
50
63
|
return serialize(h("span", { class: SC_CLASSES.value }, typeof value === "string" ? value : JSON.stringify(value)));
|
|
51
64
|
}
|
|
52
65
|
/**
|
|
66
|
+
* Render a leaf {@link WalkedField} entirely as a single HTML chunk.
|
|
67
|
+
* Used inside the streaming generators when descent into containers is
|
|
68
|
+
* complete.
|
|
69
|
+
*
|
|
70
|
+
* Delegates to the framework-agnostic {@link dispatchRenderField}
|
|
71
|
+
* dispatcher so resolver lookup, the (unused) widget step, error
|
|
72
|
+
* wrapping, and the unresolved-type fallback share one implementation
|
|
73
|
+
* with the sync HTML renderer and the React adapter.
|
|
74
|
+
*
|
|
75
|
+
* @param depth - Current recursion depth — defaults to `0` so the
|
|
76
|
+
* public signature stays additive. Callers inside the streaming
|
|
77
|
+
* pipeline thread the live `currentDepth` so the dispatcher's
|
|
78
|
+
* internal depth check is consistent with the streamField gate.
|
|
79
|
+
*/
|
|
80
|
+
function renderLeaf(tree, value, mergedResolver, path, depth = 0) {
|
|
81
|
+
return dispatchRenderField({
|
|
82
|
+
tree,
|
|
83
|
+
value,
|
|
84
|
+
path,
|
|
85
|
+
depth,
|
|
86
|
+
resolver: mergedResolver,
|
|
87
|
+
config: {
|
|
88
|
+
buildProps: (fieldTree, fieldPath) => buildLeafProps(fieldTree, value, fieldPath),
|
|
89
|
+
lookupRenderFn: (type, htmlResolver) => getHtmlRenderFn(type, htmlResolver),
|
|
90
|
+
recursionSentinel: (fieldTree) => {
|
|
91
|
+
return recursionSentinelHtml(typeof fieldTree.meta.description === "string" ? fieldTree.meta.description : "schema");
|
|
92
|
+
},
|
|
93
|
+
fallback: (_fieldTree, fieldValue) => leafFallbackHtml(fieldValue),
|
|
94
|
+
coerceResult: (result) => typeof result === "string" ? result : void 0
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
53
99
|
* Drain {@link streamField} into a single string. Used when a streamed
|
|
54
100
|
* sub-tree needs to be embedded inside a non-streaming chunk (e.g. as
|
|
55
101
|
* children of a parent element).
|
|
@@ -87,7 +133,7 @@ function* streamField(tree, value, mergedResolver, path, rawResolver, currentDep
|
|
|
87
133
|
const effectiveValue = value ?? tree.defaultValue;
|
|
88
134
|
const type = tree.type;
|
|
89
135
|
if (type === "string" || type === "number" || type === "boolean" || type === "enum" || type === "literal" || type === "file" || type === "unknown") {
|
|
90
|
-
yield renderLeaf(tree, effectiveValue, mergedResolver, path);
|
|
136
|
+
yield renderLeaf(tree, effectiveValue, mergedResolver, path, currentDepth);
|
|
91
137
|
return;
|
|
92
138
|
}
|
|
93
139
|
if (type === "union") {
|
|
@@ -110,7 +156,7 @@ function* streamField(tree, value, mergedResolver, path, rawResolver, currentDep
|
|
|
110
156
|
yield* streamRecord(tree, value, mergedResolver, path, rawResolver, currentDepth, diagnostics);
|
|
111
157
|
return;
|
|
112
158
|
}
|
|
113
|
-
yield renderLeaf(tree, value, mergedResolver, path);
|
|
159
|
+
yield renderLeaf(tree, value, mergedResolver, path, currentDepth);
|
|
114
160
|
}
|
|
115
161
|
function* streamObject(tree, value, mergedResolver, path, rawResolver, currentDepth, diagnostics) {
|
|
116
162
|
if (tree.type !== "object") return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "schema-components",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "React components that render UI from Zod schemas, JSON Schema, and OpenAPI documents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
"types": "./dist/themes/*.d.mts",
|
|
30
30
|
"import": "./dist/themes/*.mjs"
|
|
31
31
|
},
|
|
32
|
+
"./vue/*.vue": "./src/vue/*.vue",
|
|
32
33
|
"./vue/*": {
|
|
33
|
-
"types": "./
|
|
34
|
-
"import": "./
|
|
34
|
+
"types": "./src/vue/*.ts",
|
|
35
|
+
"import": "./src/vue/*.ts"
|
|
35
36
|
},
|
|
36
37
|
"./svelte/*.svelte": "./src/svelte/*.svelte",
|
|
37
38
|
"./svelte/renderers/*.svelte": "./src/svelte/renderers/*.svelte",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"files": [
|
|
53
54
|
"dist",
|
|
54
55
|
"src/svelte",
|
|
56
|
+
"src/vue",
|
|
55
57
|
"LICENSE",
|
|
56
58
|
"README.md",
|
|
57
59
|
"CHANGELOG.md"
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<SchemaComponent>` — Vue counterpart of the React `<SchemaComponent>`.
|
|
4
|
+
*
|
|
5
|
+
* Auto-detects the input format, normalises to JSON Schema via the
|
|
6
|
+
* adapter, walks the JSON Schema tree, and delegates per-field
|
|
7
|
+
* rendering to the {@link VueComponentResolver} supplied via
|
|
8
|
+
* `<SchemaProvider>` — falling back to the headless renderer when no
|
|
9
|
+
* provider is present.
|
|
10
|
+
*
|
|
11
|
+
* `onChange` semantics: the component accepts BOTH a `v-model`
|
|
12
|
+
* binding (Vue-idiomatic — `modelValue` prop +
|
|
13
|
+
* `update:modelValue` emit) and an explicit `onChange` callback prop
|
|
14
|
+
* (matching the React adapter). It also emits a `change` event for
|
|
15
|
+
* Vue authors who prefer event listeners. All three surfaces fire
|
|
16
|
+
* together so consumers may use whichever idiom suits them.
|
|
17
|
+
*
|
|
18
|
+
* @group Components
|
|
19
|
+
*/
|
|
20
|
+
import { computed, h, toRaw, type VNode } from "vue";
|
|
21
|
+
import { walk } from "../core/walker.ts";
|
|
22
|
+
import type { WalkOptions } from "../core/walkBuilders.ts";
|
|
23
|
+
import { normaliseSchema } from "../core/adapter.ts";
|
|
24
|
+
import type { SchemaMeta, WalkedField } from "../core/types.ts";
|
|
25
|
+
import type { Diagnostic, DiagnosticsOptions } from "../core/diagnostics.ts";
|
|
26
|
+
import { SchemaNormalisationError } from "../core/errors.ts";
|
|
27
|
+
import { toRecordOrUndefined } from "../core/guards.ts";
|
|
28
|
+
import { VueResolverContext, VueWidgetsContext } from "./contexts.ts";
|
|
29
|
+
import { vueRenderField } from "./renderField.ts";
|
|
30
|
+
import { deriveIdPrefix, joinPath } from "./idPrefix.ts";
|
|
31
|
+
import type {
|
|
32
|
+
VueComponentResolver,
|
|
33
|
+
VueRenderProps,
|
|
34
|
+
VueWidgetMap,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
import { VNodeHost } from "./VNodeHost.ts";
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<{
|
|
40
|
+
/** Zod schema, JSON Schema object, or OpenAPI document. */
|
|
41
|
+
schema: unknown;
|
|
42
|
+
/** For OpenAPI: a ref string like `#/components/schemas/User`. */
|
|
43
|
+
refPath?: string;
|
|
44
|
+
/** v-model binding for the current value. */
|
|
45
|
+
modelValue?: unknown;
|
|
46
|
+
/**
|
|
47
|
+
* Explicit `onChange` callback — wired in parallel with
|
|
48
|
+
* `update:modelValue` so consumers may use either surface.
|
|
49
|
+
*/
|
|
50
|
+
onChange?: (value: unknown) => void;
|
|
51
|
+
/** Convenience: sets `readOnly` on all fields. */
|
|
52
|
+
readOnly?: boolean;
|
|
53
|
+
/** Convenience: sets `writeOnly` on all fields. */
|
|
54
|
+
writeOnly?: boolean;
|
|
55
|
+
/** Convenience: sets `description` on the root. */
|
|
56
|
+
description?: string;
|
|
57
|
+
/** Meta overrides applied to the root schema. */
|
|
58
|
+
meta?: SchemaMeta;
|
|
59
|
+
/** Per-field meta overrides — nested object mirroring schema shape. */
|
|
60
|
+
fields?: Record<string, unknown>;
|
|
61
|
+
/** Theme resolver. Overrides the context resolver when supplied. */
|
|
62
|
+
resolver?: VueComponentResolver;
|
|
63
|
+
/** Instance-scoped widgets — override context and global widgets. */
|
|
64
|
+
widgets?: VueWidgetMap;
|
|
65
|
+
/** Deterministic id prefix. Defaults to a per-instance `useId()` value. */
|
|
66
|
+
idPrefix?: string;
|
|
67
|
+
/** Called with each diagnostic emitted during schema processing. */
|
|
68
|
+
onDiagnostic?: (diagnostic: Diagnostic) => void;
|
|
69
|
+
/** When `true`, any diagnostic becomes a thrown error. */
|
|
70
|
+
strict?: boolean;
|
|
71
|
+
/** Called when schema normalisation fails. */
|
|
72
|
+
onError?: (error: SchemaNormalisationError) => void;
|
|
73
|
+
}>(),
|
|
74
|
+
{
|
|
75
|
+
refPath: undefined,
|
|
76
|
+
modelValue: undefined,
|
|
77
|
+
onChange: undefined,
|
|
78
|
+
readOnly: false,
|
|
79
|
+
writeOnly: false,
|
|
80
|
+
description: undefined,
|
|
81
|
+
meta: () => ({}),
|
|
82
|
+
fields: undefined,
|
|
83
|
+
resolver: undefined,
|
|
84
|
+
widgets: undefined,
|
|
85
|
+
idPrefix: undefined,
|
|
86
|
+
onDiagnostic: undefined,
|
|
87
|
+
strict: false,
|
|
88
|
+
onError: undefined,
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const emit = defineEmits<{
|
|
93
|
+
"update:modelValue": [value: unknown];
|
|
94
|
+
change: [value: unknown];
|
|
95
|
+
}>();
|
|
96
|
+
|
|
97
|
+
// Consume the resolver and widget contexts. Both ports return
|
|
98
|
+
// `undefined` when no provider is mounted in scope — the dispatcher
|
|
99
|
+
// then falls through to the headless resolver.
|
|
100
|
+
const contextResolver = VueResolverContext.consume();
|
|
101
|
+
const contextWidgets = VueWidgetsContext.consume();
|
|
102
|
+
|
|
103
|
+
const rootPath = computed(() => deriveIdPrefix(props.idPrefix));
|
|
104
|
+
|
|
105
|
+
const mergedMeta = computed<SchemaMeta>(() => {
|
|
106
|
+
const merged: SchemaMeta = { ...props.meta };
|
|
107
|
+
if (props.readOnly) merged.readOnly = true;
|
|
108
|
+
if (props.writeOnly) merged.writeOnly = true;
|
|
109
|
+
if (props.description !== undefined) merged.description = props.description;
|
|
110
|
+
return merged;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const diagnostics = computed<DiagnosticsOptions | undefined>(() => {
|
|
114
|
+
if (props.onDiagnostic === undefined && !props.strict) return undefined;
|
|
115
|
+
const opts: DiagnosticsOptions = {};
|
|
116
|
+
if (props.onDiagnostic !== undefined) opts.diagnostics = props.onDiagnostic;
|
|
117
|
+
if (props.strict) opts.strict = true;
|
|
118
|
+
return opts;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
interface Normalised {
|
|
122
|
+
jsonSchema: Record<string, unknown>;
|
|
123
|
+
rootMeta: SchemaMeta | undefined;
|
|
124
|
+
rootDocument: Record<string, unknown>;
|
|
125
|
+
error?: SchemaNormalisationError;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const normalised = computed<Normalised>(() => {
|
|
129
|
+
try {
|
|
130
|
+
const opts =
|
|
131
|
+
diagnostics.value !== undefined
|
|
132
|
+
? { diagnostics: diagnostics.value }
|
|
133
|
+
: undefined;
|
|
134
|
+
// Vue wraps every reactive prop in a Proxy. Zod 4 schemas
|
|
135
|
+
// carry non-configurable internal data members (`_zod`) that
|
|
136
|
+
// the default reactivity proxy cannot mirror — accessing them
|
|
137
|
+
// through the proxy throws. `toRaw` recovers the original
|
|
138
|
+
// object before passing it into `normaliseSchema`, which does
|
|
139
|
+
// not need (and should not see) Vue's reactivity layer. The
|
|
140
|
+
// same fix is applied to `props.modelValue` further down for
|
|
141
|
+
// consistency with the React adapter, which receives raw
|
|
142
|
+
// values directly.
|
|
143
|
+
const rawSchema = toRaw(props.schema);
|
|
144
|
+
const result = normaliseSchema(rawSchema, props.refPath, opts);
|
|
145
|
+
return {
|
|
146
|
+
jsonSchema: result.jsonSchema,
|
|
147
|
+
rootMeta: result.rootMeta,
|
|
148
|
+
rootDocument: result.rootDocument,
|
|
149
|
+
};
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const error =
|
|
152
|
+
err instanceof SchemaNormalisationError
|
|
153
|
+
? err
|
|
154
|
+
: new SchemaNormalisationError(
|
|
155
|
+
err instanceof Error
|
|
156
|
+
? err.message
|
|
157
|
+
: "Failed to normalise schema",
|
|
158
|
+
toRaw(props.schema),
|
|
159
|
+
"unknown"
|
|
160
|
+
);
|
|
161
|
+
return {
|
|
162
|
+
jsonSchema: {},
|
|
163
|
+
rootMeta: undefined,
|
|
164
|
+
rootDocument: {},
|
|
165
|
+
error,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const tree = computed<WalkedField | undefined>(() => {
|
|
171
|
+
const n = normalised.value;
|
|
172
|
+
if (n.error !== undefined) return undefined;
|
|
173
|
+
const walkOptions: WalkOptions = {
|
|
174
|
+
componentMeta: mergedMeta.value,
|
|
175
|
+
rootDocument: n.rootDocument,
|
|
176
|
+
};
|
|
177
|
+
if (n.rootMeta !== undefined) walkOptions.rootMeta = n.rootMeta;
|
|
178
|
+
const fieldsRecord = toRecordOrUndefined(props.fields);
|
|
179
|
+
if (fieldsRecord !== undefined) walkOptions.fieldOverrides = fieldsRecord;
|
|
180
|
+
if (diagnostics.value !== undefined)
|
|
181
|
+
walkOptions.diagnostics = diagnostics.value;
|
|
182
|
+
return walk(n.jsonSchema, walkOptions);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const effectiveValue = computed<unknown>(() => {
|
|
186
|
+
if (props.modelValue !== undefined) return props.modelValue;
|
|
187
|
+
return tree.value?.defaultValue;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function handleChange(next: unknown): void {
|
|
191
|
+
emit("update:modelValue", next);
|
|
192
|
+
// Vue auto-wires any `onChange="…"` template attribute as a
|
|
193
|
+
// `change` event listener (the same `on<Event>` convention React
|
|
194
|
+
// uses for synthetic events). To avoid invoking the same handler
|
|
195
|
+
// twice — once via `emit("change", …)` and once via
|
|
196
|
+
// `props.onChange(…)` — we emit the event when no explicit prop
|
|
197
|
+
// handler was supplied, and call the prop directly otherwise.
|
|
198
|
+
// Both paths are observable to consumers but never overlap.
|
|
199
|
+
if (props.onChange !== undefined) {
|
|
200
|
+
props.onChange(next);
|
|
201
|
+
} else {
|
|
202
|
+
emit("change", next);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build the recursive `renderChild` closure. Each invocation increments
|
|
208
|
+
* the depth counter so the dispatcher's `MAX_RENDER_DEPTH` cap fires
|
|
209
|
+
* on truly recursive structures rather than on shallow trees.
|
|
210
|
+
*/
|
|
211
|
+
function makeRenderChild(
|
|
212
|
+
currentDepth: number,
|
|
213
|
+
parentPath: string
|
|
214
|
+
): VueRenderProps["renderChild"] {
|
|
215
|
+
return (
|
|
216
|
+
childTree: WalkedField,
|
|
217
|
+
childValue: unknown,
|
|
218
|
+
childOnChange: (v: unknown) => void,
|
|
219
|
+
pathSuffix?: string
|
|
220
|
+
) => {
|
|
221
|
+
const childPath = joinPath(parentPath, pathSuffix);
|
|
222
|
+
return vueRenderField(
|
|
223
|
+
childTree,
|
|
224
|
+
childValue,
|
|
225
|
+
childOnChange,
|
|
226
|
+
props.resolver ?? contextResolver,
|
|
227
|
+
makeRenderChild(currentDepth + 1, childPath),
|
|
228
|
+
childPath,
|
|
229
|
+
props.widgets,
|
|
230
|
+
contextWidgets,
|
|
231
|
+
currentDepth + 1
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Reactive root VNode. Recomputed whenever any reactive dependency
|
|
238
|
+
* (props, contexts, tree) changes. The template renders it via
|
|
239
|
+
* `<component :is="rootVNode">` — Vue accepts a VNode object as the
|
|
240
|
+
* target of `:is`, which lets us drive the render from a render
|
|
241
|
+
* function while keeping the SFC `<template>` block as the single
|
|
242
|
+
* mount point.
|
|
243
|
+
*/
|
|
244
|
+
const rootVNode = computed<VNode>(() => {
|
|
245
|
+
const t = tree.value;
|
|
246
|
+
if (t === undefined) {
|
|
247
|
+
const err = normalised.value.error;
|
|
248
|
+
if (err !== undefined) {
|
|
249
|
+
if (props.onError !== undefined) {
|
|
250
|
+
props.onError(err);
|
|
251
|
+
return h("span", { style: { display: "none" } });
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
return h("span", { style: { display: "none" } });
|
|
256
|
+
}
|
|
257
|
+
const renderChild = makeRenderChild(0, rootPath.value);
|
|
258
|
+
return vueRenderField(
|
|
259
|
+
t,
|
|
260
|
+
effectiveValue.value,
|
|
261
|
+
handleChange,
|
|
262
|
+
props.resolver ?? contextResolver,
|
|
263
|
+
renderChild,
|
|
264
|
+
rootPath.value,
|
|
265
|
+
props.widgets,
|
|
266
|
+
contextWidgets,
|
|
267
|
+
0
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
</script>
|
|
271
|
+
|
|
272
|
+
<template>
|
|
273
|
+
<VNodeHost :node="rootVNode" />
|
|
274
|
+
</template>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<SchemaErrorBoundary>` — Vue counterpart of the React
|
|
4
|
+
* `SchemaErrorBoundary`.
|
|
5
|
+
*
|
|
6
|
+
* Uses Vue 3's `onErrorCaptured` lifecycle hook
|
|
7
|
+
* (https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured)
|
|
8
|
+
* to catch render-time errors thrown by any descendant — including
|
|
9
|
+
* the dispatcher-wrapped `SchemaRenderError` from theme adapters that
|
|
10
|
+
* throw inside their render function. Returning `false` from
|
|
11
|
+
* `onErrorCaptured` halts further propagation up the component tree.
|
|
12
|
+
*
|
|
13
|
+
* The fallback slot is invoked with the captured error and a `reset`
|
|
14
|
+
* callback. Calling `reset()` clears the captured error so the
|
|
15
|
+
* children re-render (e.g. after fixing a bad schema prop).
|
|
16
|
+
*
|
|
17
|
+
* Like the React boundary, this captures render-time and lifecycle
|
|
18
|
+
* errors but NOT errors thrown from event handlers (Vue routes those
|
|
19
|
+
* through a separate `errorHandler` on the app instance) or async
|
|
20
|
+
* code that escapes the component tree.
|
|
21
|
+
*
|
|
22
|
+
* @group Components
|
|
23
|
+
*/
|
|
24
|
+
import { onErrorCaptured, ref } from "vue";
|
|
25
|
+
|
|
26
|
+
const captured = ref<Error | undefined>(undefined);
|
|
27
|
+
|
|
28
|
+
defineSlots<{
|
|
29
|
+
/**
|
|
30
|
+
* Default slot — rendered when no error has been captured.
|
|
31
|
+
*/
|
|
32
|
+
default(): unknown;
|
|
33
|
+
/**
|
|
34
|
+
* Fallback slot — invoked with the captured error and a `reset`
|
|
35
|
+
* callback. Use it to render an error UI; call `reset` to clear
|
|
36
|
+
* the captured state and let the children re-render.
|
|
37
|
+
*/
|
|
38
|
+
fallback(props: { error: Error; reset: () => void }): unknown;
|
|
39
|
+
}>();
|
|
40
|
+
|
|
41
|
+
onErrorCaptured((err) => {
|
|
42
|
+
captured.value =
|
|
43
|
+
err instanceof Error ? err : new Error("Unknown render error");
|
|
44
|
+
return false;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function reset(): void {
|
|
48
|
+
captured.value = undefined;
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<slot
|
|
54
|
+
v-if="captured !== undefined"
|
|
55
|
+
name="fallback"
|
|
56
|
+
:error="captured"
|
|
57
|
+
:reset="reset"
|
|
58
|
+
/>
|
|
59
|
+
<slot v-else />
|
|
60
|
+
</template>
|