@changfenhuang/dsh-genui 0.9.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/CHANGELOG.md +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- package/tsdown.config.ts +193 -0
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GenUI spec guard: resource limits, structural validation, and deterministic
|
|
3
|
+
* repair for ```dsh-ui fence specs.
|
|
4
|
+
*
|
|
5
|
+
* The renderer path runs every fence body through `repairGenuiSpec` before
|
|
6
|
+
* rendering, so a pathological or hostile spec — deep nesting, thousands of
|
|
7
|
+
* nodes, oversized strings, out-of-range numbers — degrades gracefully instead
|
|
8
|
+
* of stalling the UI. Repair is deterministic and prefix-stable: a component
|
|
9
|
+
* that survives repair of a partial stream keeps its position when later
|
|
10
|
+
* chunks arrive, so streaming re-renders stay consistent.
|
|
11
|
+
*
|
|
12
|
+
* Policy:
|
|
13
|
+
* - Unknown node `type`s pass through untouched (plugin-registered custom
|
|
14
|
+
* components via `registerGenuiComponent` are opaque to this package).
|
|
15
|
+
* - Known types: required fields must have the right type or the node is
|
|
16
|
+
* dropped; numbers are clamped into range; strings truncated; arrays
|
|
17
|
+
* sliced to their caps; containers recursed with a depth budget.
|
|
18
|
+
* - The whole spec carries a node budget; once exhausted, remaining siblings
|
|
19
|
+
* are elided.
|
|
20
|
+
*/
|
|
21
|
+
import type { GenuiFileTreeNode, GenuiList, GenuiNode, GenuiPlot, GenuiPlotSeries, GenuiScene3D, GenuiSpec, GenuiDiagram, GenuiDiagramTheme, GenuiDiagramKind } from './spec.ts'
|
|
22
|
+
import { wrapSingleComponentRoot } from './spec.ts'
|
|
23
|
+
|
|
24
|
+
/** Hard resource limits enforced by repair (and mirrored at render time). */
|
|
25
|
+
export const GENUI_LIMITS = {
|
|
26
|
+
/** Maximum nesting depth of the component tree. */
|
|
27
|
+
maxDepth: 8,
|
|
28
|
+
/** Maximum total nodes across the whole spec. */
|
|
29
|
+
maxNodes: 200,
|
|
30
|
+
/** Maximum length of any plain string field. */
|
|
31
|
+
maxString: 2000,
|
|
32
|
+
/** Maximum length of a `code` body. */
|
|
33
|
+
maxCode: 12_000,
|
|
34
|
+
/** Maximum length of a mermaid source. */
|
|
35
|
+
maxMermaid: 8000,
|
|
36
|
+
/** Maximum `grid` columns. */
|
|
37
|
+
maxGridCols: 12,
|
|
38
|
+
/** Maximum `tabs` count. */
|
|
39
|
+
maxTabs: 12,
|
|
40
|
+
/** Maximum `accordion` items. */
|
|
41
|
+
maxAccordionItems: 24,
|
|
42
|
+
/** Maximum `list` items. */
|
|
43
|
+
maxListItems: 50,
|
|
44
|
+
/** Maximum `select`/`radio` options. */
|
|
45
|
+
maxOptions: 50,
|
|
46
|
+
/** Maximum `table` rows / columns. */
|
|
47
|
+
maxTableRows: 50,
|
|
48
|
+
maxTableCols: 12,
|
|
49
|
+
/** Maximum `chart` data points per series. */
|
|
50
|
+
maxChartPoints: 60,
|
|
51
|
+
/** Maximum `plot` series and per-series parameters. */
|
|
52
|
+
maxPlotSeries: 8,
|
|
53
|
+
maxPlotParams: 6,
|
|
54
|
+
/** Maximum `scene3d` meshes. */
|
|
55
|
+
maxMeshes: 5,
|
|
56
|
+
/** Maximum `quiz` options. */
|
|
57
|
+
maxQuizOptions: 8,
|
|
58
|
+
/** Maximum `steps` / `timeline` / `breadcrumb` / `keyvalue` entries. */
|
|
59
|
+
maxSteps: 24,
|
|
60
|
+
maxTimelineItems: 24,
|
|
61
|
+
maxBreadcrumbItems: 12,
|
|
62
|
+
maxKeyValuePairs: 24,
|
|
63
|
+
/** Maximum `file-tree` nesting. */
|
|
64
|
+
maxTreeDepth: 6,
|
|
65
|
+
/** Maximum `diagram` nodes / edges / zones / focal accents (editorial
|
|
66
|
+
* complexity budget, mirroring diagram-design's §7 limits). */
|
|
67
|
+
maxDiagramNodes: 9,
|
|
68
|
+
maxDiagramEdges: 12,
|
|
69
|
+
maxDiagramZones: 3,
|
|
70
|
+
maxDiagramFocal: 2,
|
|
71
|
+
maxDiagramLabel: 14,
|
|
72
|
+
|
|
73
|
+
/** Maximum depth of an `echart` option object (prevents pathological nested
|
|
74
|
+
* ECharts configs from stalling the guard walk). */
|
|
75
|
+
maxEChartOptionDepth: 10,
|
|
76
|
+
/** Maximum length of any single array inside an `echart` option (prevents
|
|
77
|
+
* a model from stalling rendering with `series.data` of hundreds of
|
|
78
|
+
* thousands of points). */
|
|
79
|
+
maxEChartArrayLen: 500,
|
|
80
|
+
/** Maximum total entries (object keys + array elements) traversed while
|
|
81
|
+
* sanitizing an `echart` option. Bounds the walk so a pathologically
|
|
82
|
+
* large option object cannot stall the guard. */
|
|
83
|
+
maxEChartOptionNodes: 2000,
|
|
84
|
+
} as const
|
|
85
|
+
|
|
86
|
+
/** Result of `validateGenuiSpec`. */
|
|
87
|
+
export interface GenuiValidation {
|
|
88
|
+
ok: boolean
|
|
89
|
+
/** Human-readable problems, empty when `ok`. */
|
|
90
|
+
errors: string[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ---------------- shared field helpers ---------------- */
|
|
94
|
+
|
|
95
|
+
/** Is `v` one of `values`? (enum guard) */
|
|
96
|
+
function inEnum<T extends string>(v: unknown, values: readonly T[]): v is T {
|
|
97
|
+
return typeof v === 'string' && (values as readonly string[]).includes(v)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** String field: truncate a string to `cap`, or undefined when not a string. */
|
|
101
|
+
function str(v: unknown, cap: number): string | undefined {
|
|
102
|
+
return typeof v === 'string' ? v.slice(0, cap) : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Color field: the value lands in an inline `style` (background/stroke) or
|
|
107
|
+
* THREE.Color. Arbitrary CSS values are an exfiltration channel — a model
|
|
108
|
+
* (or a hostile spec) could emit `url(https://attacker/track?...)` and the
|
|
109
|
+
* browser would fetch it. Only formats that name a color pass: hex, rgb/hsl
|
|
110
|
+
* functions, and host design tokens (`var(--dsw-*)`). Anything else degrades
|
|
111
|
+
* to the component's default palette.
|
|
112
|
+
*/
|
|
113
|
+
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})?\))$/
|
|
114
|
+
|
|
115
|
+
function color(v: unknown): string | undefined {
|
|
116
|
+
if (typeof v !== 'string') return undefined
|
|
117
|
+
const s = v.trim()
|
|
118
|
+
return s.length <= 64 && SAFE_COLOR_RE.test(s) ? s : undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Link target field: only http(s) and mailto survive. `javascript:`/`data:`
|
|
123
|
+
* and every other scheme degrade to a plain-text node — the model's link is
|
|
124
|
+
* display, not an execution channel.
|
|
125
|
+
*/
|
|
126
|
+
function safeHref(v: unknown): string | undefined {
|
|
127
|
+
if (typeof v !== 'string') return undefined
|
|
128
|
+
const s = v.trim()
|
|
129
|
+
if (s.length > 2048) return undefined
|
|
130
|
+
return /^https?:\/\//i.test(s) || /^mailto:[^@\s]+@[^@\s]+$/i.test(s) ? s : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Media loads bytes, so accept only browser-reachable http(s) or same-origin
|
|
134
|
+
* relative paths. Active/local schemes and protocol-relative URLs are
|
|
135
|
+
* rejected. The renderer always keeps playback user-controlled. */
|
|
136
|
+
function safeMediaSrc(v: unknown): string | undefined {
|
|
137
|
+
if (typeof v !== 'string') return undefined
|
|
138
|
+
const s = v.trim()
|
|
139
|
+
if (s === '' || s.length > 2048) return undefined
|
|
140
|
+
if (/^https?:\/\//i.test(s)) return s
|
|
141
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(s) || /^[/\\]{2}/.test(s)) return undefined
|
|
142
|
+
return s
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Finite-number field: clamp into [min, max], or undefined when not finite. */
|
|
146
|
+
function num(v: unknown, min: number, max: number): number | undefined {
|
|
147
|
+
return typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Integer field: clamp into [min, max], or undefined when not a finite integer. */
|
|
151
|
+
function int(v: unknown, min: number, max: number): number | undefined {
|
|
152
|
+
return typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.trunc(v))) : undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Optional enum field: the value when it matches, otherwise undefined. */
|
|
156
|
+
function enu<T extends string>(v: unknown, values: readonly T[]): T | undefined {
|
|
157
|
+
return inEnum(v, values) ? v : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Plain object (not array, not null). */
|
|
161
|
+
function obj(v: unknown): Record<string, unknown> | undefined {
|
|
162
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) ? v as Record<string, unknown> : undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Optional-field spread helper. `exactOptionalPropertyTypes` forbids
|
|
167
|
+
* `{ gap: number | undefined }`; computing the value into a const first and
|
|
168
|
+
* spreading `opt('gap', g)` keeps every optional field either absent or a
|
|
169
|
+
* plain value.
|
|
170
|
+
*/
|
|
171
|
+
function opt<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
|
|
172
|
+
return value === undefined ? {} : { [key]: value } as Partial<Record<K, V>>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const TEXT_SIZES = ['h1', 'h2', 'h3', 'body', 'muted', 'caption'] as const
|
|
176
|
+
const BUTTON_TONES = ['primary', 'danger', 'success', 'ghost'] as const
|
|
177
|
+
const BADGE_TONES = ['success', 'warn', 'danger', 'accent'] as const
|
|
178
|
+
const INPUT_TYPES = ['text', 'email', 'password'] as const
|
|
179
|
+
const CALLOUT_TONES = ['info', 'success', 'warning', 'error'] as const
|
|
180
|
+
const CHART_KINDS = ['bars', 'line', 'donut'] as const
|
|
181
|
+
const PLOT_KINDS = ['line', 'area', 'scatter'] as const
|
|
182
|
+
const MEDIA_ASPECT_RATIOS = ['16:9', '4:3', '1:1', '9:16'] as const
|
|
183
|
+
const MESH_SHAPES = ['box', 'sphere', 'cone', 'cylinder', 'torus'] as const
|
|
184
|
+
const FILE_TYPES = ['file', 'dir'] as const
|
|
185
|
+
const DIAGRAM_KINDS: readonly string[] = [
|
|
186
|
+
'architecture', 'it-state', 'flowchart', 'sequence', 'state', 'er', 'timeline',
|
|
187
|
+
'swimlane', 'quadrant', 'radar', 'loop', 'nested', 'tree', 'org-chart', 'layers',
|
|
188
|
+
'venn', 'pyramid', 'bar', 'line', 'gantt', 'scatter', 'high-level', 'process',
|
|
189
|
+
'medallion', 'data-flow', 'dp-integration', 'dp-security-matrix',
|
|
190
|
+
]
|
|
191
|
+
const DIAGRAM_NODE_TYPES = ['focal', 'backend', 'store', 'external', 'input', 'optional', 'security'] as const
|
|
192
|
+
const DIAGRAM_VARIANTS = ['light', 'dark', 'editorial'] as const
|
|
193
|
+
const DIAGRAM_EDGE_KINDS = ['solid', 'dashed', 'accent', 'link'] as const
|
|
194
|
+
const DIAGRAM_ROUTES = ['auto', 'orthogonal', 'straight'] as const
|
|
195
|
+
|
|
196
|
+
const ECHART_PRESETS = ['bar', 'line', 'area', 'pie', 'scatter'] as const
|
|
197
|
+
|
|
198
|
+
/* ---------------- repair ---------------- */
|
|
199
|
+
|
|
200
|
+
interface RepairCtx {
|
|
201
|
+
/** Nodes left in the budget; 0 stops the walk. */
|
|
202
|
+
remaining: number
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Walk `list` with the shared node budget; drops invalid entries. */
|
|
206
|
+
function repairItems(list: unknown, ctx: RepairCtx, depth: number): GenuiNode[] {
|
|
207
|
+
if (!Array.isArray(list)) return []
|
|
208
|
+
const out: GenuiNode[] = []
|
|
209
|
+
for (const item of list) {
|
|
210
|
+
if (ctx.remaining <= 0) break
|
|
211
|
+
ctx.remaining -= 1
|
|
212
|
+
const node = repairNode(item, ctx, depth)
|
|
213
|
+
if (node !== null) out.push(node)
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode | null {
|
|
219
|
+
if (depth > GENUI_LIMITS.maxDepth) return null
|
|
220
|
+
const v = obj(value)
|
|
221
|
+
if (v === undefined) return null
|
|
222
|
+
const type = v.type
|
|
223
|
+
if (typeof type !== 'string') return null
|
|
224
|
+
switch (type) {
|
|
225
|
+
case 'text': {
|
|
226
|
+
const content = str(v.content, GENUI_LIMITS.maxString) ?? str(v.text, GENUI_LIMITS.maxString)
|
|
227
|
+
if (content === undefined) return null
|
|
228
|
+
return { type: 'text', content, ...opt('size', enu(v.size, TEXT_SIZES)), ...opt('center', v.center === true ? true : undefined) }
|
|
229
|
+
}
|
|
230
|
+
case 'row': {
|
|
231
|
+
return { type: 'row', items: repairItems(v.items, ctx, depth + 1), ...opt('wrap', v.wrap === true ? true : undefined), ...opt('spacer', v.spacer === true ? true : undefined) }
|
|
232
|
+
}
|
|
233
|
+
case 'col': {
|
|
234
|
+
return { type: 'col', items: repairItems(v.items, ctx, depth + 1), ...opt('gap', num(v.gap, 0, 96)) }
|
|
235
|
+
}
|
|
236
|
+
case 'grid': {
|
|
237
|
+
return { type: 'grid', cols: int(v.cols, 1, GENUI_LIMITS.maxGridCols) ?? 1, items: repairItems(v.items, ctx, depth + 1) }
|
|
238
|
+
}
|
|
239
|
+
case 'card': {
|
|
240
|
+
return { type: 'card', items: repairItems(v.items, ctx, depth + 1), ...opt('title', str(v.title, GENUI_LIMITS.maxString)) }
|
|
241
|
+
}
|
|
242
|
+
case 'button': {
|
|
243
|
+
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
244
|
+
if (label === undefined) return null
|
|
245
|
+
return {
|
|
246
|
+
type: 'button', label,
|
|
247
|
+
...opt('tone', enu(v.tone, BUTTON_TONES)),
|
|
248
|
+
...opt('full', v.full === true ? true : undefined),
|
|
249
|
+
...opt('small', v.small === true ? true : undefined),
|
|
250
|
+
...opt('icon', str(v.icon, 64)),
|
|
251
|
+
...opt('action', str(v.action, 200)),
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
case 'input': {
|
|
255
|
+
return {
|
|
256
|
+
type: 'input',
|
|
257
|
+
...opt('label', str(v.label, GENUI_LIMITS.maxString)),
|
|
258
|
+
...opt('placeholder', str(v.placeholder, GENUI_LIMITS.maxString)),
|
|
259
|
+
...opt('value', str(v.value, GENUI_LIMITS.maxString)),
|
|
260
|
+
...opt('inputType', enu(v.inputType, INPUT_TYPES)),
|
|
261
|
+
...opt('action', str(v.action, 200)),
|
|
262
|
+
...opt('id', str(v.id, 200)),
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
case 'select': {
|
|
266
|
+
const options = repairStrings(v.options, GENUI_LIMITS.maxOptions, GENUI_LIMITS.maxString)
|
|
267
|
+
if (options === undefined) return null
|
|
268
|
+
return {
|
|
269
|
+
type: 'select', options,
|
|
270
|
+
...opt('label', str(v.label, GENUI_LIMITS.maxString)),
|
|
271
|
+
...opt('action', str(v.action, 200)),
|
|
272
|
+
...opt('selected', int(v.selected, 0, options.length - 1)),
|
|
273
|
+
...opt('id', str(v.id, 200)),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
case 'checkbox': {
|
|
277
|
+
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
278
|
+
if (label === undefined) return null
|
|
279
|
+
return { type: 'checkbox', label, ...opt('checked', v.checked === true ? true : undefined), ...opt('action', str(v.action, 200)) }
|
|
280
|
+
}
|
|
281
|
+
case 'link': {
|
|
282
|
+
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
283
|
+
if (label === undefined) return null
|
|
284
|
+
return { type: 'link', label, ...opt('href', safeHref(v.href)) }
|
|
285
|
+
}
|
|
286
|
+
case 'audio': {
|
|
287
|
+
const src = safeMediaSrc(v.src)
|
|
288
|
+
if (src === undefined) return null
|
|
289
|
+
return {
|
|
290
|
+
type: 'audio', src,
|
|
291
|
+
...opt('alt', str(v.alt, GENUI_LIMITS.maxString)),
|
|
292
|
+
...opt('loop', v.loop === true ? true : undefined),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
case 'video': {
|
|
296
|
+
const src = safeMediaSrc(v.src)
|
|
297
|
+
if (src === undefined) return null
|
|
298
|
+
return {
|
|
299
|
+
type: 'video', src,
|
|
300
|
+
...opt('alt', str(v.alt, GENUI_LIMITS.maxString)),
|
|
301
|
+
...opt('poster', safeMediaSrc(v.poster)),
|
|
302
|
+
...opt('loop', v.loop === true ? true : undefined),
|
|
303
|
+
...opt('muted', v.muted === true ? true : undefined),
|
|
304
|
+
...opt('aspectRatio', enu(v.aspectRatio, MEDIA_ASPECT_RATIOS)),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
case 'badge': {
|
|
308
|
+
const label = str(v.label, GENUI_LIMITS.maxString) ?? str(v.text, GENUI_LIMITS.maxString) ?? str(v.value, GENUI_LIMITS.maxString)
|
|
309
|
+
if (label === undefined) return null
|
|
310
|
+
return { type: 'badge', label, ...opt('tone', enu(v.tone, BADGE_TONES)), ...opt('icon', str(v.icon, 64)) }
|
|
311
|
+
}
|
|
312
|
+
case 'stat': {
|
|
313
|
+
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
314
|
+
const value = str(v.value, 128)
|
|
315
|
+
if (label === undefined || value === undefined) return null
|
|
316
|
+
return { type: 'stat', label, value, ...opt('delta', str(v.delta, 64)) }
|
|
317
|
+
}
|
|
318
|
+
case 'progress': {
|
|
319
|
+
const value = num(v.value, 0, 100)
|
|
320
|
+
if (value === undefined) return null
|
|
321
|
+
return { type: 'progress', value, ...opt('label', str(v.label, GENUI_LIMITS.maxString)), ...opt('valueLabel', str(v.valueLabel, 64)) }
|
|
322
|
+
}
|
|
323
|
+
case 'divider': return { type: 'divider' }
|
|
324
|
+
case 'spacer': return { type: 'spacer' }
|
|
325
|
+
case 'avatar': {
|
|
326
|
+
const name = str(v.name, 64)
|
|
327
|
+
if (name === undefined) return null
|
|
328
|
+
return { type: 'avatar', name, ...opt('color', color(v.color)) }
|
|
329
|
+
}
|
|
330
|
+
case 'list': {
|
|
331
|
+
const items = repairListItems(v.items, GENUI_LIMITS.maxListItems, ctx, depth + 1)
|
|
332
|
+
if (items === undefined) return null
|
|
333
|
+
return { type: 'list', items }
|
|
334
|
+
}
|
|
335
|
+
case 'table': {
|
|
336
|
+
let rawCols = v.columns as unknown
|
|
337
|
+
let rawRows = v.rows !== undefined ? v.rows : (v as Record<string, unknown>).data
|
|
338
|
+
// Self-heal model-shaped tables: antd-style object columns
|
|
339
|
+
// ({title,key}) become header strings, and object-array rows (or a
|
|
340
|
+
// `data` alias) flatten to 2D rows keyed by the column keys — without
|
|
341
|
+
// this the whole node is dropped for "missing 2D rows" and the user
|
|
342
|
+
// sees nothing (issue #42).
|
|
343
|
+
if (Array.isArray(rawCols) && rawCols.length > 0 && typeof rawCols[0] === 'object' && rawCols[0] !== null) {
|
|
344
|
+
rawCols = rawCols.map(c => columnHeaderText(c))
|
|
345
|
+
}
|
|
346
|
+
if (Array.isArray(rawRows) && rawRows.length > 0 && typeof rawRows[0] === 'object' && rawRows[0] !== null && !Array.isArray(rawRows[0])) {
|
|
347
|
+
const keys = Array.isArray(v.columns) && v.columns.length > 0 && typeof v.columns[0] === 'object' && v.columns[0] !== null
|
|
348
|
+
? v.columns.map(c => columnKeyOf(c)).filter((k): k is string => k !== undefined)
|
|
349
|
+
: Object.keys(rawRows[0] as Record<string, unknown>)
|
|
350
|
+
rawRows = rawRows.map(row => keys.map(k => cellText((row as Record<string, unknown>)[k])))
|
|
351
|
+
}
|
|
352
|
+
const columns = repairStrings(rawCols, GENUI_LIMITS.maxTableCols, 128)
|
|
353
|
+
const rows = repairRows(rawRows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)
|
|
354
|
+
if (columns === undefined || rows === undefined) return null
|
|
355
|
+
return { type: 'table', columns, rows }
|
|
356
|
+
}
|
|
357
|
+
case 'chart': {
|
|
358
|
+
const data = repairChartData(v.data, GENUI_LIMITS.maxChartPoints)
|
|
359
|
+
const series = Array.isArray(v.series) ? repairSeries(v.series, GENUI_LIMITS.maxPlotSeries, GENUI_LIMITS.maxChartPoints) : undefined
|
|
360
|
+
// `data` is required by the type but grouped bars may ship `series`
|
|
361
|
+
// alone; a series-only chart gets an empty data array (the renderer
|
|
362
|
+
// reads `series` in that case).
|
|
363
|
+
if (data === undefined && series === undefined) return null
|
|
364
|
+
return { type: 'chart', data: data ?? [], ...opt('kind', enu(v.kind, CHART_KINDS)), ...opt('series', series) }
|
|
365
|
+
}
|
|
366
|
+
case 'tabs': {
|
|
367
|
+
const tabs = repairTabs(v.tabs, ctx, depth)
|
|
368
|
+
if (tabs === undefined) return null
|
|
369
|
+
return { type: 'tabs', tabs }
|
|
370
|
+
}
|
|
371
|
+
case 'plot': {
|
|
372
|
+
const series = repairPlotSeries(v.series, GENUI_LIMITS.maxPlotSeries)
|
|
373
|
+
if (series === undefined) return null
|
|
374
|
+
return {
|
|
375
|
+
type: 'plot', series,
|
|
376
|
+
...opt('xMin', num(v.xMin, -1e6, 1e6)),
|
|
377
|
+
...opt('xMax', num(v.xMax, -1e6, 1e6)),
|
|
378
|
+
...opt('yMin', num(v.yMin, -1e9, 1e9)),
|
|
379
|
+
...opt('yMax', num(v.yMax, -1e9, 1e9)),
|
|
380
|
+
...opt('title', str(v.title, GENUI_LIMITS.maxString)),
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
case 'callout': {
|
|
384
|
+
const content = str(v.content, GENUI_LIMITS.maxString)
|
|
385
|
+
if (content === undefined) return null
|
|
386
|
+
return { type: 'callout', content, ...opt('tone', enu(v.tone, CALLOUT_TONES)), ...opt('title', str(v.title, GENUI_LIMITS.maxString)) }
|
|
387
|
+
}
|
|
388
|
+
case 'steps': {
|
|
389
|
+
const steps = repairSteps(v.steps)
|
|
390
|
+
if (steps === undefined) return null
|
|
391
|
+
return { type: 'steps', steps, ...opt('current', int(v.current, 0, steps.length)) }
|
|
392
|
+
}
|
|
393
|
+
case 'keyvalue': {
|
|
394
|
+
const pairs = repairPairs(v.pairs, GENUI_LIMITS.maxKeyValuePairs)
|
|
395
|
+
if (pairs === undefined) return null
|
|
396
|
+
return { type: 'keyvalue', pairs }
|
|
397
|
+
}
|
|
398
|
+
case 'diff': {
|
|
399
|
+
const diffs = repairDiffs(v.diffs)
|
|
400
|
+
if (diffs === undefined) return null
|
|
401
|
+
return { type: 'diff', diffs }
|
|
402
|
+
}
|
|
403
|
+
case 'json': {
|
|
404
|
+
// Any JSON value is acceptable; only the node itself is validated.
|
|
405
|
+
if (!('value' in v)) return null
|
|
406
|
+
return { type: 'json', value: v.value }
|
|
407
|
+
}
|
|
408
|
+
case 'code': {
|
|
409
|
+
const code = str(v.code, GENUI_LIMITS.maxCode)
|
|
410
|
+
if (code === undefined) return null
|
|
411
|
+
return { type: 'code', code, ...opt('lang', str(v.lang, 64)) }
|
|
412
|
+
}
|
|
413
|
+
case 'radio': {
|
|
414
|
+
const options = repairStrings(v.options, GENUI_LIMITS.maxOptions, GENUI_LIMITS.maxString)
|
|
415
|
+
if (options === undefined) return null
|
|
416
|
+
return {
|
|
417
|
+
type: 'radio', options,
|
|
418
|
+
...opt('label', str(v.label, GENUI_LIMITS.maxString)),
|
|
419
|
+
...opt('selected', int(v.selected, 0, options.length - 1)),
|
|
420
|
+
...opt('action', str(v.action, 200)),
|
|
421
|
+
...opt('group', str(v.group, 200)),
|
|
422
|
+
// answer: option index (number) or label (string); out-of-range
|
|
423
|
+
// indices are DROPPED (clamping would silently grade against the
|
|
424
|
+
// wrong option)
|
|
425
|
+
...opt('answer', typeof v.answer === 'number' && Number.isFinite(v.answer)
|
|
426
|
+
&& v.answer >= 0 && v.answer < options.length
|
|
427
|
+
? Math.trunc(v.answer)
|
|
428
|
+
: typeof v.answer === 'string' ? v.answer.slice(0, 512) : undefined),
|
|
429
|
+
...opt('explanation', str(v.explanation, GENUI_LIMITS.maxString)),
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
case 'submit': {
|
|
433
|
+
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
434
|
+
// action is OPTIONAL: local grading (any question carries `answer`)
|
|
435
|
+
// needs no round trip, so a submit without an action is valid. It only
|
|
436
|
+
// becomes semantically required when no local answers exist — the
|
|
437
|
+
// renderer disables the button then (honest affordance).
|
|
438
|
+
const action = str(v.action, 200)
|
|
439
|
+
if (label === undefined) return null
|
|
440
|
+
return {
|
|
441
|
+
type: 'submit', label,
|
|
442
|
+
...opt('action', action),
|
|
443
|
+
...opt('resetAction', str(v.resetAction, 200)),
|
|
444
|
+
...opt('groups', repairStrings(v.groups, GENUI_LIMITS.maxOptions, 200)),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
case 'switch': {
|
|
448
|
+
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
449
|
+
if (label === undefined) return null
|
|
450
|
+
return { type: 'switch', label, ...opt('checked', v.checked === true ? true : undefined), ...opt('action', str(v.action, 200)) }
|
|
451
|
+
}
|
|
452
|
+
case 'slider': {
|
|
453
|
+
const min = num(v.min, -1e9, 1e9) ?? 0
|
|
454
|
+
const max = num(v.max, -1e9, 1e9) ?? 100
|
|
455
|
+
const lo = Math.min(min, max)
|
|
456
|
+
const hi = Math.max(min, max)
|
|
457
|
+
const step = num(v.step, 1e-9, Math.max(hi - lo, 1e-9))
|
|
458
|
+
const value = num(v.value, lo, hi) ?? lo
|
|
459
|
+
return {
|
|
460
|
+
type: 'slider',
|
|
461
|
+
min: lo,
|
|
462
|
+
max: hi,
|
|
463
|
+
...opt('step', step),
|
|
464
|
+
value,
|
|
465
|
+
...opt('label', str(v.label, GENUI_LIMITS.maxString)),
|
|
466
|
+
...opt('action', str(v.action, 200)),
|
|
467
|
+
...opt('id', str(v.id, 200)),
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
case 'textarea': {
|
|
471
|
+
return {
|
|
472
|
+
type: 'textarea',
|
|
473
|
+
...opt('label', str(v.label, GENUI_LIMITS.maxString)),
|
|
474
|
+
...opt('placeholder', str(v.placeholder, GENUI_LIMITS.maxString)),
|
|
475
|
+
...opt('rows', int(v.rows, 1, 30)),
|
|
476
|
+
...opt('value', str(v.value, GENUI_LIMITS.maxString)),
|
|
477
|
+
...opt('action', str(v.action, 200)),
|
|
478
|
+
...opt('id', str(v.id, 200)),
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
case 'accordion': {
|
|
482
|
+
const items = repairAccordion(v.items, ctx, depth)
|
|
483
|
+
if (items === undefined) return null
|
|
484
|
+
return { type: 'accordion', items }
|
|
485
|
+
}
|
|
486
|
+
case 'copy': {
|
|
487
|
+
const text = str(v.text, GENUI_LIMITS.maxCode)
|
|
488
|
+
if (text === undefined) return null
|
|
489
|
+
return { type: 'copy', text, ...opt('label', str(v.label, 128)) }
|
|
490
|
+
}
|
|
491
|
+
case 'mermaid': {
|
|
492
|
+
const code = str(v.code, GENUI_LIMITS.maxMermaid)
|
|
493
|
+
if (code === undefined) return null
|
|
494
|
+
return { type: 'mermaid', code }
|
|
495
|
+
}
|
|
496
|
+
case 'scene3d': {
|
|
497
|
+
const meshes = repairMeshes(v.meshes)
|
|
498
|
+
if (meshes === undefined) return null
|
|
499
|
+
return { type: 'scene3d', meshes, ...opt('title', str(v.title, GENUI_LIMITS.maxString)), ...opt('ambient', num(v.ambient, 0, 2)), ...opt('background', color(v.background)) }
|
|
500
|
+
}
|
|
501
|
+
case 'diagram': {
|
|
502
|
+
const repaired = repairDiagram(v)
|
|
503
|
+
return repaired
|
|
504
|
+
}
|
|
505
|
+
case 'timeline': {
|
|
506
|
+
const items = repairTimeline(v.items, GENUI_LIMITS.maxTimelineItems)
|
|
507
|
+
if (items === undefined) return null
|
|
508
|
+
return { type: 'timeline', items }
|
|
509
|
+
}
|
|
510
|
+
case 'file-tree': {
|
|
511
|
+
const items = repairTree(v.items, GENUI_LIMITS.maxListItems)
|
|
512
|
+
if (items === undefined) return null
|
|
513
|
+
return { type: 'file-tree', items }
|
|
514
|
+
}
|
|
515
|
+
case 'breadcrumb': {
|
|
516
|
+
const items = repairStrings(v.items, GENUI_LIMITS.maxBreadcrumbItems, GENUI_LIMITS.maxString)
|
|
517
|
+
if (items === undefined) return null
|
|
518
|
+
return { type: 'breadcrumb', items }
|
|
519
|
+
}
|
|
520
|
+
case 'quiz': {
|
|
521
|
+
const question = str(v.question, GENUI_LIMITS.maxString)
|
|
522
|
+
const options = repairQuizOptions(v.options)
|
|
523
|
+
if (question === undefined || options === undefined) return null
|
|
524
|
+
return {
|
|
525
|
+
type: 'quiz', question, options,
|
|
526
|
+
...opt('explanation', str(v.explanation, GENUI_LIMITS.maxString)),
|
|
527
|
+
...opt('id', str(v.id, 200)),
|
|
528
|
+
...opt('action', str(v.action, 200)),
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
case 'echart': {
|
|
532
|
+
// Preset shorthand data/series reuse the chart repair helpers.
|
|
533
|
+
const data = v.data !== undefined ? repairChartData(v.data, GENUI_LIMITS.maxChartPoints) : undefined
|
|
534
|
+
const series = v.series !== undefined && Array.isArray(v.series)
|
|
535
|
+
? repairSeries(v.series, GENUI_LIMITS.maxPlotSeries, GENUI_LIMITS.maxChartPoints)
|
|
536
|
+
: undefined
|
|
537
|
+
// Full option: depth-bounded pass-through (the model writes the ECharts
|
|
538
|
+
// option object; the guard walks it to cap nesting but does not
|
|
539
|
+
// validate ECharts semantics — that is echarts' own job).
|
|
540
|
+
const sanitized = v.option !== undefined
|
|
541
|
+
? sanitizeEChartOption(v.option, 0, { count: GENUI_LIMITS.maxEChartOptionNodes })
|
|
542
|
+
: undefined
|
|
543
|
+
// A chart option root is always a plain object; a scalar root is
|
|
544
|
+
// invalid, so degrade to preset/data/series handling (option dropped).
|
|
545
|
+
const option: Record<string, unknown> | undefined =
|
|
546
|
+
sanitized === undefined || typeof sanitized !== 'object' || sanitized === null || Array.isArray(sanitized)
|
|
547
|
+
? undefined
|
|
548
|
+
: sanitized as Record<string, unknown>
|
|
549
|
+
// At least one of preset+data or option must be present.
|
|
550
|
+
if (option === undefined && data === undefined && series === undefined) return null
|
|
551
|
+
return {
|
|
552
|
+
type: 'echart',
|
|
553
|
+
...opt('title', str(v.title, GENUI_LIMITS.maxString)),
|
|
554
|
+
...opt('height', int(v.height, 100, 800)),
|
|
555
|
+
...opt('preset', enu(v.preset, ECHART_PRESETS)),
|
|
556
|
+
...opt('data', data),
|
|
557
|
+
...opt('series', series),
|
|
558
|
+
...opt('option', option),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
default:
|
|
562
|
+
// Plugin-registered custom node types are opaque to the guard: pass
|
|
563
|
+
// through unchanged (the renderer's default branch resolves them).
|
|
564
|
+
return value as GenuiNode
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* ---------------- per-type sub-repairers ---------------- */
|
|
569
|
+
|
|
570
|
+
function repairStrings(v: unknown, cap: number, strCap: number): string[] | undefined {
|
|
571
|
+
if (!Array.isArray(v)) return undefined
|
|
572
|
+
const out: string[] = []
|
|
573
|
+
for (const item of v) {
|
|
574
|
+
if (out.length >= cap) break
|
|
575
|
+
if (typeof item === 'string') {
|
|
576
|
+
out.push(item.slice(0, strCap))
|
|
577
|
+
} else if (item !== null && typeof item === 'object') {
|
|
578
|
+
// 兼容模型误用对象数组(如把 ask_user_question 的 {label,description}
|
|
579
|
+
// 格式错用到 select/radio 的 options)——提取可读字段,而不是静默丢
|
|
580
|
+
// 掉整个选项,让用户看到「选项没列举出来」的空列表。
|
|
581
|
+
const o = item as Record<string, unknown>
|
|
582
|
+
const s = typeof o.label === 'string' ? o.label
|
|
583
|
+
: typeof o.value === 'string' ? o.value
|
|
584
|
+
: typeof o.title === 'string' ? o.title
|
|
585
|
+
: JSON.stringify(item)
|
|
586
|
+
out.push(s.slice(0, strCap))
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return out
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function repairListItems(
|
|
593
|
+
v: unknown,
|
|
594
|
+
cap: number,
|
|
595
|
+
ctx: RepairCtx,
|
|
596
|
+
depth: number,
|
|
597
|
+
): GenuiList['items'] | undefined {
|
|
598
|
+
if (!Array.isArray(v)) return undefined
|
|
599
|
+
const out: GenuiList['items'] = []
|
|
600
|
+
for (const item of v) {
|
|
601
|
+
if (out.length >= cap) break
|
|
602
|
+
if (typeof item === 'string') {
|
|
603
|
+
out.push(item.slice(0, GENUI_LIMITS.maxString))
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
const o = obj(item)
|
|
607
|
+
const title = o === undefined ? undefined : str(o.title, GENUI_LIMITS.maxString)
|
|
608
|
+
if (title !== undefined) {
|
|
609
|
+
out.push({ title, ...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString)) })
|
|
610
|
+
continue
|
|
611
|
+
}
|
|
612
|
+
if (o !== undefined && typeof o.type === 'string') {
|
|
613
|
+
// Typed children are GenuiNodes: charge them against the shared node
|
|
614
|
+
// budget (module header promise — exhausted budget elides remaining
|
|
615
|
+
// siblings). Strings and {title,desc} objects are list-item shapes,
|
|
616
|
+
// not nodes, so they never consume budget.
|
|
617
|
+
if (ctx.remaining <= 0) break
|
|
618
|
+
ctx.remaining -= 1
|
|
619
|
+
const node = repairNode(o, ctx, depth)
|
|
620
|
+
if (node !== null) out.push(node)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return out
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function repairRows(v: unknown, rowCap: number, colCap: number): Array<Array<string | number>> | undefined {
|
|
627
|
+
if (!Array.isArray(v)) return undefined
|
|
628
|
+
const out: Array<Array<string | number>> = []
|
|
629
|
+
for (const row of v) {
|
|
630
|
+
if (out.length >= rowCap) break
|
|
631
|
+
if (!Array.isArray(row)) continue
|
|
632
|
+
const cells: Array<string | number> = []
|
|
633
|
+
for (const cell of row) {
|
|
634
|
+
if (cells.length >= colCap) break
|
|
635
|
+
if (typeof cell === 'string') cells.push(cell.slice(0, 256))
|
|
636
|
+
else if (typeof cell === 'number' && Number.isFinite(cell)) cells.push(cell)
|
|
637
|
+
}
|
|
638
|
+
if (cells.length > 0) out.push(cells)
|
|
639
|
+
}
|
|
640
|
+
return out
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function repairChartData(v: unknown, cap: number): Array<{ label: string; value: number; color?: string }> | undefined {
|
|
644
|
+
if (!Array.isArray(v)) return undefined
|
|
645
|
+
const out: Array<{ label: string; value: number; color?: string }> = []
|
|
646
|
+
for (const datum of v) {
|
|
647
|
+
if (out.length >= cap) break
|
|
648
|
+
const o = obj(datum)
|
|
649
|
+
const label = o === undefined ? undefined : str(o.label, 128)
|
|
650
|
+
const value = o === undefined ? undefined : num(o.value, -1e12, 1e12)
|
|
651
|
+
if (label === undefined || value === undefined) continue
|
|
652
|
+
out.push({ label, value, ...opt('color', o === undefined ? undefined : color(o.color)) })
|
|
653
|
+
}
|
|
654
|
+
return out
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function repairSeries(v: unknown, cap: number, pointCap: number): Array<{ label: string; color?: string; data: Array<{ label: string; value: number; color?: string }> }> | undefined {
|
|
658
|
+
if (!Array.isArray(v)) return undefined
|
|
659
|
+
const out: Array<{ label: string; color?: string; data: Array<{ label: string; value: number; color?: string }> }> = []
|
|
660
|
+
for (const s of v) {
|
|
661
|
+
if (out.length >= cap) break
|
|
662
|
+
const o = obj(s)
|
|
663
|
+
const label = o === undefined ? undefined : str(o.label, 128)
|
|
664
|
+
const data = o === undefined ? undefined : repairChartData(o.data, pointCap)
|
|
665
|
+
if (label === undefined || data === undefined) continue
|
|
666
|
+
out.push({ label, data, ...opt('color', o === undefined ? undefined : color(o.color)) })
|
|
667
|
+
}
|
|
668
|
+
return out
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function repairTabs(v: unknown, ctx: RepairCtx, depth: number): Array<{ label: string; items: GenuiNode[] }> | undefined {
|
|
672
|
+
if (!Array.isArray(v)) return undefined
|
|
673
|
+
const out: Array<{ label: string; items: GenuiNode[] }> = []
|
|
674
|
+
for (const tab of v) {
|
|
675
|
+
if (out.length >= GENUI_LIMITS.maxTabs) break
|
|
676
|
+
const o = obj(tab)
|
|
677
|
+
const label = o === undefined ? undefined : str(o.label, 128)
|
|
678
|
+
if (label === undefined || o === undefined) continue
|
|
679
|
+
// `content` is accepted as an `items` alias (single component or array) —
|
|
680
|
+
// models routinely emit tabs[].content and losing it empties every tab.
|
|
681
|
+
const rawItems = o.items !== undefined ? o.items
|
|
682
|
+
: o.content !== undefined ? (Array.isArray(o.content) ? o.content : [o.content])
|
|
683
|
+
: undefined
|
|
684
|
+
out.push({ label, items: repairItems(rawItems, ctx, depth + 1) })
|
|
685
|
+
}
|
|
686
|
+
return out
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Header text for an object-shaped table column ({title,key} antd style). */
|
|
690
|
+
function columnHeaderText(c: unknown): string {
|
|
691
|
+
const o = obj(c)
|
|
692
|
+
if (o === undefined) return String(c)
|
|
693
|
+
for (const k of ['title', 'label', 'key', 'dataIndex'] as const) {
|
|
694
|
+
const s = o[k]
|
|
695
|
+
if (typeof s === 'string' && s !== '') return s
|
|
696
|
+
}
|
|
697
|
+
return JSON.stringify(c)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Row key for an object-shaped column, mirroring columnHeaderText's order. */
|
|
701
|
+
function columnKeyOf(c: unknown): string | undefined {
|
|
702
|
+
const o = obj(c)
|
|
703
|
+
if (o === undefined) return undefined
|
|
704
|
+
for (const k of ['key', 'dataIndex', 'title', 'label'] as const) {
|
|
705
|
+
const s = o[k]
|
|
706
|
+
if (typeof s === 'string' && s !== '') return s
|
|
707
|
+
}
|
|
708
|
+
return undefined
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Cell text for object-array rows: strings/finite numbers pass through,
|
|
712
|
+
* everything else stringifies so the column alignment is preserved
|
|
713
|
+
* (repairRows would drop null/undefined cells and shift the row). */
|
|
714
|
+
function cellText(v: unknown): string | number {
|
|
715
|
+
if (typeof v === 'string') return v
|
|
716
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
717
|
+
if (v === null || v === undefined) return ''
|
|
718
|
+
return JSON.stringify(v)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function repairPlotSeries(v: unknown, cap: number): GenuiPlot['series'] | undefined {
|
|
722
|
+
if (!Array.isArray(v)) return undefined
|
|
723
|
+
const out: GenuiPlot['series'] = []
|
|
724
|
+
for (const s of v) {
|
|
725
|
+
if (out.length >= cap) break
|
|
726
|
+
const o = obj(s)
|
|
727
|
+
const expr = o === undefined ? undefined : str(o.expr, 512)
|
|
728
|
+
if (expr === undefined || o === undefined) continue
|
|
729
|
+
const params: NonNullable<GenuiPlotSeries['params']> = []
|
|
730
|
+
if (Array.isArray(o.params)) {
|
|
731
|
+
for (const p of o.params) {
|
|
732
|
+
if (params.length >= GENUI_LIMITS.maxPlotParams) break
|
|
733
|
+
const po = obj(p)
|
|
734
|
+
const name = po === undefined ? undefined : str(po.name, 64)
|
|
735
|
+
const value = po === undefined ? undefined : num(po.value, -1e9, 1e9)
|
|
736
|
+
if (name === undefined || value === undefined) continue
|
|
737
|
+
params.push({
|
|
738
|
+
name, value,
|
|
739
|
+
...opt('min', po === undefined ? undefined : num(po.min, -1e9, 1e9)),
|
|
740
|
+
...opt('max', po === undefined ? undefined : num(po.max, -1e9, 1e9)),
|
|
741
|
+
...opt('step', po === undefined ? undefined : num(po.step, 1e-9, 1e9)),
|
|
742
|
+
...opt('animateTo', po === undefined ? undefined : num(po.animateTo, -1e9, 1e9)),
|
|
743
|
+
...opt('durationMs', po === undefined ? undefined : num(po.durationMs, 1, 120_000)),
|
|
744
|
+
...opt('loop', po === undefined ? undefined : po.loop === true ? true : undefined),
|
|
745
|
+
})
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
out.push({ expr, ...opt('label', str(o.label, 128)), ...opt('color', color(o.color)), ...opt('kind', enu(o.kind, PLOT_KINDS)), ...opt('params', params.length > 0 ? params : undefined) })
|
|
749
|
+
}
|
|
750
|
+
return out
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function repairSteps(v: unknown): Array<{ title: string; desc?: string }> | undefined {
|
|
754
|
+
if (!Array.isArray(v)) return undefined
|
|
755
|
+
const out: Array<{ title: string; desc?: string }> = []
|
|
756
|
+
for (const s of v) {
|
|
757
|
+
if (out.length >= GENUI_LIMITS.maxSteps) break
|
|
758
|
+
const o = obj(s)
|
|
759
|
+
const title = o === undefined ? undefined : str(o.title, 256)
|
|
760
|
+
if (title === undefined) continue
|
|
761
|
+
out.push({ title, ...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString)) })
|
|
762
|
+
}
|
|
763
|
+
return out
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function repairPairs(v: unknown, cap: number): Array<{ key: string; value: string }> | undefined {
|
|
767
|
+
if (!Array.isArray(v)) return undefined
|
|
768
|
+
const out: Array<{ key: string; value: string }> = []
|
|
769
|
+
for (const p of v) {
|
|
770
|
+
if (out.length >= cap) break
|
|
771
|
+
const o = obj(p)
|
|
772
|
+
const key = o === undefined ? undefined : str(o.key, 256)
|
|
773
|
+
const value = o === undefined ? undefined : str(o.value, GENUI_LIMITS.maxString)
|
|
774
|
+
if (key === undefined || value === undefined) continue
|
|
775
|
+
out.push({ key, value })
|
|
776
|
+
}
|
|
777
|
+
return out
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function repairDiffs(v: unknown): Array<{ path: string; oldText: string | null; newText: string }> | undefined {
|
|
781
|
+
if (!Array.isArray(v)) return undefined
|
|
782
|
+
const out: Array<{ path: string; oldText: string | null; newText: string }> = []
|
|
783
|
+
for (const d of v) {
|
|
784
|
+
if (out.length >= 24) break
|
|
785
|
+
const o = obj(d)
|
|
786
|
+
const path = o === undefined ? undefined : str(o.path, 1024)
|
|
787
|
+
const newText = o === undefined ? undefined : str(o.newText, 20_000)
|
|
788
|
+
if (path === undefined || newText === undefined) continue
|
|
789
|
+
const old = o === undefined ? undefined : o.oldText
|
|
790
|
+
out.push({ path, newText, oldText: old === null || typeof old !== 'string' ? null : old.slice(0, 20_000) })
|
|
791
|
+
}
|
|
792
|
+
return out
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function repairAccordion(v: unknown, ctx: RepairCtx, depth: number): Array<{ title: string; items: GenuiNode[] }> | undefined {
|
|
796
|
+
if (!Array.isArray(v)) return undefined
|
|
797
|
+
const out: Array<{ title: string; items: GenuiNode[] }> = []
|
|
798
|
+
for (const item of v) {
|
|
799
|
+
if (out.length >= GENUI_LIMITS.maxAccordionItems) break
|
|
800
|
+
const o = obj(item)
|
|
801
|
+
const title = o === undefined ? undefined : str(o.title, 256)
|
|
802
|
+
if (title === undefined || o === undefined) continue
|
|
803
|
+
out.push({ title, items: repairItems(o.items, ctx, depth + 1) })
|
|
804
|
+
}
|
|
805
|
+
return out
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function repairMeshes(v: unknown): GenuiScene3D['meshes'] | undefined {
|
|
809
|
+
if (!Array.isArray(v)) return undefined
|
|
810
|
+
const out: GenuiScene3D['meshes'] = []
|
|
811
|
+
for (const m of v) {
|
|
812
|
+
if (out.length >= GENUI_LIMITS.maxMeshes) break
|
|
813
|
+
const o = obj(m)
|
|
814
|
+
const shape = o === undefined ? undefined : enu(o.shape, MESH_SHAPES)
|
|
815
|
+
if (shape === undefined) continue
|
|
816
|
+
const scale = o === undefined ? undefined : num(o.scale, -1e6, 1e6) ?? tuple3(o.scale)
|
|
817
|
+
const size = o === undefined ? undefined : num(o.size, -1e6, 1e6) ?? tuple3(o.size)
|
|
818
|
+
out.push({
|
|
819
|
+
shape,
|
|
820
|
+
...opt('color', o === undefined ? undefined : color(o.color)),
|
|
821
|
+
...opt('position', o === undefined ? undefined : tuple3(o.position)),
|
|
822
|
+
...opt('rotation', o === undefined ? undefined : tuple3(o.rotation)),
|
|
823
|
+
...opt('scale', scale),
|
|
824
|
+
...opt('size', size),
|
|
825
|
+
})
|
|
826
|
+
}
|
|
827
|
+
return out
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function tuple3(v: unknown): [number, number, number] | undefined {
|
|
831
|
+
if (!Array.isArray(v) || v.length !== 3) return undefined
|
|
832
|
+
const [a, b, c] = v
|
|
833
|
+
if (typeof a !== 'number' || !Number.isFinite(a) || typeof b !== 'number' || !Number.isFinite(b)
|
|
834
|
+
|| typeof c !== 'number' || !Number.isFinite(c)) return undefined
|
|
835
|
+
return [Math.min(1e6, Math.max(-1e6, a)), Math.min(1e6, Math.max(-1e6, b)), Math.min(1e6, Math.max(-1e6, c))]
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/* ---------------- diagram (editorial) sub-repairers ---------------- */
|
|
839
|
+
|
|
840
|
+
/** Clamp a coordinate/size to the 4px editorial grid. */
|
|
841
|
+
function grid4(v: number, min: number, max: number): number {
|
|
842
|
+
return Math.min(max, Math.max(min, Math.round(v / 4) * 4))
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function repairDiagramNodes(v: unknown): GenuiDiagram['nodes'] | undefined {
|
|
846
|
+
if (!Array.isArray(v)) return undefined
|
|
847
|
+
const out: GenuiDiagram['nodes'] = []
|
|
848
|
+
const seen = new Set<string>()
|
|
849
|
+
for (const raw of v) {
|
|
850
|
+
if (out.length >= GENUI_LIMITS.maxDiagramNodes) break
|
|
851
|
+
const o = obj(raw)
|
|
852
|
+
if (o === undefined) continue
|
|
853
|
+
const id = str(o.id, 128)
|
|
854
|
+
const label = str(o.label, GENUI_LIMITS.maxString)
|
|
855
|
+
if (id === undefined || label === undefined) continue
|
|
856
|
+
if (seen.has(id)) continue
|
|
857
|
+
seen.add(id)
|
|
858
|
+
const nodeType = enu(o.type, DIAGRAM_NODE_TYPES)
|
|
859
|
+
// Coordinate fields are clamped to a sane canvas and rounded to 4px.
|
|
860
|
+
const x = o.x === undefined ? undefined : grid4(num(o.x, -1e6, 1e6) ?? 0, 0, 1e6)
|
|
861
|
+
const y = o.y === undefined ? undefined : grid4(num(o.y, -1e6, 1e6) ?? 0, 0, 1e6)
|
|
862
|
+
const w = o.w === undefined ? undefined : grid4(num(o.w, -1e6, 1e6) ?? 96, 40, 2000)
|
|
863
|
+
const h = o.h === undefined ? undefined : grid4(num(o.h, -1e6, 1e6) ?? 48, 24, 1200)
|
|
864
|
+
out.push({
|
|
865
|
+
id, label,
|
|
866
|
+
...opt('sub', str(o.sub, 256)),
|
|
867
|
+
...opt('type', nodeType),
|
|
868
|
+
...opt('x', x),
|
|
869
|
+
...opt('y', y),
|
|
870
|
+
...opt('w', w),
|
|
871
|
+
...opt('h', h),
|
|
872
|
+
...opt('tag', str(o.tag, 32)),
|
|
873
|
+
})
|
|
874
|
+
}
|
|
875
|
+
return out
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function repairDiagramEdges(v: unknown): GenuiDiagram['edges'] | undefined {
|
|
879
|
+
if (v === undefined) return []
|
|
880
|
+
if (!Array.isArray(v)) return undefined
|
|
881
|
+
const out: GenuiDiagram['edges'] = []
|
|
882
|
+
for (const raw of v) {
|
|
883
|
+
if (out.length >= GENUI_LIMITS.maxDiagramEdges) break
|
|
884
|
+
const o = obj(raw)
|
|
885
|
+
if (o === undefined) continue
|
|
886
|
+
const from = str(o.from, 128)
|
|
887
|
+
const to = str(o.to, 128)
|
|
888
|
+
if (from === undefined || to === undefined) continue
|
|
889
|
+
out.push({
|
|
890
|
+
from, to,
|
|
891
|
+
...opt('label', str(o.label, GENUI_LIMITS.maxDiagramLabel)),
|
|
892
|
+
...opt('kind', enu(o.kind, DIAGRAM_EDGE_KINDS)),
|
|
893
|
+
...opt('route', enu(o.route, DIAGRAM_ROUTES)),
|
|
894
|
+
})
|
|
895
|
+
}
|
|
896
|
+
return out
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function repairDiagramTheme(v: unknown): GenuiDiagramTheme | undefined {
|
|
900
|
+
const o = obj(v)
|
|
901
|
+
if (o === undefined) return undefined
|
|
902
|
+
const out: GenuiDiagramTheme = {}
|
|
903
|
+
for (const key of ['paper', 'paper-2', 'ink', 'muted', 'soft', 'rule', 'accent', 'accent-tint', 'link'] as const) {
|
|
904
|
+
const c = color(o[key])
|
|
905
|
+
if (c !== undefined) out[key] = c
|
|
906
|
+
}
|
|
907
|
+
return Object.keys(out).length === 0 ? undefined : out
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function repairDiagramZones(v: unknown): GenuiDiagram['zones'] | undefined {
|
|
911
|
+
if (v === undefined) return []
|
|
912
|
+
if (!Array.isArray(v)) return undefined
|
|
913
|
+
const out: GenuiDiagram['zones'] = []
|
|
914
|
+
for (const raw of v) {
|
|
915
|
+
if (out.length >= GENUI_LIMITS.maxDiagramZones) break
|
|
916
|
+
const o = obj(raw)
|
|
917
|
+
if (o === undefined) continue
|
|
918
|
+
const label = str(o.label, 64)
|
|
919
|
+
if (label === undefined) continue
|
|
920
|
+
out.push({
|
|
921
|
+
label,
|
|
922
|
+
...opt('x', o.x === undefined ? undefined : grid4(num(o.x, -1e6, 1e6) ?? 0, 0, 1e6)),
|
|
923
|
+
...opt('y', o.y === undefined ? undefined : grid4(num(o.y, -1e6, 1e6) ?? 0, 0, 1e6)),
|
|
924
|
+
...opt('w', o.w === undefined ? undefined : grid4(num(o.w, -1e6, 1e6) ?? 100, 40, 2000)),
|
|
925
|
+
...opt('h', o.h === undefined ? undefined : grid4(num(o.h, -1e6, 1e6) ?? 100, 40, 1200)),
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
return out
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function repairDiagram(v: unknown): GenuiDiagram | null {
|
|
932
|
+
const o = obj(v)
|
|
933
|
+
if (o === undefined) return null
|
|
934
|
+
const kind = enu(o.kind, DIAGRAM_KINDS as unknown as readonly GenuiDiagramKind[])
|
|
935
|
+
if (kind === undefined) return null
|
|
936
|
+
const nodes = repairDiagramNodes(o.nodes)
|
|
937
|
+
if (nodes === undefined) return null
|
|
938
|
+
const edges = repairDiagramEdges(o.edges)
|
|
939
|
+
if (edges === undefined) return null
|
|
940
|
+
const zones = repairDiagramZones(o.zones)
|
|
941
|
+
if (zones === undefined) return null
|
|
942
|
+
return {
|
|
943
|
+
type: 'diagram', kind, nodes, edges, zones,
|
|
944
|
+
...opt('variant', enu(o.variant, DIAGRAM_VARIANTS)),
|
|
945
|
+
...opt('title', str(o.title, 256)),
|
|
946
|
+
...opt('theme', repairDiagramTheme(o.theme)),
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function repairTimeline(v: unknown, cap: number): Array<{ title: string; desc?: string; time?: string }> | undefined {
|
|
951
|
+
if (!Array.isArray(v)) return undefined
|
|
952
|
+
const out: Array<{ title: string; desc?: string; time?: string }> = []
|
|
953
|
+
for (const item of v) {
|
|
954
|
+
if (out.length >= cap) break
|
|
955
|
+
const o = obj(item)
|
|
956
|
+
const title = o === undefined ? undefined : str(o.title, 256)
|
|
957
|
+
if (title === undefined) continue
|
|
958
|
+
out.push({
|
|
959
|
+
title,
|
|
960
|
+
...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString)),
|
|
961
|
+
...opt('time', o === undefined ? undefined : str(o.time, 128)),
|
|
962
|
+
})
|
|
963
|
+
}
|
|
964
|
+
return out
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function repairTree(v: unknown, cap: number): GenuiFileTreeNode[] | undefined {
|
|
968
|
+
// Recursion is bounded by GENUI_LIMITS.maxTreeDepth (see the inner walk).
|
|
969
|
+
return walkTree(v, cap, GENUI_LIMITS.maxTreeDepth)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function walkTree(v: unknown, cap: number, depthLeft: number): GenuiFileTreeNode[] | undefined {
|
|
973
|
+
if (!Array.isArray(v)) return undefined
|
|
974
|
+
const out: GenuiFileTreeNode[] = []
|
|
975
|
+
for (const item of v) {
|
|
976
|
+
if (out.length >= cap) break
|
|
977
|
+
const o = obj(item)
|
|
978
|
+
const name = o === undefined ? undefined : str(o.name, 256)
|
|
979
|
+
if (name === undefined) continue
|
|
980
|
+
const children = o !== undefined && depthLeft > 0 && Array.isArray(o.children) ? walkTree(o.children, cap, depthLeft - 1) : undefined
|
|
981
|
+
out.push({ name, ...opt('type', o === undefined ? undefined : enu(o.type, FILE_TYPES)), ...opt('children', children) })
|
|
982
|
+
}
|
|
983
|
+
return out
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function repairQuizOptions(v: unknown): Array<{ label: string; correct?: boolean; feedback?: string }> | undefined {
|
|
987
|
+
if (!Array.isArray(v)) return undefined
|
|
988
|
+
const out: Array<{ label: string; correct?: boolean; feedback?: string }> = []
|
|
989
|
+
for (const optItem of v) {
|
|
990
|
+
if (out.length >= GENUI_LIMITS.maxQuizOptions) break
|
|
991
|
+
const o = obj(optItem)
|
|
992
|
+
const label = o === undefined ? undefined : str(o.label, 512)
|
|
993
|
+
if (label === undefined) continue
|
|
994
|
+
out.push({
|
|
995
|
+
label,
|
|
996
|
+
...opt('correct', o === undefined ? undefined : o.correct === true ? true : undefined),
|
|
997
|
+
...opt('feedback', o === undefined ? undefined : str(o.feedback, GENUI_LIMITS.maxString)),
|
|
998
|
+
})
|
|
999
|
+
}
|
|
1000
|
+
return out
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Patterns that indicate HTML/script injection in a string field. ECharts
|
|
1005
|
+
* default `tooltip.renderMode: 'html'` writes tooltip content via
|
|
1006
|
+
* `innerHTML`; even with renderMode forced to 'richText' (see below),
|
|
1007
|
+
* filtering these patterns is defense-in-depth — a model (or a
|
|
1008
|
+
* prompt-injected model) should never emit `<script>`, `onerror=`, or
|
|
1009
|
+
* `javascript:` inside a chart option string.
|
|
1010
|
+
*/
|
|
1011
|
+
const ECHART_HTML_DANGER_RE = /<(?:script|img|svg|iframe|video|audio|object|embed|source)\b|on[a-z]+\s*=|javascript:/i
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Mutable budget counter for the sanitize walk — passed by reference so
|
|
1015
|
+
* every recursion shares one pool.
|
|
1016
|
+
*/
|
|
1017
|
+
interface EChartSanitizeBudget { count: number }
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Sanitize an ECharts option object: depth-bounded, budget-bounded
|
|
1021
|
+
* pass-through that strips dangerous values (functions, `url()` in styles,
|
|
1022
|
+
* HTML/script injection patterns in strings) but preserves the object shape
|
|
1023
|
+
* ECharts needs. Scalars are KEPT: ECharts options are full of them,
|
|
1024
|
+
* including inside `data` arrays (`data: [120, 150, 180]`,
|
|
1025
|
+
* `xAxis.data: ['1月', '2月']`). Previously a scalar hit the plain-object
|
|
1026
|
+
* gate below and returned undefined, so every primitive-valued array was
|
|
1027
|
+
* filtered to empty and dropped — a chart with a full `option` rendered
|
|
1028
|
+
* with empty series (blank canvas). This is a safety walk, not an ECharts
|
|
1029
|
+
* semantic validator.
|
|
1030
|
+
*
|
|
1031
|
+
* Security: `tooltip.renderMode` is forced to `'richText'` on every tooltip
|
|
1032
|
+
* object. ECharts' default `'html'` mode writes tooltip content via
|
|
1033
|
+
* `innerHTML`, which is an XSS vector when the option originates from model
|
|
1034
|
+
* output — a prompt-injected model could emit
|
|
1035
|
+
* `{"tooltip":{"formatter":"<img src=x onerror=...>"}}` and execute
|
|
1036
|
+
* arbitrary script. `richText` renders as text, never touching innerHTML.
|
|
1037
|
+
*/
|
|
1038
|
+
function sanitizeEChartOption(v: unknown, depth: number, budget: EChartSanitizeBudget): unknown {
|
|
1039
|
+
if (budget.count <= 0) return undefined
|
|
1040
|
+
budget.count -= 1
|
|
1041
|
+
if (depth > GENUI_LIMITS.maxEChartOptionDepth) return undefined
|
|
1042
|
+
// Scalars pass through: numbers/strings/booleans/null are legal ECharts
|
|
1043
|
+
// values both as object fields and as array elements.
|
|
1044
|
+
if (typeof v === 'string') {
|
|
1045
|
+
const s = v.slice(0, GENUI_LIMITS.maxString)
|
|
1046
|
+
// Reject strings containing HTML/script injection patterns or CSS url()
|
|
1047
|
+
// (exfiltration channel). Preserves legitimate ECharts string values
|
|
1048
|
+
// (labels, plain-text formatter templates, etc.).
|
|
1049
|
+
if (s.toLowerCase().includes('url(') || ECHART_HTML_DANGER_RE.test(s)) return undefined
|
|
1050
|
+
return s
|
|
1051
|
+
}
|
|
1052
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
1053
|
+
if (typeof v === 'boolean') return v
|
|
1054
|
+
if (v === null) return null
|
|
1055
|
+
if (Array.isArray(v)) {
|
|
1056
|
+
const cap = Math.min(v.length, GENUI_LIMITS.maxEChartArrayLen)
|
|
1057
|
+
const arr: unknown[] = []
|
|
1058
|
+
for (let i = 0; i < cap; i++) {
|
|
1059
|
+
const s = sanitizeEChartOption(v[i], depth + 1, budget)
|
|
1060
|
+
if (s !== undefined) arr.push(s)
|
|
1061
|
+
}
|
|
1062
|
+
return arr.length > 0 ? arr : undefined
|
|
1063
|
+
}
|
|
1064
|
+
const o = obj(v)
|
|
1065
|
+
if (o === undefined) return undefined
|
|
1066
|
+
const out: Record<string, unknown> = {}
|
|
1067
|
+
for (const [key, val] of Object.entries(o)) {
|
|
1068
|
+
const s = sanitizeEChartOption(val, depth + 1, budget)
|
|
1069
|
+
if (s === undefined) continue
|
|
1070
|
+
// Force tooltip.renderMode: 'richText' to prevent ECharts from writing
|
|
1071
|
+
// tooltip content via innerHTML (the default 'html' mode is an XSS
|
|
1072
|
+
// vector when the option comes from model output).
|
|
1073
|
+
if (key === 'tooltip' && typeof s === 'object' && s !== null && !Array.isArray(s)) {
|
|
1074
|
+
(s as Record<string, unknown>).renderMode = 'richText'
|
|
1075
|
+
}
|
|
1076
|
+
out[key] = s
|
|
1077
|
+
}
|
|
1078
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Deterministically repair a raw spec value into a renderable GenuiSpec.
|
|
1083
|
+
* Returns null only when the root is not an object with an `items` array
|
|
1084
|
+
* (a bare component root is wrapped into a col first — the documented fence
|
|
1085
|
+
* vocabulary allows single-component bodies); every other defect is healed by
|
|
1086
|
+
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
|
|
1087
|
+
* no-op.
|
|
1088
|
+
*/
|
|
1089
|
+
export function repairGenuiSpec(value: unknown): GenuiSpec | null {
|
|
1090
|
+
const v = obj(value)
|
|
1091
|
+
if (v === undefined) return null
|
|
1092
|
+
if (!Array.isArray(v.items)) {
|
|
1093
|
+
const wrapped = wrapSingleComponentRoot(value)
|
|
1094
|
+
if (wrapped === null) return null
|
|
1095
|
+
return repairGenuiSpec(wrapped)
|
|
1096
|
+
}
|
|
1097
|
+
const ctx: RepairCtx = { remaining: GENUI_LIMITS.maxNodes }
|
|
1098
|
+
return {
|
|
1099
|
+
...opt('title', str(v.title, GENUI_LIMITS.maxString)),
|
|
1100
|
+
...opt('gap', num(v.gap, 0, 96)),
|
|
1101
|
+
...opt('panel', v.panel === true ? true : undefined),
|
|
1102
|
+
...opt('append', v.append === true ? true : undefined),
|
|
1103
|
+
items: repairItems(v.items, ctx, 0),
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/* ---------------- validation ---------------- */
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Count the nodes of a spec tree (every item, descending into tabs /
|
|
1111
|
+
* accordion / file-tree / list containers — the same descent
|
|
1112
|
+
* `validateGenuiSpec` walks). Shared by the panel fold (node-budget gate)
|
|
1113
|
+
* and validation, so the panel never runs a second, divergent traversal.
|
|
1114
|
+
* `cap` bounds the walk for hostile inputs; the panel passes
|
|
1115
|
+
* `PANEL_LIMITS.maxNodes + 1` to detect overflow without counting the whole
|
|
1116
|
+
* tree.
|
|
1117
|
+
*/
|
|
1118
|
+
export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
|
|
1119
|
+
let count = 0
|
|
1120
|
+
const walk = (list: unknown): void => {
|
|
1121
|
+
if (!Array.isArray(list)) return
|
|
1122
|
+
for (const item of list) {
|
|
1123
|
+
if (count >= cap) return
|
|
1124
|
+
count += 1
|
|
1125
|
+
const v = obj(item)
|
|
1126
|
+
if (v === undefined) continue
|
|
1127
|
+
if (v.type === 'tabs' && Array.isArray(v.tabs)) {
|
|
1128
|
+
for (const t of v.tabs) {
|
|
1129
|
+
if (count >= cap) return
|
|
1130
|
+
const to = obj(t)
|
|
1131
|
+
if (to !== undefined) walk(to.items)
|
|
1132
|
+
}
|
|
1133
|
+
} else if (v.type === 'accordion' && Array.isArray(v.items)) {
|
|
1134
|
+
for (const it of v.items) {
|
|
1135
|
+
if (count >= cap) return
|
|
1136
|
+
const io = obj(it)
|
|
1137
|
+
if (io !== undefined) walk(io.items)
|
|
1138
|
+
}
|
|
1139
|
+
} else if ((v.type === 'row' || v.type === 'col' || v.type === 'grid' || v.type === 'card') && Array.isArray(v.items)) {
|
|
1140
|
+
// Layout containers hold real children; skipping them undercounted
|
|
1141
|
+
// the tree and hid silent drops from validate_dsh_ui (issue #42).
|
|
1142
|
+
walk(v.items)
|
|
1143
|
+
} else if (v.type === 'file-tree' && Array.isArray(v.items)) {
|
|
1144
|
+
walk(v.items)
|
|
1145
|
+
} else if (v.type === 'list' && Array.isArray(v.items)) {
|
|
1146
|
+
// Typed list children are nodes too (repair charges them against the
|
|
1147
|
+
// budget); strings and {title,desc} shapes are skipped.
|
|
1148
|
+
for (const li of v.items) {
|
|
1149
|
+
if (count >= cap) return
|
|
1150
|
+
const lo = obj(li)
|
|
1151
|
+
if (lo !== undefined && typeof lo.type === 'string') walk([lo])
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
const root = obj(value)
|
|
1157
|
+
walk(root === undefined ? [] : root.items)
|
|
1158
|
+
return count
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Every white-listed node `type`. Keep in sync with the repairNode switch —
|
|
1162
|
+
* validate_dsh_ui uses it to tell declared GenUI nodes apart from unrelated
|
|
1163
|
+
* `"type"` strings (e.g. file-tree's `{type:'file'}` children). */
|
|
1164
|
+
export const GENUI_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
1165
|
+
'accordion', 'audio', 'avatar', 'badge', 'breadcrumb', 'button', 'callout', 'card', 'chart',
|
|
1166
|
+
'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'input', 'json',
|
|
1167
|
+
'keyvalue', 'link', 'list', 'mermaid', 'plot', 'progress', 'quiz', 'radio', 'row', 'scene3d',
|
|
1168
|
+
'select', 'slider', 'spacer', 'stat', 'steps', 'submit', 'switch', 'table', 'tabs', 'text',
|
|
1169
|
+
'textarea', 'timeline', 'video', 'echart', 'diagram',
|
|
1170
|
+
])
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Count DECLARED nodes in a raw spec tree: objects whose `type` is a
|
|
1174
|
+
* white-listed string, descending the same containers `countGenuiNodes`
|
|
1175
|
+
* walks. `validate_dsh_ui` compares this with the repaired count to surface
|
|
1176
|
+
* children the repair silently dropped (blank-render class of bugs, issue
|
|
1177
|
+
* #42) instead of reporting a green check on a half-empty tree.
|
|
1178
|
+
*/
|
|
1179
|
+
export function countDeclaredGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
|
|
1180
|
+
let count = 0
|
|
1181
|
+
const declared = (candidate: unknown): boolean => {
|
|
1182
|
+
const o = obj(candidate)
|
|
1183
|
+
return o !== undefined && typeof o.type === 'string' && GENUI_NODE_TYPES.has(o.type)
|
|
1184
|
+
}
|
|
1185
|
+
const walk = (list: unknown): void => {
|
|
1186
|
+
if (!Array.isArray(list)) return
|
|
1187
|
+
for (const item of list) {
|
|
1188
|
+
if (count >= cap) return
|
|
1189
|
+
if (!declared(item)) continue
|
|
1190
|
+
count += 1
|
|
1191
|
+
const v = obj(item)
|
|
1192
|
+
if (v === undefined) continue
|
|
1193
|
+
if (v.type === 'tabs' && Array.isArray(v.tabs)) {
|
|
1194
|
+
for (const t of v.tabs) walkItemsOf(t)
|
|
1195
|
+
} else if (v.type === 'accordion' && Array.isArray(v.items)) {
|
|
1196
|
+
for (const it of v.items) walkItemsOf(it)
|
|
1197
|
+
} else if ((v.type === 'row' || v.type === 'col' || v.type === 'grid' || v.type === 'card') && Array.isArray(v.items)) {
|
|
1198
|
+
walk(v.items)
|
|
1199
|
+
} else if (v.type === 'list' && Array.isArray(v.items)) {
|
|
1200
|
+
for (const li of v.items) {
|
|
1201
|
+
if (declared(li)) walk([li])
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const walkItemsOf = (holder: unknown): void => {
|
|
1207
|
+
const o = obj(holder)
|
|
1208
|
+
if (o === undefined) return
|
|
1209
|
+
const items = o.items !== undefined ? o.items : o.content
|
|
1210
|
+
if (Array.isArray(items)) walk(items)
|
|
1211
|
+
else if (declared(items)) walk([items])
|
|
1212
|
+
}
|
|
1213
|
+
const root = obj(value)
|
|
1214
|
+
if (root === undefined) return count
|
|
1215
|
+
// Single-component root (no items array): the root itself is the declared node.
|
|
1216
|
+
if (!Array.isArray(root.items) && declared(value)) walk([value])
|
|
1217
|
+
else walk(root.items)
|
|
1218
|
+
return count
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Validate a raw spec value against the white list and limits, collecting
|
|
1223
|
+
* human-readable problems. Unlike repair this never mutates: it is a
|
|
1224
|
+
* diagnostic for tests and tooling. Unknown `type`s are reported (a plugin
|
|
1225
|
+
* custom type is valid only when a renderer is registered — the guard cannot
|
|
1226
|
+
* know, so it flags them as warnings).
|
|
1227
|
+
*/
|
|
1228
|
+
export function validateGenuiSpec(value: unknown): GenuiValidation {
|
|
1229
|
+
const errors: string[] = []
|
|
1230
|
+
const v = obj(value)
|
|
1231
|
+
if (v === undefined) return { ok: false, errors: ['spec root must be an object'] }
|
|
1232
|
+
if (!Array.isArray(v.items)) {
|
|
1233
|
+
// Single-component root: validate through the wrapped form so the tool
|
|
1234
|
+
// agrees with the renderer about what is a valid fence body.
|
|
1235
|
+
const wrapped = wrapSingleComponentRoot(value)
|
|
1236
|
+
if (wrapped !== null) return validateGenuiSpec(wrapped)
|
|
1237
|
+
return { ok: false, errors: ['spec.items must be an array'] }
|
|
1238
|
+
}
|
|
1239
|
+
if (v.title !== undefined && typeof v.title !== 'string') errors.push('spec.title must be a string')
|
|
1240
|
+
if (v.gap !== undefined && (typeof v.gap !== 'number' || !Number.isFinite(v.gap))) errors.push('spec.gap must be a finite number')
|
|
1241
|
+
let count = 0
|
|
1242
|
+
let capped = false
|
|
1243
|
+
const walk = (list: unknown, depth: number, path: string): void => {
|
|
1244
|
+
if (capped) return
|
|
1245
|
+
if (!Array.isArray(list)) {
|
|
1246
|
+
errors.push(`${path} must be an array`)
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
1249
|
+
for (let i = 0; i < list.length; i++) {
|
|
1250
|
+
if (capped || count >= GENUI_LIMITS.maxNodes) {
|
|
1251
|
+
if (!capped) {
|
|
1252
|
+
errors.push(`spec exceeds ${GENUI_LIMITS.maxNodes} nodes; tail elided`)
|
|
1253
|
+
capped = true
|
|
1254
|
+
}
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
count += 1
|
|
1258
|
+
const at = `${path}[${i}]`
|
|
1259
|
+
validateNode(list[i], depth, at, errors, walk)
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
walk(v.items, 0, 'items')
|
|
1263
|
+
return { ok: errors.length === 0, errors }
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
type Walker = (list: unknown, depth: number, path: string) => void
|
|
1267
|
+
|
|
1268
|
+
function validateNode(value: unknown, depth: number, at: string, errors: string[], walk: Walker): void {
|
|
1269
|
+
if (depth > GENUI_LIMITS.maxDepth) {
|
|
1270
|
+
errors.push(`${at}: exceeds max depth ${GENUI_LIMITS.maxDepth}`)
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
1273
|
+
const v = obj(value)
|
|
1274
|
+
if (v === undefined) {
|
|
1275
|
+
errors.push(`${at}: must be an object`)
|
|
1276
|
+
return
|
|
1277
|
+
}
|
|
1278
|
+
const type = v.type
|
|
1279
|
+
if (typeof type !== 'string') {
|
|
1280
|
+
errors.push(`${at}: missing string 'type'`)
|
|
1281
|
+
return
|
|
1282
|
+
}
|
|
1283
|
+
const isStr = (name: string): void => { if (v[name] !== undefined && typeof v[name] !== 'string') errors.push(`${at}: '${name}' must be a string`) }
|
|
1284
|
+
const isNum = (name: string): void => { if (v[name] !== undefined && (typeof v[name] !== 'number' || !Number.isFinite(v[name]))) errors.push(`${at}: '${name}' must be a finite number`) }
|
|
1285
|
+
switch (type) {
|
|
1286
|
+
case 'text':
|
|
1287
|
+
if (typeof v.content !== 'string' && typeof v.text !== 'string') {
|
|
1288
|
+
errors.push(`${at}: type 'text' requires content or text (string)`)
|
|
1289
|
+
}
|
|
1290
|
+
isStr('content')
|
|
1291
|
+
isStr('text')
|
|
1292
|
+
break
|
|
1293
|
+
case 'row': case 'col': case 'card': case 'grid':
|
|
1294
|
+
if (!Array.isArray(v.items)) errors.push(`${at}: type '${type}' requires items (array)`)
|
|
1295
|
+
walk(v.items, depth + 1, `${at}.items`)
|
|
1296
|
+
if (type === 'grid') isNum('cols')
|
|
1297
|
+
break
|
|
1298
|
+
case 'button': case 'checkbox': case 'link': case 'switch':
|
|
1299
|
+
if (typeof v.label !== 'string') errors.push(`${at}: type '${type}' requires label (string)`)
|
|
1300
|
+
isStr('label')
|
|
1301
|
+
break
|
|
1302
|
+
case 'audio': case 'video':
|
|
1303
|
+
if (typeof v.src !== 'string') errors.push(`${at}: type '${type}' requires src (string)`)
|
|
1304
|
+
isStr('src')
|
|
1305
|
+
isStr('alt')
|
|
1306
|
+
if (type === 'video') isStr('poster')
|
|
1307
|
+
break
|
|
1308
|
+
case 'slider':
|
|
1309
|
+
isStr('label')
|
|
1310
|
+
isNum('min'); isNum('max'); isNum('step'); isNum('value')
|
|
1311
|
+
break
|
|
1312
|
+
case 'input': case 'textarea':
|
|
1313
|
+
isStr('label'); isStr('placeholder'); isStr('value')
|
|
1314
|
+
break
|
|
1315
|
+
case 'select': case 'radio':
|
|
1316
|
+
if (!Array.isArray(v.options)) errors.push(`${at}: type '${type}' requires options (array)`)
|
|
1317
|
+
break
|
|
1318
|
+
case 'submit':
|
|
1319
|
+
if (typeof v.label !== 'string') errors.push(`${at}: type 'submit' requires label (string)`)
|
|
1320
|
+
// action is optional (local grading needs no round trip); the
|
|
1321
|
+
// renderer disables the button when it is absent AND no question
|
|
1322
|
+
// carries local `answer` data.
|
|
1323
|
+
break
|
|
1324
|
+
case 'badge':
|
|
1325
|
+
if (typeof v.label !== 'string' && typeof v.text !== 'string' && typeof v.value !== 'string') {
|
|
1326
|
+
errors.push(`${at}: type 'badge' requires label, text, or value (string)`)
|
|
1327
|
+
}
|
|
1328
|
+
isStr('label')
|
|
1329
|
+
isStr('text')
|
|
1330
|
+
isStr('value')
|
|
1331
|
+
break
|
|
1332
|
+
case 'stat':
|
|
1333
|
+
if (typeof v.label !== 'string') errors.push(`${at}: type 'stat' requires label (string)`)
|
|
1334
|
+
if (typeof v.value !== 'string') errors.push(`${at}: type 'stat' requires value (string)`)
|
|
1335
|
+
isStr('delta')
|
|
1336
|
+
break
|
|
1337
|
+
case 'progress':
|
|
1338
|
+
if (typeof v.value !== 'number' || !Number.isFinite(v.value) || (v.value as number) < 0 || (v.value as number) > 100) {
|
|
1339
|
+
errors.push(`${at}: type 'progress' requires value (number 0..100)`)
|
|
1340
|
+
}
|
|
1341
|
+
isNum('value')
|
|
1342
|
+
break
|
|
1343
|
+
case 'avatar':
|
|
1344
|
+
if (typeof v.name !== 'string') errors.push(`${at}: type 'avatar' requires name (string)`)
|
|
1345
|
+
break
|
|
1346
|
+
case 'list':
|
|
1347
|
+
if (!Array.isArray(v.items)) errors.push(`${at}: type 'list' requires items (array)`)
|
|
1348
|
+
if (Array.isArray(v.items)) {
|
|
1349
|
+
// Descend into typed children so validation agrees with repair and
|
|
1350
|
+
// rendering (they recurse into list items as GenuiNodes). Strings and
|
|
1351
|
+
// {title,desc} list-item shapes are not nodes and are skipped.
|
|
1352
|
+
for (let i = 0; i < v.items.length; i++) {
|
|
1353
|
+
const item = obj(v.items[i])
|
|
1354
|
+
if (item !== undefined && typeof item.type === 'string') {
|
|
1355
|
+
validateNode(item, depth + 1, `${at}.items[${i}]`, errors, walk)
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
break
|
|
1360
|
+
case 'table':
|
|
1361
|
+
if (!Array.isArray(v.columns)) errors.push(`${at}: type 'table' requires columns (array)`)
|
|
1362
|
+
if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
|
|
1363
|
+
break
|
|
1364
|
+
case 'chart':
|
|
1365
|
+
if (!Array.isArray(v.data) && !Array.isArray(v.series)) errors.push(`${at}: type 'chart' requires data or series (array)`)
|
|
1366
|
+
break
|
|
1367
|
+
case 'tabs': {
|
|
1368
|
+
if (!Array.isArray(v.tabs)) errors.push(`${at}: type 'tabs' requires tabs (array)`)
|
|
1369
|
+
if (Array.isArray(v.tabs)) {
|
|
1370
|
+
for (let i = 0; i < v.tabs.length; i++) {
|
|
1371
|
+
const t = obj(v.tabs[i])
|
|
1372
|
+
if (t === undefined) { errors.push(`${at}.tabs[${i}] must be an object`); continue }
|
|
1373
|
+
if (typeof t.label !== 'string') errors.push(`${at}.tabs[${i}].label must be a string`)
|
|
1374
|
+
walk(t.items, depth + 1, `${at}.tabs[${i}].items`)
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
break
|
|
1378
|
+
}
|
|
1379
|
+
case 'plot':
|
|
1380
|
+
if (!Array.isArray(v.series)) errors.push(`${at}: type 'plot' requires series (array)`)
|
|
1381
|
+
break
|
|
1382
|
+
case 'callout':
|
|
1383
|
+
if (typeof v.content !== 'string') errors.push(`${at}: type 'callout' requires content (string)`)
|
|
1384
|
+
break
|
|
1385
|
+
case 'steps':
|
|
1386
|
+
if (!Array.isArray(v.steps)) errors.push(`${at}: type 'steps' requires steps (array)`)
|
|
1387
|
+
break
|
|
1388
|
+
case 'keyvalue':
|
|
1389
|
+
if (!Array.isArray(v.pairs)) errors.push(`${at}: type 'keyvalue' requires pairs (array)`)
|
|
1390
|
+
break
|
|
1391
|
+
case 'diff':
|
|
1392
|
+
if (!Array.isArray(v.diffs)) errors.push(`${at}: type 'diff' requires diffs (array)`)
|
|
1393
|
+
break
|
|
1394
|
+
case 'json':
|
|
1395
|
+
if (!('value' in v)) errors.push(`${at}: type 'json' requires value`)
|
|
1396
|
+
break
|
|
1397
|
+
case 'code':
|
|
1398
|
+
if (typeof v.code !== 'string') errors.push(`${at}: type 'code' requires code (string)`)
|
|
1399
|
+
break
|
|
1400
|
+
case 'accordion':
|
|
1401
|
+
if (!Array.isArray(v.items)) errors.push(`${at}: type 'accordion' requires items (array)`)
|
|
1402
|
+
if (Array.isArray(v.items)) {
|
|
1403
|
+
for (let i = 0; i < v.items.length; i++) {
|
|
1404
|
+
const item = obj(v.items[i])
|
|
1405
|
+
if (item === undefined) { errors.push(`${at}.items[${i}] must be an object`); continue }
|
|
1406
|
+
if (typeof item.title !== 'string') errors.push(`${at}.items[${i}].title must be a string`)
|
|
1407
|
+
walk(item.items, depth + 1, `${at}.items[${i}].items`)
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
break
|
|
1411
|
+
case 'copy':
|
|
1412
|
+
if (typeof v.text !== 'string') errors.push(`${at}: type 'copy' requires text (string)`)
|
|
1413
|
+
break
|
|
1414
|
+
case 'mermaid':
|
|
1415
|
+
if (typeof v.code !== 'string') errors.push(`${at}: type 'mermaid' requires code (string)`)
|
|
1416
|
+
break
|
|
1417
|
+
case 'scene3d':
|
|
1418
|
+
if (!Array.isArray(v.meshes)) errors.push(`${at}: type 'scene3d' requires meshes (array)`)
|
|
1419
|
+
break
|
|
1420
|
+
case 'timeline':
|
|
1421
|
+
if (!Array.isArray(v.items)) errors.push(`${at}: type 'timeline' requires items (array)`)
|
|
1422
|
+
break
|
|
1423
|
+
case 'file-tree':
|
|
1424
|
+
if (!Array.isArray(v.items)) errors.push(`${at}: type 'file-tree' requires items (array)`)
|
|
1425
|
+
break
|
|
1426
|
+
case 'breadcrumb':
|
|
1427
|
+
if (!Array.isArray(v.items)) errors.push(`${at}: type 'breadcrumb' requires items (array)`)
|
|
1428
|
+
break
|
|
1429
|
+
case 'quiz':
|
|
1430
|
+
if (typeof v.question !== 'string') errors.push(`${at}: type 'quiz' requires question (string)`)
|
|
1431
|
+
if (!Array.isArray(v.options)) errors.push(`${at}: type 'quiz' requires options (array)`)
|
|
1432
|
+
break
|
|
1433
|
+
case 'diagram':
|
|
1434
|
+
if (typeof v.kind !== 'string') errors.push(`${at}: type 'diagram' requires kind (string)`)
|
|
1435
|
+
if (!Array.isArray(v.nodes)) errors.push(`${at}: type 'diagram' requires nodes (array)`)
|
|
1436
|
+
if (v.edges !== undefined && !Array.isArray(v.edges)) errors.push(`${at}: type 'diagram' requires edges (array) when present`)
|
|
1437
|
+
break
|
|
1438
|
+
|
|
1439
|
+
case 'echart':
|
|
1440
|
+
if (v.option === undefined && v.data === undefined && v.series === undefined) {
|
|
1441
|
+
errors.push(`${at}: type 'echart' requires option, data, or series`)
|
|
1442
|
+
}
|
|
1443
|
+
isNum('height')
|
|
1444
|
+
break
|
|
1445
|
+
default:
|
|
1446
|
+
// Unknown type: plugin-registered custom nodes are valid when a
|
|
1447
|
+
// renderer exists; the guard cannot know, so report as a warning.
|
|
1448
|
+
errors.push(`${at}: unknown type '${type}' (custom renderer?)`)
|
|
1449
|
+
}
|
|
1450
|
+
}
|