schema-components 0.0.0 → 1.1.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/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +526 -0
- package/dist/core/adapter.d.mts +19 -0
- package/dist/core/adapter.mjs +140 -0
- package/dist/core/errors.d.mts +2 -0
- package/dist/core/errors.mjs +74 -0
- package/dist/core/guards.d.mts +44 -0
- package/dist/core/guards.mjs +58 -0
- package/dist/core/renderer.d.mts +2 -0
- package/dist/core/renderer.mjs +71 -0
- package/dist/core/types.d.mts +3 -0
- package/dist/core/types.mjs +40 -0
- package/dist/core/walker.d.mts +14 -0
- package/dist/core/walker.mjs +366 -0
- package/dist/errors-DIKI2C78.d.mts +57 -0
- package/dist/html/a11y.d.mts +47 -0
- package/dist/html/a11y.mjs +81 -0
- package/dist/html/html.d.mts +135 -0
- package/dist/html/html.mjs +168 -0
- package/dist/html/renderToHtml.d.mts +32 -0
- package/dist/html/renderToHtml.mjs +352 -0
- package/dist/html/renderToHtmlStream.d.mts +58 -0
- package/dist/html/renderToHtmlStream.mjs +285 -0
- package/dist/html/styles.css +151 -0
- package/dist/openapi/components.d.mts +76 -0
- package/dist/openapi/components.mjs +223 -0
- package/dist/openapi/parser.d.mts +45 -0
- package/dist/openapi/parser.mjs +159 -0
- package/dist/react/SchemaComponent.d.mts +96 -0
- package/dist/react/SchemaComponent.mjs +283 -0
- package/dist/react/SchemaErrorBoundary.d.mts +26 -0
- package/dist/react/SchemaErrorBoundary.mjs +47 -0
- package/dist/react/headless.d.mts +13 -0
- package/dist/react/headless.mjs +163 -0
- package/dist/themes/shadcn.d.mts +6 -0
- package/dist/themes/shadcn.mjs +166 -0
- package/dist/types-BU0ETFHk.d.mts +326 -0
- package/package.json +113 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
//#region src/html/html.ts
|
|
2
|
+
const VOID_ELEMENTS = new Set([
|
|
3
|
+
"area",
|
|
4
|
+
"base",
|
|
5
|
+
"br",
|
|
6
|
+
"col",
|
|
7
|
+
"embed",
|
|
8
|
+
"hr",
|
|
9
|
+
"img",
|
|
10
|
+
"input",
|
|
11
|
+
"link",
|
|
12
|
+
"meta",
|
|
13
|
+
"param",
|
|
14
|
+
"source",
|
|
15
|
+
"track",
|
|
16
|
+
"wbr"
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* Build an HTML element node.
|
|
20
|
+
*
|
|
21
|
+
* - Tag name is type-checked (must be a known HTML tag)
|
|
22
|
+
* - Attributes are collected as a record — callers get IntelliSense for
|
|
23
|
+
* common attributes but can also pass `aria-*`, `data-*` etc.
|
|
24
|
+
* - Children are flattened; `undefined`, `null`, and `false` are dropped.
|
|
25
|
+
* - For void elements (input, img, etc.), children are ignored.
|
|
26
|
+
*
|
|
27
|
+
* @param tag - HTML element tag name
|
|
28
|
+
* @param attrs - Optional attributes (class, id, aria-*, etc.)
|
|
29
|
+
* @param children - Child nodes (strings are escaped by the serialiser)
|
|
30
|
+
*/
|
|
31
|
+
function h(tag, attrs, ...children) {
|
|
32
|
+
return {
|
|
33
|
+
tag,
|
|
34
|
+
attributes: attrs ?? {},
|
|
35
|
+
children: flattenChildren(children)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create a text node. The value is NOT escaped — the serialiser handles it.
|
|
40
|
+
* Use this for dynamic text that must appear in the output.
|
|
41
|
+
*/
|
|
42
|
+
function text(value) {
|
|
43
|
+
return { text: value };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a raw HTML node. The value is emitted verbatim — NOT escaped.
|
|
47
|
+
* Use for embedding already-serialised HTML (e.g. from child renderers).
|
|
48
|
+
* Never use for user-supplied data.
|
|
49
|
+
*/
|
|
50
|
+
function raw(html) {
|
|
51
|
+
return { html };
|
|
52
|
+
}
|
|
53
|
+
function flattenChildren(nodes) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const node of nodes) {
|
|
56
|
+
if (node === void 0 || node === null || node === false) continue;
|
|
57
|
+
if (typeof node === "string") out.push(node);
|
|
58
|
+
else if ("tag" in node) out.push(node);
|
|
59
|
+
else if ("html" in node) out.push(node);
|
|
60
|
+
else if ("text" in node) out.push(node);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Serialise an HTML node to a string.
|
|
66
|
+
*
|
|
67
|
+
* - Text content is automatically escaped
|
|
68
|
+
* - Void elements are self-closing
|
|
69
|
+
* - Boolean attributes render as just the name (`disabled`, `checked`)
|
|
70
|
+
* - `false`/`undefined` attribute values are omitted
|
|
71
|
+
*
|
|
72
|
+
* @param node - An HtmlElement, HtmlText, or string to serialise
|
|
73
|
+
* @returns HTML string
|
|
74
|
+
*/
|
|
75
|
+
function serialize(node) {
|
|
76
|
+
if (node === void 0 || node === null || node === false) return "";
|
|
77
|
+
if (typeof node === "string") return escapeHtml(node);
|
|
78
|
+
if ("html" in node) return node.html;
|
|
79
|
+
if ("text" in node) return escapeHtml(node.text);
|
|
80
|
+
if (node.tag === "") return node.children.map((child) => serialize(child)).join("");
|
|
81
|
+
return serializeElement(node);
|
|
82
|
+
}
|
|
83
|
+
function serializeElement(el) {
|
|
84
|
+
const attrs = serializeAttributes(el.attributes);
|
|
85
|
+
if (VOID_ELEMENTS.has(el.tag)) return `<${el.tag}${attrs}>`;
|
|
86
|
+
if (el.children.length === 0) return `<${el.tag}${attrs}></${el.tag}>`;
|
|
87
|
+
const inner = el.children.map((child) => serialize(child)).join("");
|
|
88
|
+
return `<${el.tag}${attrs}>${inner}</${el.tag}>`;
|
|
89
|
+
}
|
|
90
|
+
function serializeAttributes(attrs) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
93
|
+
if (value === void 0 || value === false) continue;
|
|
94
|
+
if (value === true) parts.push(` ${key}`);
|
|
95
|
+
else parts.push(` ${key}="${escapeHtml(String(value))}"`);
|
|
96
|
+
}
|
|
97
|
+
return parts.join("");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Serialise an HTML node to chunks, yielded at natural element boundaries.
|
|
101
|
+
*
|
|
102
|
+
* - Each top-level child element becomes its own chunk
|
|
103
|
+
* - Leaf text within an element stays with its parent
|
|
104
|
+
* - Void elements are single chunks
|
|
105
|
+
*
|
|
106
|
+
* This is used by the streaming renderer to produce incremental output.
|
|
107
|
+
*
|
|
108
|
+
* @param node - An HTML node to serialise
|
|
109
|
+
* @returns Iterable of HTML string chunks
|
|
110
|
+
*/
|
|
111
|
+
function* serializeChunks(node) {
|
|
112
|
+
if (node === void 0 || node === null || node === false) return;
|
|
113
|
+
if (typeof node === "string") {
|
|
114
|
+
yield escapeHtml(node);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if ("html" in node) {
|
|
118
|
+
yield node.html;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if ("text" in node) {
|
|
122
|
+
yield escapeHtml(node.text);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const attrs = serializeAttributes(node.attributes);
|
|
126
|
+
const tag = node.tag;
|
|
127
|
+
const open = `<${tag}${attrs}>`;
|
|
128
|
+
if (VOID_ELEMENTS.has(tag)) {
|
|
129
|
+
yield open;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (node.children.length === 0) {
|
|
133
|
+
yield `${open}</${tag}>`;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
yield open;
|
|
137
|
+
for (const child of node.children) if (typeof child === "string") yield escapeHtml(child);
|
|
138
|
+
else if ("html" in child) yield child.html;
|
|
139
|
+
else if ("text" in child) yield escapeHtml(child.text);
|
|
140
|
+
else yield* serializeChunks(child);
|
|
141
|
+
yield `</${tag}>`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Escape a string for safe inclusion in HTML text content or attribute values.
|
|
145
|
+
*/
|
|
146
|
+
function escapeHtml(str) {
|
|
147
|
+
return str.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Create a fragment: children rendered sequentially with no wrapping element.
|
|
151
|
+
* Useful when a renderer needs to return multiple top-level nodes.
|
|
152
|
+
*/
|
|
153
|
+
function fragment(...children) {
|
|
154
|
+
return h("", void 0, ...children);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Serialise a node, treating fragments (empty tag) as just their children.
|
|
158
|
+
*/
|
|
159
|
+
function serializeFragment(node) {
|
|
160
|
+
if (node === void 0 || node === null || node === false) return "";
|
|
161
|
+
if (typeof node === "string") return escapeHtml(node);
|
|
162
|
+
if ("html" in node) return node.html;
|
|
163
|
+
if ("text" in node) return escapeHtml(node.text);
|
|
164
|
+
if (node.tag === "") return node.children.map((child) => serialize(child)).join("");
|
|
165
|
+
return serializeElement(node);
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
export { VOID_ELEMENTS, escapeHtml, fragment, h, raw, serialize, serializeAttributes, serializeChunks, serializeElement, serializeFragment, text };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { C as HtmlResolver, S as HtmlRenderProps, m as SchemaMeta, x as HtmlRenderFunction } from "../types-BU0ETFHk.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/html/renderToHtml.d.ts
|
|
4
|
+
interface RenderToHtmlOptions {
|
|
5
|
+
/** The data value to render. */
|
|
6
|
+
value?: unknown;
|
|
7
|
+
/** For OpenAPI: a ref string like "#/components/schemas/User". */
|
|
8
|
+
ref?: string;
|
|
9
|
+
/** Per-field meta overrides. */
|
|
10
|
+
fields?: Record<string, unknown>;
|
|
11
|
+
/** Meta overrides applied to the root schema. */
|
|
12
|
+
meta?: SchemaMeta;
|
|
13
|
+
/** Force all fields read-only. */
|
|
14
|
+
readOnly?: boolean;
|
|
15
|
+
/** Force all fields as inputs. */
|
|
16
|
+
writeOnly?: boolean;
|
|
17
|
+
/** Root description. */
|
|
18
|
+
description?: string;
|
|
19
|
+
/** Custom HTML resolver. Falls back to defaultHtmlResolver. */
|
|
20
|
+
resolver?: HtmlResolver;
|
|
21
|
+
}
|
|
22
|
+
declare const defaultHtmlResolver: HtmlResolver;
|
|
23
|
+
/**
|
|
24
|
+
* Render a schema to an HTML string.
|
|
25
|
+
*
|
|
26
|
+
* @param schema - Zod schema, JSON Schema, or OpenAPI document
|
|
27
|
+
* @param options - Value, overrides, and resolver options
|
|
28
|
+
* @returns Semantic HTML string with `sc-` prefixed classes
|
|
29
|
+
*/
|
|
30
|
+
declare function renderToHtml(schema: unknown, options?: RenderToHtmlOptions): string;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { type HtmlRenderFunction, type HtmlRenderProps, type HtmlResolver, RenderToHtmlOptions, defaultHtmlResolver, renderToHtml };
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { normaliseSchema } from "../core/adapter.mjs";
|
|
2
|
+
import { getHtmlRenderFn, mergeHtmlResolvers } from "../core/renderer.mjs";
|
|
3
|
+
import { walk } from "../core/walker.mjs";
|
|
4
|
+
import { h, raw, serialize } from "./html.mjs";
|
|
5
|
+
import { ariaDescribedByAttrs, ariaLabelAttrs, ariaReadonlyAttrs, ariaRequiredAttrs, buildHintElement, buildInputId, requiredIndicator } from "./a11y.mjs";
|
|
6
|
+
//#region src/html/renderToHtml.ts
|
|
7
|
+
/**
|
|
8
|
+
* HTML renderer — produces semantic HTML from schemas using the typed `h()` builder.
|
|
9
|
+
*
|
|
10
|
+
* Framework-agnostic alternative to the React rendering pipeline.
|
|
11
|
+
* Uses the same walker and adapter (normalise → walk → render) but
|
|
12
|
+
* outputs HTML strings instead of ReactNode.
|
|
13
|
+
*
|
|
14
|
+
* All HTML construction goes through `h()` from `html.ts`, which gives
|
|
15
|
+
* compile-time tag/attribute checking and automatic escaping.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* import { renderToHtml } from "schema-components/html/renderToHtml";
|
|
19
|
+
* const html = renderToHtml(userSchema, { value: userData });
|
|
20
|
+
*
|
|
21
|
+
* Custom resolver:
|
|
22
|
+
* const html = renderToHtml(schema, {
|
|
23
|
+
* value,
|
|
24
|
+
* resolver: { string: (props) => h("b", {}, String(props.value)) },
|
|
25
|
+
* });
|
|
26
|
+
*/
|
|
27
|
+
function renderStringHtml(props) {
|
|
28
|
+
if (props.readOnly) return serialize(renderStringReadOnly(props));
|
|
29
|
+
return serialize(renderStringEditable(props));
|
|
30
|
+
}
|
|
31
|
+
function renderStringReadOnly(props) {
|
|
32
|
+
const strValue = typeof props.value === "string" ? props.value : void 0;
|
|
33
|
+
if (strValue === void 0 || strValue.length === 0) return h("span", {
|
|
34
|
+
class: "sc-value sc-value--empty",
|
|
35
|
+
...ariaReadonlyAttrs()
|
|
36
|
+
}, "—");
|
|
37
|
+
const format = props.constraints.format;
|
|
38
|
+
if (format === "email") return h("a", {
|
|
39
|
+
class: "sc-value",
|
|
40
|
+
href: `mailto:${strValue}`,
|
|
41
|
+
...ariaReadonlyAttrs()
|
|
42
|
+
}, strValue);
|
|
43
|
+
if (format === "uri" || format === "url") return h("a", {
|
|
44
|
+
class: "sc-value",
|
|
45
|
+
href: strValue,
|
|
46
|
+
...ariaReadonlyAttrs()
|
|
47
|
+
}, strValue);
|
|
48
|
+
return h("span", {
|
|
49
|
+
class: "sc-value",
|
|
50
|
+
...ariaReadonlyAttrs()
|
|
51
|
+
}, strValue);
|
|
52
|
+
}
|
|
53
|
+
function renderStringEditable(props) {
|
|
54
|
+
const strValue = typeof props.value === "string" ? props.value : "";
|
|
55
|
+
const inputType = props.constraints.format === "email" ? "email" : props.constraints.format === "uri" ? "url" : "text";
|
|
56
|
+
const id = props.path;
|
|
57
|
+
const attrs = {
|
|
58
|
+
class: "sc-input",
|
|
59
|
+
id,
|
|
60
|
+
type: inputType,
|
|
61
|
+
name: id
|
|
62
|
+
};
|
|
63
|
+
if (!props.writeOnly) attrs.value = strValue;
|
|
64
|
+
if (typeof props.meta.description === "string") attrs.placeholder = props.meta.description;
|
|
65
|
+
if (props.constraints.minLength !== void 0) attrs.minlength = String(props.constraints.minLength);
|
|
66
|
+
if (props.constraints.maxLength !== void 0) attrs.maxlength = String(props.constraints.maxLength);
|
|
67
|
+
Object.assign(attrs, ariaRequiredAttrs(props.tree));
|
|
68
|
+
Object.assign(attrs, ariaDescribedByAttrs(id, props.constraints));
|
|
69
|
+
return h("input", attrs);
|
|
70
|
+
}
|
|
71
|
+
function renderNumberHtml(props) {
|
|
72
|
+
if (props.readOnly) return serialize(renderNumberReadOnly(props));
|
|
73
|
+
return serialize(renderNumberEditable(props));
|
|
74
|
+
}
|
|
75
|
+
function renderNumberReadOnly(props) {
|
|
76
|
+
if (typeof props.value !== "number") return h("span", {
|
|
77
|
+
class: "sc-value sc-value--empty",
|
|
78
|
+
...ariaReadonlyAttrs()
|
|
79
|
+
}, "—");
|
|
80
|
+
return h("span", {
|
|
81
|
+
class: "sc-value",
|
|
82
|
+
...ariaReadonlyAttrs()
|
|
83
|
+
}, props.value.toLocaleString());
|
|
84
|
+
}
|
|
85
|
+
function renderNumberEditable(props) {
|
|
86
|
+
const numValue = typeof props.value === "number" ? String(props.value) : "";
|
|
87
|
+
const id = props.path;
|
|
88
|
+
const attrs = {
|
|
89
|
+
class: "sc-input",
|
|
90
|
+
id,
|
|
91
|
+
type: "number",
|
|
92
|
+
name: id
|
|
93
|
+
};
|
|
94
|
+
if (!props.writeOnly) attrs.value = numValue;
|
|
95
|
+
if (props.constraints.minimum !== void 0) attrs.min = String(props.constraints.minimum);
|
|
96
|
+
if (props.constraints.maximum !== void 0) attrs.max = String(props.constraints.maximum);
|
|
97
|
+
Object.assign(attrs, ariaRequiredAttrs(props.tree));
|
|
98
|
+
Object.assign(attrs, ariaDescribedByAttrs(id, props.constraints));
|
|
99
|
+
return h("input", attrs);
|
|
100
|
+
}
|
|
101
|
+
function renderBooleanHtml(props) {
|
|
102
|
+
if (props.readOnly) return serialize(renderBooleanReadOnly(props));
|
|
103
|
+
return serialize(renderBooleanEditable(props));
|
|
104
|
+
}
|
|
105
|
+
function renderBooleanReadOnly(props) {
|
|
106
|
+
if (typeof props.value !== "boolean") return h("span", {
|
|
107
|
+
class: "sc-value sc-value--empty",
|
|
108
|
+
...ariaReadonlyAttrs()
|
|
109
|
+
}, "—");
|
|
110
|
+
return h("span", {
|
|
111
|
+
class: "sc-value sc-value--boolean",
|
|
112
|
+
...ariaReadonlyAttrs()
|
|
113
|
+
}, props.value ? "Yes" : "No");
|
|
114
|
+
}
|
|
115
|
+
function renderBooleanEditable(props) {
|
|
116
|
+
const id = props.path;
|
|
117
|
+
const attrs = {
|
|
118
|
+
class: "sc-input",
|
|
119
|
+
id,
|
|
120
|
+
type: "checkbox",
|
|
121
|
+
name: id
|
|
122
|
+
};
|
|
123
|
+
if (props.value === true) attrs.checked = true;
|
|
124
|
+
Object.assign(attrs, ariaRequiredAttrs(props.tree));
|
|
125
|
+
Object.assign(attrs, ariaLabelAttrs(props.meta.description));
|
|
126
|
+
return h("input", attrs);
|
|
127
|
+
}
|
|
128
|
+
function renderEnumHtml(props) {
|
|
129
|
+
if (props.readOnly) return serialize(renderEnumReadOnly(props));
|
|
130
|
+
return serialize(renderEnumEditable(props));
|
|
131
|
+
}
|
|
132
|
+
function renderEnumReadOnly(props) {
|
|
133
|
+
const enumValue = typeof props.value === "string" ? props.value : "";
|
|
134
|
+
if (enumValue.length === 0) return h("span", {
|
|
135
|
+
class: "sc-value sc-value--empty",
|
|
136
|
+
...ariaReadonlyAttrs()
|
|
137
|
+
}, "—");
|
|
138
|
+
return h("span", {
|
|
139
|
+
class: "sc-value",
|
|
140
|
+
...ariaReadonlyAttrs()
|
|
141
|
+
}, enumValue);
|
|
142
|
+
}
|
|
143
|
+
function renderEnumEditable(props) {
|
|
144
|
+
const enumValue = typeof props.value === "string" ? props.value : "";
|
|
145
|
+
const id = props.path;
|
|
146
|
+
const selectedValue = props.writeOnly ? "" : enumValue;
|
|
147
|
+
const optionNodes = [h("option", { value: "" }, "Select…"), ...(props.enumValues ?? []).map((v) => {
|
|
148
|
+
const attrs = { value: v };
|
|
149
|
+
if (v === selectedValue) attrs.selected = true;
|
|
150
|
+
return h("option", attrs, v);
|
|
151
|
+
})];
|
|
152
|
+
const selectAttrs = {
|
|
153
|
+
class: "sc-input",
|
|
154
|
+
id,
|
|
155
|
+
name: id
|
|
156
|
+
};
|
|
157
|
+
Object.assign(selectAttrs, ariaRequiredAttrs(props.tree));
|
|
158
|
+
return h("select", selectAttrs, ...optionNodes);
|
|
159
|
+
}
|
|
160
|
+
function renderObjectHtml(props) {
|
|
161
|
+
return serialize(renderObjectNode(props));
|
|
162
|
+
}
|
|
163
|
+
function renderObjectNode(props) {
|
|
164
|
+
const fields = props.fields;
|
|
165
|
+
if (fields === void 0) return "";
|
|
166
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
167
|
+
const obj = isRecord(props.value) ? props.value : {};
|
|
168
|
+
const descriptionText = typeof props.meta.description === "string" ? props.meta.description : void 0;
|
|
169
|
+
const legend = descriptionText !== void 0 ? h("legend", {}, descriptionText) : void 0;
|
|
170
|
+
if (props.readOnly) {
|
|
171
|
+
const children = [];
|
|
172
|
+
if (legend !== void 0) children.push(legend);
|
|
173
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
174
|
+
const label = typeof field.meta.description === "string" ? field.meta.description : key;
|
|
175
|
+
const childValue = obj[key];
|
|
176
|
+
const childHtml = props.renderChild(field, childValue, key);
|
|
177
|
+
children.push(h("dt", { class: "sc-label" }, label));
|
|
178
|
+
children.push(h("dd", { class: "sc-value" }, raw(childHtml)));
|
|
179
|
+
}
|
|
180
|
+
const dlAttrs = { class: "sc-object" };
|
|
181
|
+
Object.assign(dlAttrs, ariaLabelAttrs(descriptionText));
|
|
182
|
+
return h("dl", dlAttrs, ...children);
|
|
183
|
+
}
|
|
184
|
+
const children = [];
|
|
185
|
+
if (legend !== void 0) children.push(legend);
|
|
186
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
187
|
+
const label = typeof field.meta.description === "string" ? field.meta.description : key;
|
|
188
|
+
const fieldId = buildInputId(props.path, key);
|
|
189
|
+
const childValue = obj[key];
|
|
190
|
+
const childHtml = props.renderChild(field, childValue, key);
|
|
191
|
+
const required = requiredIndicator(field);
|
|
192
|
+
const labelContent = [label];
|
|
193
|
+
if (required !== void 0) labelContent.push(required);
|
|
194
|
+
const fieldChildren = [h("label", {
|
|
195
|
+
class: "sc-label",
|
|
196
|
+
for: fieldId
|
|
197
|
+
}, ...labelContent), raw(childHtml)];
|
|
198
|
+
const hint = buildHintElement(key, field.constraints);
|
|
199
|
+
if (hint !== void 0) fieldChildren.push(hint);
|
|
200
|
+
children.push(h("div", { class: "sc-field" }, ...fieldChildren));
|
|
201
|
+
}
|
|
202
|
+
const fieldsetAttrs = { class: "sc-object" };
|
|
203
|
+
Object.assign(fieldsetAttrs, ariaLabelAttrs(descriptionText));
|
|
204
|
+
return h("fieldset", fieldsetAttrs, ...children);
|
|
205
|
+
}
|
|
206
|
+
function renderArrayHtml(props) {
|
|
207
|
+
return serialize(renderArrayNode(props));
|
|
208
|
+
}
|
|
209
|
+
function renderArrayNode(props) {
|
|
210
|
+
const arr = Array.isArray(props.value) ? props.value : [];
|
|
211
|
+
const element = props.element;
|
|
212
|
+
if (element === void 0) return "";
|
|
213
|
+
const items = arr.map((item) => {
|
|
214
|
+
return h("li", { class: "sc-item" }, raw(props.renderChild(element, item)));
|
|
215
|
+
});
|
|
216
|
+
if (props.readOnly) return h("ul", { class: "sc-array" }, ...items);
|
|
217
|
+
return h("div", { class: "sc-array" }, ...arr.map((item) => {
|
|
218
|
+
return h("div", {}, raw(props.renderChild(element, item)));
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
function renderRecordHtml(props) {
|
|
222
|
+
return serialize(renderRecordNode(props));
|
|
223
|
+
}
|
|
224
|
+
function renderRecordNode(props) {
|
|
225
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
226
|
+
const obj = isRecord(props.value) ? props.value : {};
|
|
227
|
+
const valueType = props.valueType;
|
|
228
|
+
if (valueType === void 0) return "";
|
|
229
|
+
const attrs = {
|
|
230
|
+
class: "sc-record",
|
|
231
|
+
role: "group"
|
|
232
|
+
};
|
|
233
|
+
if (props.readOnly) {
|
|
234
|
+
const children = [];
|
|
235
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
236
|
+
const childHtml = props.renderChild(valueType, val, key);
|
|
237
|
+
children.push(h("dt", { class: "sc-label" }, key));
|
|
238
|
+
children.push(h("dd", { class: "sc-value" }, raw(childHtml)));
|
|
239
|
+
}
|
|
240
|
+
return h("dl", attrs, ...children);
|
|
241
|
+
}
|
|
242
|
+
const children = [];
|
|
243
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
244
|
+
const childHtml = props.renderChild(valueType, val, key);
|
|
245
|
+
children.push(h("div", { class: "sc-field" }, h("label", { class: "sc-label" }, key), raw(childHtml)));
|
|
246
|
+
}
|
|
247
|
+
return h("div", attrs, ...children);
|
|
248
|
+
}
|
|
249
|
+
function renderLiteralHtml(props) {
|
|
250
|
+
const values = props.tree.literalValues;
|
|
251
|
+
if (values === void 0 || values.length === 0) return serialize(h("span", { class: "sc-value sc-value--empty" }, "—"));
|
|
252
|
+
return serialize(h("span", { class: "sc-value" }, values.map((v) => v === null ? "null" : String(v)).join(", ")));
|
|
253
|
+
}
|
|
254
|
+
function renderUnionHtml(props) {
|
|
255
|
+
const options = props.options;
|
|
256
|
+
if (options === void 0 || options.length === 0) {
|
|
257
|
+
if (props.value === void 0 || props.value === null) return serialize(h("span", { class: "sc-value sc-value--empty" }, "—"));
|
|
258
|
+
return serialize(h("span", { class: "sc-value" }, JSON.stringify(props.value)));
|
|
259
|
+
}
|
|
260
|
+
const matched = matchUnionOption(options, props.value);
|
|
261
|
+
if (matched !== void 0) return props.renderChild(matched, props.value);
|
|
262
|
+
const firstOption = options[0];
|
|
263
|
+
if (firstOption !== void 0) return props.renderChild(firstOption, props.value);
|
|
264
|
+
return serialize(h("span", { class: "sc-value sc-value--empty" }, "—"));
|
|
265
|
+
}
|
|
266
|
+
function renderUnknownHtml(props) {
|
|
267
|
+
if (props.readOnly) {
|
|
268
|
+
if (props.value === void 0 || props.value === null) return serialize(h("span", { class: "sc-value sc-value--empty" }, "—"));
|
|
269
|
+
if (typeof props.value === "string") return serialize(h("span", { class: "sc-value" }, props.value));
|
|
270
|
+
return serialize(h("span", { class: "sc-value" }, JSON.stringify(props.value)));
|
|
271
|
+
}
|
|
272
|
+
const strValue = typeof props.value === "string" ? props.value : "";
|
|
273
|
+
const attrs = {
|
|
274
|
+
class: "sc-input",
|
|
275
|
+
type: "text",
|
|
276
|
+
name: props.path
|
|
277
|
+
};
|
|
278
|
+
if (!props.writeOnly) attrs.value = strValue;
|
|
279
|
+
return serialize(h("input", attrs));
|
|
280
|
+
}
|
|
281
|
+
function matchUnionOption(options, value) {
|
|
282
|
+
if (typeof value === "string") return options.find((o) => o.type === "string" || o.type === "enum");
|
|
283
|
+
if (typeof value === "number") return options.find((o) => o.type === "number");
|
|
284
|
+
if (typeof value === "boolean") return options.find((o) => o.type === "boolean");
|
|
285
|
+
if (Array.isArray(value)) return options.find((o) => o.type === "array");
|
|
286
|
+
if (typeof value === "object" && value !== null) return options.find((o) => o.type === "object");
|
|
287
|
+
}
|
|
288
|
+
const defaultHtmlResolver = {
|
|
289
|
+
string: renderStringHtml,
|
|
290
|
+
number: renderNumberHtml,
|
|
291
|
+
boolean: renderBooleanHtml,
|
|
292
|
+
enum: renderEnumHtml,
|
|
293
|
+
object: renderObjectHtml,
|
|
294
|
+
array: renderArrayHtml,
|
|
295
|
+
record: renderRecordHtml,
|
|
296
|
+
literal: renderLiteralHtml,
|
|
297
|
+
union: renderUnionHtml,
|
|
298
|
+
unknown: renderUnknownHtml
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Render a schema to an HTML string.
|
|
302
|
+
*
|
|
303
|
+
* @param schema - Zod schema, JSON Schema, or OpenAPI document
|
|
304
|
+
* @param options - Value, overrides, and resolver options
|
|
305
|
+
* @returns Semantic HTML string with `sc-` prefixed classes
|
|
306
|
+
*/
|
|
307
|
+
function renderToHtml(schema, options = {}) {
|
|
308
|
+
const mergedMeta = { ...options.meta };
|
|
309
|
+
if (options.readOnly === true) mergedMeta.readOnly = true;
|
|
310
|
+
if (options.writeOnly === true) mergedMeta.writeOnly = true;
|
|
311
|
+
if (options.description !== void 0) mergedMeta.description = options.description;
|
|
312
|
+
const { jsonSchema, rootMeta, rootDocument } = normaliseSchema(schema, options.ref);
|
|
313
|
+
const tree = walk(jsonSchema, {
|
|
314
|
+
componentMeta: mergedMeta,
|
|
315
|
+
rootMeta,
|
|
316
|
+
fieldOverrides: options.fields,
|
|
317
|
+
rootDocument
|
|
318
|
+
});
|
|
319
|
+
const resolver = options.resolver ?? defaultHtmlResolver;
|
|
320
|
+
const renderChild = (childTree, childValue, pathSuffix) => {
|
|
321
|
+
return renderFieldHtml(childTree, childValue, resolver, pathSuffix ?? childTree.meta.description ?? "", renderChild);
|
|
322
|
+
};
|
|
323
|
+
return renderFieldHtml(tree, options.value, resolver, "", renderChild);
|
|
324
|
+
}
|
|
325
|
+
function renderFieldHtml(tree, value, resolver, path, renderChild) {
|
|
326
|
+
const mergedResolver = mergeHtmlResolvers(resolver, defaultHtmlResolver);
|
|
327
|
+
const renderFn = getHtmlRenderFn(tree.type, mergedResolver);
|
|
328
|
+
if (renderFn !== void 0) {
|
|
329
|
+
const props = {
|
|
330
|
+
value,
|
|
331
|
+
readOnly: tree.editability === "presentation",
|
|
332
|
+
writeOnly: tree.editability === "input",
|
|
333
|
+
meta: tree.meta,
|
|
334
|
+
constraints: tree.constraints,
|
|
335
|
+
path,
|
|
336
|
+
tree,
|
|
337
|
+
renderChild
|
|
338
|
+
};
|
|
339
|
+
if (tree.enumValues !== void 0) props.enumValues = tree.enumValues;
|
|
340
|
+
if (tree.element !== void 0) props.element = tree.element;
|
|
341
|
+
if (tree.fields !== void 0) props.fields = tree.fields;
|
|
342
|
+
if (tree.options !== void 0) props.options = tree.options;
|
|
343
|
+
if (tree.discriminator !== void 0) props.discriminator = tree.discriminator;
|
|
344
|
+
if (tree.keyType !== void 0) props.keyType = tree.keyType;
|
|
345
|
+
if (tree.valueType !== void 0) props.valueType = tree.valueType;
|
|
346
|
+
return renderFn(props);
|
|
347
|
+
}
|
|
348
|
+
if (value === void 0 || value === null) return serialize(h("span", { class: "sc-value sc-value--empty" }, "—"));
|
|
349
|
+
return serialize(h("span", { class: "sc-value" }, typeof value === "string" ? value : JSON.stringify(value)));
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
export { defaultHtmlResolver, renderToHtml };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { RenderToHtmlOptions } from "./renderToHtml.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/html/renderToHtmlStream.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Streaming HTML renderer — yields HTML chunks incrementally.
|
|
6
|
+
*
|
|
7
|
+
* Same rendering pipeline as `renderToHtml` but yields string fragments
|
|
8
|
+
* as each field/element is produced instead of building the entire string
|
|
9
|
+
* in memory. Use for server-side rendering where you want to start
|
|
10
|
+
* flushing the response before the full schema is rendered.
|
|
11
|
+
*
|
|
12
|
+
* Three output formats:
|
|
13
|
+
*
|
|
14
|
+
* - `renderToHtmlChunks(schema, options)` → sync `Iterable<string>`
|
|
15
|
+
* - `renderToHtmlStream(schema, options)` → async `AsyncIterable<string>`
|
|
16
|
+
* - `renderToHtmlReadable(schema, options)` → web `ReadableStream<string>`
|
|
17
|
+
*
|
|
18
|
+
* Chunk boundaries:
|
|
19
|
+
* - Object: opening tag, one chunk per field, closing tag
|
|
20
|
+
* - Array: opening tag, one chunk per item, closing tag
|
|
21
|
+
* - Record: opening tag, one chunk per entry, closing tag
|
|
22
|
+
* - Leaf types (string, number, boolean, enum, literal, unknown):
|
|
23
|
+
* rendered entirely as one chunk
|
|
24
|
+
*
|
|
25
|
+
* All HTML construction uses `h()` from `html.ts` — the streaming module
|
|
26
|
+
* manually yields the opening tag, then children, then the closing tag.
|
|
27
|
+
*/
|
|
28
|
+
type StreamRenderOptions = RenderToHtmlOptions;
|
|
29
|
+
/**
|
|
30
|
+
* Render a schema to HTML string chunks, yielded incrementally.
|
|
31
|
+
*
|
|
32
|
+
* Each yielded chunk is a self-contained HTML fragment. Concatenating
|
|
33
|
+
* all chunks produces the same output as `renderToHtml`.
|
|
34
|
+
*
|
|
35
|
+
* @returns Sync iterable of HTML string chunks
|
|
36
|
+
*/
|
|
37
|
+
declare function renderToHtmlChunks(schema: unknown, options?: StreamRenderOptions): Iterable<string, void, undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Render a schema to HTML string chunks asynchronously.
|
|
40
|
+
*
|
|
41
|
+
* Identical chunk boundaries to `renderToHtmlChunks` but yields via
|
|
42
|
+
* an async generator. Use with `for await...of` or pipe to a response.
|
|
43
|
+
*
|
|
44
|
+
* @returns Async iterable of HTML string chunks
|
|
45
|
+
*/
|
|
46
|
+
declare function renderToHtmlStream(schema: unknown, options?: StreamRenderOptions): AsyncIterable<string, void, undefined>;
|
|
47
|
+
/**
|
|
48
|
+
* Render a schema to a web `ReadableStream<string>`.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* return new Response(renderToHtmlReadable(schema, { value }), {
|
|
52
|
+
* headers: { "Content-Type": "text/html" },
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
declare function renderToHtmlReadable(schema: unknown, options?: StreamRenderOptions): ReadableStream<string>;
|
|
57
|
+
//#endregion
|
|
58
|
+
export { StreamRenderOptions, renderToHtmlChunks, renderToHtmlReadable, renderToHtmlStream };
|