@changfenhuang/dsh-genui 0.9.8 → 0.9.9
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 +13 -0
- package/README.md +13 -8
- package/README.zh-CN.md +13 -8
- package/SKILL.md +5 -5
- package/lib/client.js +3 -3
- package/lib/index.js +1273 -338
- package/lib/types/client/blocks/advanced.d.ts +1 -1
- package/lib/types/client/blocks/checkbox.d.ts +17 -0
- package/lib/types/client/blocks/forms.d.ts +3 -8
- package/lib/types/client/blocks/state.d.ts +18 -14
- package/lib/types/client/dom-fence.d.ts +1 -1
- package/lib/types/client/genui-runtime/diagnostics.d.ts +18 -0
- package/lib/types/client/genui-runtime/index.d.ts +1 -0
- package/lib/types/client/genui-runtime/limits.d.ts +54 -0
- package/lib/types/client/genui-runtime/normalize.d.ts +15 -0
- package/lib/types/client/genui-runtime/schema.d.ts +73 -0
- package/lib/types/client/genui-runtime/value-utils.d.ts +18 -0
- package/lib/types/client/guard.d.ts +60 -71
- package/lib/types/client/interaction-store.d.ts +7 -2
- package/lib/types/client/panel-command.d.ts +1 -1
- package/lib/types/client/primitive-labels.d.ts +11 -0
- package/lib/types/client/spec.d.ts +17 -17
- package/lib/types/plugin/chart-contract.d.ts +6 -4
- package/lib/types/plugin/index.d.ts +1 -4
- package/package.json +74 -13
- package/src/client/GenuiBlock.tsx +27 -11
- package/src/client/blocks/advanced.tsx +6 -5
- package/src/client/blocks/charts.tsx +1 -1
- package/src/client/blocks/checkbox.tsx +60 -0
- package/src/client/blocks/diagram/index.tsx +1 -1
- package/src/client/blocks/forms.tsx +51 -28
- package/src/client/blocks/render-node.tsx +3 -16
- package/src/client/blocks/state.ts +18 -14
- package/src/client/dom-fence.tsx +2 -1
- package/src/client/fence-render.tsx +26 -15
- package/src/client/genui-runtime/diagnostics.ts +143 -0
- package/src/client/genui-runtime/index.ts +1 -0
- package/src/client/genui-runtime/limits.ts +54 -0
- package/src/client/genui-runtime/normalize.ts +115 -0
- package/src/client/genui-runtime/schema.ts +291 -0
- package/src/client/genui-runtime/value-utils.ts +67 -0
- package/src/client/guard.ts +314 -169
- package/src/client/index.tsx +3 -2
- package/src/client/interaction-store.ts +7 -2
- package/src/client/panel-command.ts +2 -1
- package/src/client/panel.tsx +1 -1
- package/src/client/primitive-labels.ts +37 -0
- package/src/client/spec.ts +17 -17
- package/src/client/toolview.tsx +2 -1
- package/src/index.ts +3 -6
- package/src/plugin/chart-contract.ts +7 -97
- package/src/plugin/index.ts +2 -6
- package/src/plugin/tool.ts +60 -40
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/** Runtime metadata shared by GenUI normalization and diagnostics. */
|
|
2
|
+
|
|
3
|
+
export type ComponentFieldKind =
|
|
4
|
+
| 'string'
|
|
5
|
+
| 'string-or-null'
|
|
6
|
+
| 'number'
|
|
7
|
+
| 'boolean'
|
|
8
|
+
| 'nodes'
|
|
9
|
+
| 'array'
|
|
10
|
+
| 'object'
|
|
11
|
+
| 'unknown'
|
|
12
|
+
|
|
13
|
+
/** A conditional rule for fields whose presence depends on another field. */
|
|
14
|
+
export interface ComponentConditionalRule {
|
|
15
|
+
readonly kind: 'required-if'
|
|
16
|
+
readonly when: { readonly field: string; readonly equals: unknown }
|
|
17
|
+
readonly required: readonly string[]
|
|
18
|
+
readonly message?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A field rule that requires at least one member of a field group. */
|
|
22
|
+
export interface ComponentOneOfRule {
|
|
23
|
+
readonly kind: 'one-of-required'
|
|
24
|
+
readonly fields: readonly string[]
|
|
25
|
+
readonly message?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runtime metadata naming a component-specific semantic validator. */
|
|
29
|
+
export interface ComponentValidatorMetadata {
|
|
30
|
+
readonly name: string
|
|
31
|
+
readonly [key: string]: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Runtime schema for an object nested inside a native component field. */
|
|
35
|
+
export interface ComponentRecordSchema {
|
|
36
|
+
readonly required: readonly string[]
|
|
37
|
+
readonly fields: Readonly<Record<string, ComponentFieldKind>>
|
|
38
|
+
readonly enums: Readonly<Record<string, readonly string[]>>
|
|
39
|
+
readonly nested: Readonly<Record<string, ComponentRecordSchema>>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ComponentSchema {
|
|
43
|
+
readonly required: readonly string[]
|
|
44
|
+
readonly fields: Readonly<Record<string, ComponentFieldKind>>
|
|
45
|
+
readonly enums: Readonly<Record<string, readonly string[]>>
|
|
46
|
+
/** Explicit optional field kinds; `fields` remains the complete field map. */
|
|
47
|
+
readonly optional: Readonly<Record<string, ComponentFieldKind>>
|
|
48
|
+
readonly aliases: Readonly<Record<string, string>>
|
|
49
|
+
readonly oneOfRequired: readonly (readonly string[])[]
|
|
50
|
+
readonly conditionalRequired: readonly ComponentConditionalRule[]
|
|
51
|
+
readonly rules: readonly (ComponentOneOfRule | ComponentConditionalRule)[]
|
|
52
|
+
readonly nested: Readonly<Record<string, ComponentRecordSchema>>
|
|
53
|
+
readonly validator?: ComponentValidatorMetadata
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
/** Canonical enum domains shared by schema validation and repair. */
|
|
58
|
+
export const TEXT_SIZES = ['h1', 'h2', 'h3', 'body', 'muted', 'caption'] as const
|
|
59
|
+
export const BUTTON_TONES = ['primary', 'danger', 'success', 'ghost'] as const
|
|
60
|
+
export const BADGE_TONES = ['success', 'warn', 'danger', 'accent'] as const
|
|
61
|
+
export const INPUT_TYPES = ['text', 'email', 'password', 'color'] as const
|
|
62
|
+
export const CALLOUT_TONES = ['info', 'success', 'warning', 'error'] as const
|
|
63
|
+
export const CHART_KINDS = ['bars', 'line', 'donut'] as const
|
|
64
|
+
export const PLOT_KINDS = ['line', 'area', 'scatter'] as const
|
|
65
|
+
export const MEDIA_ASPECT_RATIOS = ['16:9', '4:3', '1:1', '9:16'] as const
|
|
66
|
+
export const MESH_SHAPES = ['box', 'sphere', 'cone', 'cylinder', 'torus'] as const
|
|
67
|
+
export const FILE_TYPES = ['file', 'dir'] as const
|
|
68
|
+
export const DIAGRAM_KINDS = [
|
|
69
|
+
'architecture', 'it-state', 'flowchart', 'sequence', 'state', 'er', 'timeline',
|
|
70
|
+
'swimlane', 'quadrant', 'radar', 'loop', 'nested', 'tree', 'org-chart', 'layers',
|
|
71
|
+
'venn', 'pyramid', 'bar', 'line', 'gantt', 'scatter', 'high-level', 'process',
|
|
72
|
+
'medallion', 'data-flow', 'dp-integration', 'dp-security-matrix',
|
|
73
|
+
] as const
|
|
74
|
+
export const DIAGRAM_NODE_TYPES = ['focal', 'backend', 'store', 'external', 'input', 'optional', 'security'] as const
|
|
75
|
+
export const DIAGRAM_VARIANTS = ['light', 'dark', 'editorial'] as const
|
|
76
|
+
export const DIAGRAM_EDGE_KINDS = ['solid', 'dashed', 'accent', 'link'] as const
|
|
77
|
+
export const DIAGRAM_ROUTES = ['auto', 'orthogonal', 'straight'] as const
|
|
78
|
+
export const ECHART_PRESETS = ['bar', 'line', 'area', 'pie', 'scatter'] as const
|
|
79
|
+
|
|
80
|
+
const schema = (
|
|
81
|
+
required: readonly string[],
|
|
82
|
+
fields: Readonly<Record<string, ComponentFieldKind>>,
|
|
83
|
+
aliases: Readonly<Record<string, string>> = {},
|
|
84
|
+
options: {
|
|
85
|
+
oneOfRequired?: readonly (readonly string[])[]
|
|
86
|
+
conditionalRequired?: readonly ComponentConditionalRule[]
|
|
87
|
+
nested?: Readonly<Record<string, ComponentRecordSchema>>
|
|
88
|
+
enums?: Readonly<Record<string, readonly string[]>>
|
|
89
|
+
validator?: ComponentValidatorMetadata
|
|
90
|
+
} = {},
|
|
91
|
+
): ComponentSchema => {
|
|
92
|
+
const optional = Object.fromEntries(
|
|
93
|
+
Object.entries(fields).filter(([field]) => field !== 'type' && !required.includes(field)),
|
|
94
|
+
) as Record<string, ComponentFieldKind>
|
|
95
|
+
const oneOfRequired = options.oneOfRequired ?? []
|
|
96
|
+
const conditionalRequired = options.conditionalRequired ?? []
|
|
97
|
+
const rules: Array<ComponentOneOfRule | ComponentConditionalRule> = [
|
|
98
|
+
...oneOfRequired.map(fieldsInRule => ({ kind: 'one-of-required' as const, fields: fieldsInRule })),
|
|
99
|
+
...conditionalRequired,
|
|
100
|
+
]
|
|
101
|
+
return {
|
|
102
|
+
required,
|
|
103
|
+
fields,
|
|
104
|
+
optional,
|
|
105
|
+
aliases,
|
|
106
|
+
oneOfRequired,
|
|
107
|
+
conditionalRequired,
|
|
108
|
+
rules,
|
|
109
|
+
enums: options.enums ?? {},
|
|
110
|
+
nested: options.nested ?? {},
|
|
111
|
+
...(options.validator === undefined ? {} : { validator: options.validator }),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const recordSchema = (
|
|
116
|
+
required: readonly string[],
|
|
117
|
+
fields: Readonly<Record<string, ComponentFieldKind>>,
|
|
118
|
+
nested: Readonly<Record<string, ComponentRecordSchema>> = {},
|
|
119
|
+
enums: Readonly<Record<string, readonly string[]>> = {},
|
|
120
|
+
): ComponentRecordSchema => ({ required, fields, enums, nested })
|
|
121
|
+
|
|
122
|
+
const nodeFields = { type: 'string' } as const
|
|
123
|
+
|
|
124
|
+
const chartDatumSchema = recordSchema(['label', 'value'], {
|
|
125
|
+
label: 'string',
|
|
126
|
+
value: 'number',
|
|
127
|
+
color: 'string',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const chartSeriesSchema = recordSchema(['label', 'data'], {
|
|
131
|
+
label: 'string',
|
|
132
|
+
color: 'string',
|
|
133
|
+
data: 'array',
|
|
134
|
+
}, { data: chartDatumSchema })
|
|
135
|
+
|
|
136
|
+
const stepsRecordSchema = recordSchema(['title'], { title: 'string', desc: 'string' })
|
|
137
|
+
const keyValueRecordSchema = recordSchema(['key', 'value'], { key: 'string', value: 'string' })
|
|
138
|
+
const timelineRecordSchema = recordSchema(['title'], { title: 'string', desc: 'string', time: 'string' })
|
|
139
|
+
const diffRecordSchema = recordSchema(['path', 'newText'], { path: 'string', oldText: 'string-or-null', newText: 'string' })
|
|
140
|
+
const plotParamSchema = recordSchema(['name', 'value'], {
|
|
141
|
+
name: 'string', value: 'number', min: 'number', max: 'number', step: 'number', animateTo: 'number', durationMs: 'number', loop: 'boolean',
|
|
142
|
+
})
|
|
143
|
+
const plotSeriesSchema = recordSchema(['expr'], {
|
|
144
|
+
expr: 'string', label: 'string', color: 'string', kind: 'string', params: 'array',
|
|
145
|
+
}, { params: plotParamSchema }, { kind: PLOT_KINDS })
|
|
146
|
+
const sceneMeshSchema = recordSchema(['shape'], {
|
|
147
|
+
shape: 'string', color: 'string', position: 'array', rotation: 'array', scale: 'unknown', size: 'unknown',
|
|
148
|
+
}, {}, { shape: MESH_SHAPES })
|
|
149
|
+
|
|
150
|
+
function fileTreeRecordSchema(depth: number): ComponentRecordSchema {
|
|
151
|
+
return recordSchema(['name'], {
|
|
152
|
+
name: 'string', type: 'string', children: 'array',
|
|
153
|
+
}, depth > 0 ? { children: fileTreeRecordSchema(depth - 1) } : {}, { type: FILE_TYPES })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fileTreeNodeSchema = fileTreeRecordSchema(6)
|
|
157
|
+
|
|
158
|
+
const tabHolderSchema = recordSchema(['label', 'items'], {
|
|
159
|
+
label: 'string',
|
|
160
|
+
items: 'nodes',
|
|
161
|
+
content: 'nodes',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const accordionHolderSchema = recordSchema(['title', 'items'], {
|
|
165
|
+
title: 'string',
|
|
166
|
+
items: 'nodes',
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const diagramNodeSchema = recordSchema(['id', 'label'], {
|
|
170
|
+
id: 'string',
|
|
171
|
+
label: 'string',
|
|
172
|
+
sub: 'string',
|
|
173
|
+
type: 'string',
|
|
174
|
+
x: 'number',
|
|
175
|
+
y: 'number',
|
|
176
|
+
w: 'number',
|
|
177
|
+
h: 'number',
|
|
178
|
+
tag: 'string',
|
|
179
|
+
}, {}, { type: DIAGRAM_NODE_TYPES })
|
|
180
|
+
|
|
181
|
+
const diagramEdgeSchema = recordSchema(['from', 'to'], {
|
|
182
|
+
from: 'string',
|
|
183
|
+
to: 'string',
|
|
184
|
+
label: 'string',
|
|
185
|
+
kind: 'string',
|
|
186
|
+
route: 'string',
|
|
187
|
+
}, {}, { kind: DIAGRAM_EDGE_KINDS, route: DIAGRAM_ROUTES })
|
|
188
|
+
|
|
189
|
+
const diagramZoneSchema = recordSchema(['label'], {
|
|
190
|
+
label: 'string',
|
|
191
|
+
x: 'number',
|
|
192
|
+
y: 'number',
|
|
193
|
+
w: 'number',
|
|
194
|
+
h: 'number',
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const diagramThemeSchema = recordSchema([], {
|
|
198
|
+
paper: 'string',
|
|
199
|
+
'paper-2': 'string',
|
|
200
|
+
ink: 'string',
|
|
201
|
+
muted: 'string',
|
|
202
|
+
soft: 'string',
|
|
203
|
+
rule: 'string',
|
|
204
|
+
accent: 'string',
|
|
205
|
+
'accent-tint': 'string',
|
|
206
|
+
link: 'string',
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
/** Root GenUI specification metadata used by diagnostics. */
|
|
210
|
+
export const GENUI_SPEC_SCHEMA = schema(['items'], {
|
|
211
|
+
title: 'string',
|
|
212
|
+
gap: 'number',
|
|
213
|
+
panel: 'boolean',
|
|
214
|
+
append: 'boolean',
|
|
215
|
+
items: 'nodes',
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
/** Backwards-friendly short alias for the root specification schema. */
|
|
219
|
+
export const SPEC_SCHEMA = GENUI_SPEC_SCHEMA
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Native component field metadata.
|
|
223
|
+
*
|
|
224
|
+
* This is intentionally explicit rather than inferred from TypeScript
|
|
225
|
+
* interfaces: the registry is also consumed at runtime by normalization and
|
|
226
|
+
* diagnostics, where erased interfaces are unavailable.
|
|
227
|
+
*/
|
|
228
|
+
export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
|
|
229
|
+
accordion: schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: accordionHolderSchema } }),
|
|
230
|
+
audio: schema(['src'], { ...nodeFields, src: 'string', alt: 'string', loop: 'boolean' }),
|
|
231
|
+
avatar: schema(['name'], { ...nodeFields, name: 'string', color: 'string' }),
|
|
232
|
+
badge: schema(['label'], { ...nodeFields, label: 'string', tone: 'string', icon: 'string' }, { text: 'label', value: 'label' }, { enums: { tone: BADGE_TONES } }),
|
|
233
|
+
breadcrumb: schema(['items'], { ...nodeFields, items: 'array' }),
|
|
234
|
+
button: schema(['label'], { ...nodeFields, label: 'string', tone: 'string', full: 'boolean', small: 'boolean', icon: 'string', action: 'string' }, {}, { enums: { tone: BUTTON_TONES } }),
|
|
235
|
+
callout: schema(['content'], { ...nodeFields, title: 'string', content: 'string', tone: 'string' }, { kind: 'tone' }, { enums: { tone: CALLOUT_TONES } }),
|
|
236
|
+
card: schema(['items'], { ...nodeFields, title: 'string', items: 'nodes' }, { label: 'title', content: 'items' }),
|
|
237
|
+
chart: schema([], { ...nodeFields, kind: 'string', data: 'array', series: 'array' }, {}, {
|
|
238
|
+
oneOfRequired: [['data', 'series']],
|
|
239
|
+
conditionalRequired: [
|
|
240
|
+
{ kind: 'required-if', when: { field: 'kind', equals: 'line' }, required: ['data'] },
|
|
241
|
+
{ kind: 'required-if', when: { field: 'kind', equals: 'donut' }, required: ['data'] },
|
|
242
|
+
],
|
|
243
|
+
nested: { data: chartDatumSchema, series: chartSeriesSchema },
|
|
244
|
+
enums: { kind: CHART_KINDS },
|
|
245
|
+
validator: { name: 'chart-renderability' },
|
|
246
|
+
}),
|
|
247
|
+
checkbox: schema(['label'], { ...nodeFields, label: 'string', checked: 'boolean', action: 'string', group: 'string' }),
|
|
248
|
+
code: schema(['code'], { ...nodeFields, lang: 'string', code: 'string' }),
|
|
249
|
+
col: schema(['items'], { ...nodeFields, items: 'nodes', gap: 'number' }),
|
|
250
|
+
copy: schema(['text'], { ...nodeFields, label: 'string', text: 'string' }),
|
|
251
|
+
diagram: schema(['kind', 'nodes'], { ...nodeFields, kind: 'string', variant: 'string', title: 'string', nodes: 'array', edges: 'array', zones: 'array', theme: 'object' }, {}, {
|
|
252
|
+
nested: { nodes: diagramNodeSchema, edges: diagramEdgeSchema, zones: diagramZoneSchema, theme: diagramThemeSchema },
|
|
253
|
+
enums: { kind: DIAGRAM_KINDS, variant: DIAGRAM_VARIANTS },
|
|
254
|
+
}),
|
|
255
|
+
diff: schema(['diffs'], { ...nodeFields, diffs: 'array' }, {}, { nested: { diffs: diffRecordSchema } }),
|
|
256
|
+
divider: schema([], nodeFields),
|
|
257
|
+
echart: schema([], { ...nodeFields, title: 'string', height: 'number', preset: 'string', data: 'array', series: 'array', option: 'object' }, {}, {
|
|
258
|
+
oneOfRequired: [['option', 'data', 'series']],
|
|
259
|
+
enums: { preset: ECHART_PRESETS },
|
|
260
|
+
}),
|
|
261
|
+
'file-tree': schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: fileTreeNodeSchema } }),
|
|
262
|
+
grid: schema(['items'], { ...nodeFields, cols: 'number', items: 'nodes' }),
|
|
263
|
+
image: schema(['src'], { ...nodeFields, src: 'string', alt: 'string' }),
|
|
264
|
+
input: schema([], { ...nodeFields, label: 'string', placeholder: 'string', value: 'string', inputType: 'string', action: 'string', id: 'string' }, {}, { enums: { inputType: INPUT_TYPES } }),
|
|
265
|
+
json: schema(['value'], { ...nodeFields, value: 'unknown' }),
|
|
266
|
+
keyvalue: schema(['pairs'], { ...nodeFields, pairs: 'array' }, {}, { nested: { pairs: keyValueRecordSchema } }),
|
|
267
|
+
link: schema(['label'], { ...nodeFields, label: 'string', href: 'string' }),
|
|
268
|
+
list: schema(['items'], { ...nodeFields, items: 'array' }),
|
|
269
|
+
mermaid: schema(['code'], { ...nodeFields, code: 'string' }),
|
|
270
|
+
plot: schema(['series'], { ...nodeFields, series: 'array', xMin: 'number', xMax: 'number', yMin: 'number', yMax: 'number', title: 'string' }, {}, { nested: { series: plotSeriesSchema } }),
|
|
271
|
+
progress: schema(['value'], { ...nodeFields, value: 'number', label: 'string', valueLabel: 'string' }),
|
|
272
|
+
quiz: schema(['question', 'options'], { ...nodeFields, question: 'string', options: 'array', explanation: 'string', id: 'string', action: 'string' }),
|
|
273
|
+
radio: schema(['options'], { ...nodeFields, label: 'string', options: 'array', selected: 'number', action: 'string', group: 'string', answer: 'unknown', explanation: 'string' }),
|
|
274
|
+
row: schema(['items'], { ...nodeFields, items: 'nodes', wrap: 'boolean', spacer: 'boolean' }),
|
|
275
|
+
scene3d: schema(['meshes'], { ...nodeFields, title: 'string', meshes: 'array', ambient: 'number', background: 'string' }, {}, { nested: { meshes: sceneMeshSchema } }),
|
|
276
|
+
select: schema(['options'], { ...nodeFields, label: 'string', options: 'array', action: 'string', selected: 'number', id: 'string' }),
|
|
277
|
+
slider: schema([], { ...nodeFields, label: 'string', min: 'number', max: 'number', step: 'number', value: 'number', action: 'string', id: 'string' }),
|
|
278
|
+
spacer: schema([], nodeFields),
|
|
279
|
+
stat: schema(['label', 'value'], { ...nodeFields, label: 'string', value: 'string', delta: 'string' }),
|
|
280
|
+
steps: schema(['steps'], { ...nodeFields, steps: 'array', current: 'number' }, { items: 'steps' }, { nested: { steps: stepsRecordSchema } }),
|
|
281
|
+
submit: schema(['label'], { ...nodeFields, label: 'string', action: 'string', resetAction: 'string', groups: 'array' }),
|
|
282
|
+
switch: schema(['label'], { ...nodeFields, label: 'string', checked: 'boolean', action: 'string' }),
|
|
283
|
+
table: schema(['columns', 'rows'], { ...nodeFields, columns: 'array', rows: 'array' }, { headers: 'columns', data: 'rows' }),
|
|
284
|
+
tabs: schema(['tabs'], { ...nodeFields, tabs: 'array' }, {}, { nested: { tabs: tabHolderSchema } }),
|
|
285
|
+
text: schema(['content'], { ...nodeFields, content: 'string', size: 'string', center: 'boolean' }, { text: 'content' }, { enums: { size: TEXT_SIZES } }),
|
|
286
|
+
textarea: schema([], { ...nodeFields, label: 'string', placeholder: 'string', rows: 'number', value: 'string', action: 'string', id: 'string' }),
|
|
287
|
+
timeline: schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: timelineRecordSchema } }),
|
|
288
|
+
video: schema(['src'], { ...nodeFields, src: 'string', alt: 'string', poster: 'string', loop: 'boolean', muted: 'boolean', aspectRatio: 'string' }, {}, { enums: { aspectRatio: MEDIA_ASPECT_RATIOS } }),
|
|
289
|
+
} as const
|
|
290
|
+
|
|
291
|
+
export const GENUI_NATIVE_TYPES: ReadonlySet<string> = new Set(Object.keys(COMPONENT_SCHEMAS))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Shared primitive sanitizers used by the GenUI guard. */
|
|
2
|
+
|
|
3
|
+
/** Is `value` one of `values`? */
|
|
4
|
+
function inEnum<T extends string>(value: unknown, values: readonly T[]): value is T {
|
|
5
|
+
return typeof value === 'string' && (values as readonly string[]).includes(value)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** String field: truncate a string to `cap`, or undefined when not a string. */
|
|
9
|
+
export function str(value: unknown, cap: number): string | undefined {
|
|
10
|
+
return typeof value === 'string' ? value.slice(0, cap) : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Color field: the value lands in an inline `style` (background/stroke) or
|
|
15
|
+
* THREE.Color. Arbitrary CSS values are an exfiltration channel, so only
|
|
16
|
+
* literal color formats and host design tokens are accepted.
|
|
17
|
+
*/
|
|
18
|
+
const SAFE_COLOR_RE = /^(?:#[\da-fA-F]{3,8}|rgba?\([^)]{0,64}\)|hsla?\([^)]{0,64}\)|var\(--dsw-[\w-]+(?:,\s*#[0-9a-fA-F]{3,8})?\))$/
|
|
19
|
+
|
|
20
|
+
export function color(value: unknown): string | undefined {
|
|
21
|
+
if (typeof value !== 'string') return undefined
|
|
22
|
+
const normalized = value.trim()
|
|
23
|
+
return normalized.length <= 64 && SAFE_COLOR_RE.test(normalized) ? normalized : undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keep only http(s) and mailto link targets. */
|
|
27
|
+
export function safeHref(value: unknown): string | undefined {
|
|
28
|
+
if (typeof value !== 'string') return undefined
|
|
29
|
+
const normalized = value.trim()
|
|
30
|
+
if (normalized.length > 2048) return undefined
|
|
31
|
+
return /^https?:\/\//i.test(normalized) || /^mailto:[^@\s]+@[^@\s]+$/i.test(normalized) ? normalized : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Keep browser-reachable http(s) or same-origin relative media paths. */
|
|
35
|
+
export function safeMediaSrc(value: unknown): string | undefined {
|
|
36
|
+
if (typeof value !== 'string') return undefined
|
|
37
|
+
const normalized = value.trim()
|
|
38
|
+
if (normalized === '' || normalized.length > 2048) return undefined
|
|
39
|
+
if (/^https?:\/\//i.test(normalized)) return normalized
|
|
40
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(normalized) || /^[/\\]{2}/.test(normalized)) return undefined
|
|
41
|
+
return normalized
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Finite-number field: clamp into [min, max], or undefined when not finite. */
|
|
45
|
+
export function num(value: unknown, min: number, max: number): number | undefined {
|
|
46
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Integer field: clamp into [min, max], or undefined when not a finite number. */
|
|
50
|
+
export function int(value: unknown, min: number, max: number): number | undefined {
|
|
51
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Optional enum field: the value when it matches, otherwise undefined. */
|
|
55
|
+
export function enu<T extends string>(value: unknown, values: readonly T[]): T | undefined {
|
|
56
|
+
return inEnum(value, values) ? value : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Plain object (not array, not null). */
|
|
60
|
+
export function obj(value: unknown): Record<string, unknown> | undefined {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Preserve an optional field only when its value exists. */
|
|
65
|
+
export function opt<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
|
|
66
|
+
return value === undefined ? {} : { [key]: value } as Partial<Record<K, V>>
|
|
67
|
+
}
|