dsh-mosaic 0.2.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 +47 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/SKILL.md +153 -0
- package/THIRD_PARTY_NOTICES.md +79 -0
- package/assets/icon-180.png +0 -0
- package/assets/icon.svg +13 -0
- package/assets/screenshots/preview.jpg +0 -0
- package/cordis.patch.yml +10 -0
- package/docs/catalog-entry.example.yml +9 -0
- package/docs/publishing.md +29 -0
- package/lib/assets/licenses/braintree-sanitize-url.LICENSE +21 -0
- package/lib/assets/licenses/chevrotain.LICENSE +202 -0
- package/lib/assets/licenses/cytoscape-cose-bilkent.LICENSE +21 -0
- package/lib/assets/licenses/cytoscape-fcose.LICENSE +19 -0
- package/lib/assets/licenses/cytoscape.LICENSE +19 -0
- package/lib/assets/licenses/d3-sankey.LICENSE +27 -0
- package/lib/assets/licenses/d3.LICENSE +13 -0
- package/lib/assets/licenses/dagre-d3-es.LICENSE +23 -0
- package/lib/assets/licenses/dayjs.LICENSE +21 -0
- package/lib/assets/licenses/dompurify.LICENSE +202 -0
- package/lib/assets/licenses/elkjs.LICENSE +263 -0
- package/lib/assets/licenses/es-toolkit.LICENSE +21 -0
- package/lib/assets/licenses/iconify-utils.LICENSE +21 -0
- package/lib/assets/licenses/katex.LICENSE +21 -0
- package/lib/assets/licenses/khroma.LICENSE +21 -0
- package/lib/assets/licenses/marked.LICENSE +44 -0
- package/lib/assets/licenses/mermaid-js-parser.LICENSE +21 -0
- package/lib/assets/licenses/mermaid.LICENSE +21 -0
- package/lib/assets/licenses/roughjs.LICENSE +21 -0
- package/lib/assets/licenses/stylis.LICENSE +21 -0
- package/lib/assets/licenses/ts-dedent.LICENSE +21 -0
- package/lib/assets/licenses/types-d3.LICENSE +21 -0
- package/lib/assets/licenses/upsetjs-venn.js.LICENSE +22 -0
- package/lib/assets/licenses/uuid.LICENSE +9 -0
- package/lib/assets/mermaid.min.js +4330 -0
- package/lib/client.js +3191 -0
- package/lib/index.js +365 -0
- package/lib/log.js +212 -0
- package/lib/prompt-store.js +354 -0
- package/lib/render-tool.js +162 -0
- package/lib/validate-tool.js +160 -0
- package/lib/validate.js +1070 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -0
- package/screenshots.json +3 -0
package/lib/validate.js
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mosaic spec validation and deterministic repair.
|
|
3
|
+
*
|
|
4
|
+
* The validator is intentionally small: it keeps a compact component
|
|
5
|
+
* vocabulary, drops invalid nodes with a path, and returns a spec only when
|
|
6
|
+
* every node is safe to render. The browser partial parser is more forgiving;
|
|
7
|
+
* this module is the preflight / tool-call authority.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const LIMITS = {
|
|
11
|
+
maxNodes: 80,
|
|
12
|
+
maxDepth: 6,
|
|
13
|
+
maxString: 2000,
|
|
14
|
+
maxCode: 12000,
|
|
15
|
+
maxWidget: 20000,
|
|
16
|
+
maxTableRows: 40,
|
|
17
|
+
maxTableCols: 8,
|
|
18
|
+
maxChartPoints: 40,
|
|
19
|
+
maxSeries: 8,
|
|
20
|
+
maxListItems: 30,
|
|
21
|
+
maxTabs: 8,
|
|
22
|
+
maxQuizOptions: 6,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TEXT_SIZES = new Set(['display', 'h1', 'h2', 'h3', 'body', 'muted', 'caption'])
|
|
26
|
+
const TEXT_TONES = new Set(['danger', 'success', 'warning', 'info', 'muted'])
|
|
27
|
+
const TEXT_ALIGNS = new Set(['left', 'center', 'right'])
|
|
28
|
+
const CARD_TONES = new Set(['info', 'success', 'warning', 'danger'])
|
|
29
|
+
const CALLOUT_TONES = new Set(['info', 'success', 'warning', 'error'])
|
|
30
|
+
const BADGE_TONES = new Set(['success', 'warn', 'danger', 'accent'])
|
|
31
|
+
const BUTTON_TONES = new Set(['primary', 'danger', 'success', 'ghost'])
|
|
32
|
+
const CHART_KINDS = new Set(['bars', 'line', 'donut'])
|
|
33
|
+
const DIAGRAM_KINDS = new Set(['flowchart', 'sequence', 'timeline', 'layers'])
|
|
34
|
+
const DIAGRAM_DIRECTIONS = new Set(['TB', 'LR'])
|
|
35
|
+
const DIAGRAM_TONES = new Set(['accent', 'success', 'warning', 'danger', 'muted'])
|
|
36
|
+
const DIAGRAM_SHAPES = new Set(['box', 'round', 'diamond'])
|
|
37
|
+
const DIAGRAM_EDGE_KINDS = new Set(['solid', 'dashed', 'accent'])
|
|
38
|
+
const SEQUENCE_MESSAGE_KINDS = new Set(['sync', 'return', 'async'])
|
|
39
|
+
const ASPECTS = new Set(['16:9', '4:3', '1:1', '9:16'])
|
|
40
|
+
const LOCAL_ACTIONS = new Set(['reset', 'copy', 'open', 'fullscreen'])
|
|
41
|
+
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function asString(value, cap = LIMITS.maxString) {
|
|
47
|
+
return typeof value === 'string' ? value.slice(0, cap) : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asNumber(value, min = -Infinity, max = Infinity) {
|
|
51
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
52
|
+
? Math.min(max, Math.max(min, value))
|
|
53
|
+
: undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function asInteger(value, min = -Infinity, max = Infinity) {
|
|
57
|
+
const n = asNumber(value, min, max)
|
|
58
|
+
return n === undefined ? undefined : Math.trunc(n)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function asEnum(value, values) {
|
|
62
|
+
return typeof value === 'string' && values.has(value) ? value : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asBoolean(value) {
|
|
66
|
+
return typeof value === 'boolean' ? value : undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function looksLikeSpec(value) {
|
|
70
|
+
return isRecord(value) && Array.isArray(value.items)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function looksLikeNode(value) {
|
|
74
|
+
return isRecord(value) && typeof value.type === 'string'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Keep only browser-reachable http(s), mailto, or root-relative targets.
|
|
79
|
+
* `data:`, `blob:`, `file:`, `javascript:` and protocol-relative URLs are
|
|
80
|
+
* rejected; widgets are the only arbitrary-code escape hatch.
|
|
81
|
+
*/
|
|
82
|
+
export function safeUrl(value, { media = false } = {}) {
|
|
83
|
+
const raw = asString(value, 2048)
|
|
84
|
+
if (raw === undefined) return undefined
|
|
85
|
+
const url = raw.trim()
|
|
86
|
+
if (url === '') return undefined
|
|
87
|
+
if (/^https?:\/\//i.test(url)) return url
|
|
88
|
+
if (!media && /^mailto:[^@\s]+@[^@\s]+$/i.test(url)) return url
|
|
89
|
+
if (url.startsWith('/') && !url.startsWith('//')) return url
|
|
90
|
+
if (url.startsWith('./') || url.startsWith('../')) return url
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fail(state, path, message) {
|
|
95
|
+
state.errors.push(`${path}: ${message}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function takeString(state, path, value, cap = LIMITS.maxString) {
|
|
99
|
+
const result = asString(value, cap)
|
|
100
|
+
if (result === undefined) fail(state, path, 'must be a string')
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function takeOptionalNumber(state, path, value, min, max) {
|
|
105
|
+
if (value === undefined) return undefined
|
|
106
|
+
const result = asNumber(value, min, max)
|
|
107
|
+
if (result === undefined) fail(state, path, 'must be a finite number')
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function takeOptionalInteger(state, path, value, min, max) {
|
|
112
|
+
if (value === undefined) return undefined
|
|
113
|
+
const result = asInteger(value, min, max)
|
|
114
|
+
if (result === undefined) fail(state, path, 'must be an integer')
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function takeNodeList(state, path, value, depth) {
|
|
119
|
+
if (!Array.isArray(value)) {
|
|
120
|
+
fail(state, path, 'must be an array')
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
const out = []
|
|
124
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
125
|
+
const node = repairNode(state, value[index], `${path}[${index}]`, depth + 1)
|
|
126
|
+
if (node !== null) out.push(node)
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function takeStringList(state, path, value, cap = LIMITS.maxString, maxItems = LIMITS.maxListItems) {
|
|
132
|
+
if (!Array.isArray(value)) {
|
|
133
|
+
fail(state, path, 'must be an array')
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
const out = []
|
|
137
|
+
for (let index = 0; index < value.length && out.length < maxItems; index += 1) {
|
|
138
|
+
const item = asString(value[index], cap)
|
|
139
|
+
if (item === undefined) {
|
|
140
|
+
fail(state, `${path}[${index}]`, 'must be a string')
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
out.push(item)
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeListItems(state, path, value, depth) {
|
|
149
|
+
if (!Array.isArray(value)) {
|
|
150
|
+
fail(state, path, 'must be an array')
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
const out = []
|
|
154
|
+
for (let index = 0; index < value.length && out.length < LIMITS.maxListItems; index += 1) {
|
|
155
|
+
const item = value[index]
|
|
156
|
+
const at = `${path}[${index}]`
|
|
157
|
+
if (typeof item === 'string') {
|
|
158
|
+
out.push(item.slice(0, LIMITS.maxString))
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (looksLikeNode(item)) {
|
|
162
|
+
const node = repairNode(state, item, at, depth + 1)
|
|
163
|
+
if (node !== null) out.push(node)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (!isRecord(item)) {
|
|
167
|
+
fail(state, at, 'must be a string, record, or component')
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
const title = asString(item.title ?? item.label ?? item.name, LIMITS.maxString)
|
|
171
|
+
if (title === undefined) {
|
|
172
|
+
fail(state, at, 'record needs a title')
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
out.push({
|
|
176
|
+
title,
|
|
177
|
+
...(asString(item.desc ?? item.description ?? item.content ?? item.text, LIMITS.maxString) === undefined
|
|
178
|
+
? {}
|
|
179
|
+
: { desc: asString(item.desc ?? item.description ?? item.content ?? item.text, LIMITS.maxString) }),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeTable(state, path, value) {
|
|
186
|
+
let columnsRaw = value.columns ?? value.headers
|
|
187
|
+
let rowsRaw = value.rows ?? value.data
|
|
188
|
+
|
|
189
|
+
if (rowsRaw !== undefined && !Array.isArray(rowsRaw)) {
|
|
190
|
+
fail(state, `${path}.rows`, 'must be an array')
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Object rows: use the declared columns or the first row's keys.
|
|
195
|
+
if (Array.isArray(rowsRaw) && rowsRaw.length > 0 && isRecord(rowsRaw[0])) {
|
|
196
|
+
const keys = Array.isArray(columnsRaw) && columnsRaw.every((column) => typeof column === 'string')
|
|
197
|
+
? columnsRaw
|
|
198
|
+
: Object.keys(rowsRaw[0])
|
|
199
|
+
rowsRaw = rowsRaw.map((row) => keys.map((key) => (isRecord(row) ? row[key] : undefined)))
|
|
200
|
+
if (!Array.isArray(columnsRaw)) columnsRaw = keys
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2D rows: derive the header from the leading row when columns are absent.
|
|
204
|
+
if ((!Array.isArray(columnsRaw) || columnsRaw.length === 0)
|
|
205
|
+
&& Array.isArray(rowsRaw) && rowsRaw.length > 0 && Array.isArray(rowsRaw[0])) {
|
|
206
|
+
columnsRaw = rowsRaw[0]
|
|
207
|
+
rowsRaw = rowsRaw.slice(1)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const columns = takeStringList(state, `${path}.columns`, columnsRaw, 256, LIMITS.maxTableCols)
|
|
211
|
+
if (columns === undefined || columns.length === 0) {
|
|
212
|
+
if (columns !== undefined) fail(state, `${path}.columns`, 'must not be empty')
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!Array.isArray(rowsRaw)) {
|
|
217
|
+
fail(state, `${path}.rows`, 'must be an array')
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const rows = []
|
|
222
|
+
for (let rowIndex = 0; rowIndex < rowsRaw.length && rows.length < LIMITS.maxTableRows; rowIndex += 1) {
|
|
223
|
+
const row = rowsRaw[rowIndex]
|
|
224
|
+
if (!Array.isArray(row)) {
|
|
225
|
+
fail(state, `${path}.rows[${rowIndex}]`, 'must be an array')
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const cells = []
|
|
229
|
+
for (let cellIndex = 0; cellIndex < row.length && cellIndex < columns.length; cellIndex += 1) {
|
|
230
|
+
const cell = row[cellIndex]
|
|
231
|
+
if (typeof cell === 'string') cells.push(cell.slice(0, 512))
|
|
232
|
+
else if (typeof cell === 'number' && Number.isFinite(cell)) cells.push(cell)
|
|
233
|
+
else if (cell === null) cells.push('')
|
|
234
|
+
else fail(state, `${path}.rows[${rowIndex}][${cellIndex}]`, 'must be a string, number, or null')
|
|
235
|
+
}
|
|
236
|
+
rows.push(cells)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const types = Array.isArray(value.types)
|
|
240
|
+
? columns.map((_, index) => {
|
|
241
|
+
const type = value.types[index]
|
|
242
|
+
return typeof type === 'string' && ['text', 'num', 'delta', 'bar', 'badge'].includes(type) ? type : 'text'
|
|
243
|
+
})
|
|
244
|
+
: undefined
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
type: 'table',
|
|
248
|
+
columns,
|
|
249
|
+
rows,
|
|
250
|
+
...(types === undefined ? {} : { types }),
|
|
251
|
+
...(asBoolean(value.total) === true ? { total: true } : {}),
|
|
252
|
+
...(asBoolean(value.export) === true ? { export: true } : {}),
|
|
253
|
+
...(asString(value.filter, 200) === undefined ? {} : { filter: asString(value.filter, 200) }),
|
|
254
|
+
...(asString(value.sort, 200) === undefined ? {} : { sort: asString(value.sort, 200) }),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizeChart(state, path, value) {
|
|
259
|
+
const kind = value.kind === undefined ? 'bars' : asEnum(value.kind, CHART_KINDS)
|
|
260
|
+
if (kind === undefined) {
|
|
261
|
+
fail(state, `${path}.kind`, 'must be bars, line, or donut')
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
const data = Array.isArray(value.data)
|
|
265
|
+
? value.data.slice(0, LIMITS.maxChartPoints).flatMap((datum, index) => {
|
|
266
|
+
if (!isRecord(datum)) {
|
|
267
|
+
fail(state, `${path}.data[${index}]`, 'must be an object')
|
|
268
|
+
return []
|
|
269
|
+
}
|
|
270
|
+
const label = asString(datum.label, 128)
|
|
271
|
+
const number = asNumber(datum.value)
|
|
272
|
+
if (label === undefined || number === undefined) {
|
|
273
|
+
fail(state, `${path}.data[${index}]`, 'needs label (string) and value (finite number)')
|
|
274
|
+
return []
|
|
275
|
+
}
|
|
276
|
+
return [{ label, value: number }]
|
|
277
|
+
})
|
|
278
|
+
: undefined
|
|
279
|
+
|
|
280
|
+
const series = Array.isArray(value.series)
|
|
281
|
+
? value.series.slice(0, LIMITS.maxSeries).flatMap((entry, index) => {
|
|
282
|
+
if (!isRecord(entry)) {
|
|
283
|
+
fail(state, `${path}.series[${index}]`, 'must be an object')
|
|
284
|
+
return []
|
|
285
|
+
}
|
|
286
|
+
const label = asString(entry.label, 128)
|
|
287
|
+
if (label === undefined) {
|
|
288
|
+
fail(state, `${path}.series[${index}].label`, 'must be a string')
|
|
289
|
+
return []
|
|
290
|
+
}
|
|
291
|
+
const points = Array.isArray(entry.data)
|
|
292
|
+
? entry.data.slice(0, LIMITS.maxChartPoints).flatMap((point, pointIndex) => {
|
|
293
|
+
if (!isRecord(point)) {
|
|
294
|
+
fail(state, `${path}.series[${index}].data[${pointIndex}]`, 'must be an object')
|
|
295
|
+
return []
|
|
296
|
+
}
|
|
297
|
+
const pointLabel = asString(point.label, 128)
|
|
298
|
+
const pointValue = asNumber(point.value)
|
|
299
|
+
if (pointLabel === undefined || pointValue === undefined) {
|
|
300
|
+
fail(state, `${path}.series[${index}].data[${pointIndex}]`, 'needs label and value')
|
|
301
|
+
return []
|
|
302
|
+
}
|
|
303
|
+
return [{ label: pointLabel, value: pointValue }]
|
|
304
|
+
})
|
|
305
|
+
: []
|
|
306
|
+
return [{ label, data: points }]
|
|
307
|
+
})
|
|
308
|
+
: undefined
|
|
309
|
+
|
|
310
|
+
if ((data === undefined || data.length === 0) && (series === undefined || series.length === 0)) {
|
|
311
|
+
fail(state, path, 'requires data or series')
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
if (kind === 'donut' && (data === undefined || data.length === 0)) {
|
|
315
|
+
fail(state, `${path}.data`, 'is required for donut')
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
if (kind === 'donut' && series !== undefined) {
|
|
319
|
+
fail(state, `${path}.series`, 'is not supported for donut')
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
type: 'chart',
|
|
325
|
+
kind,
|
|
326
|
+
...(data === undefined ? {} : { data }),
|
|
327
|
+
...(series === undefined ? {} : { series }),
|
|
328
|
+
...(asBoolean(value.stacked) === true ? { stacked: true } : {}),
|
|
329
|
+
...(asString(value.title, LIMITS.maxString) === undefined ? {} : { title: asString(value.title, LIMITS.maxString) }),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function normalizeWidget(state, path, value) {
|
|
334
|
+
const code = asString(value.code, LIMITS.maxWidget)
|
|
335
|
+
if (code === undefined) {
|
|
336
|
+
fail(state, `${path}.code`, 'must be a string')
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
type: 'widget',
|
|
341
|
+
code,
|
|
342
|
+
height: asInteger(value.height, 80, 900) ?? 320,
|
|
343
|
+
...(asString(value.title, 256) === undefined ? {} : { title: asString(value.title, 256) }),
|
|
344
|
+
...(asString(value.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(value.caption, LIMITS.maxString) }),
|
|
345
|
+
// Widgets may only call the model without a fresh user gesture when they opt in.
|
|
346
|
+
...(value.actions === 'programmatic' ? { actions: 'programmatic' } : {}),
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Normalize a small structured diagram: flowchart, sequence, timeline, or
|
|
352
|
+
* layered stack. All members are plain JSON so the client can lay them out
|
|
353
|
+
* without a diagram engine.
|
|
354
|
+
*/
|
|
355
|
+
function normalizeDiagram(state, path, raw) {
|
|
356
|
+
const inferred = raw.kind === undefined
|
|
357
|
+
? (Array.isArray(raw.nodes) || Array.isArray(raw.edges) ? 'flowchart'
|
|
358
|
+
: Array.isArray(raw.participants) || Array.isArray(raw.messages) ? 'sequence'
|
|
359
|
+
: Array.isArray(raw.layers) ? 'layers'
|
|
360
|
+
: Array.isArray(raw.items) ? 'timeline' : undefined)
|
|
361
|
+
: undefined
|
|
362
|
+
const kind = raw.kind === undefined ? inferred : asEnum(raw.kind, DIAGRAM_KINDS)
|
|
363
|
+
if (kind === undefined) {
|
|
364
|
+
fail(state, `${path}.kind`, 'must be flowchart, sequence, timeline, or layers')
|
|
365
|
+
return null
|
|
366
|
+
}
|
|
367
|
+
const title = asString(raw.title, 256)
|
|
368
|
+
const common = { type: 'diagram', kind, ...(title === undefined ? {} : { title }) }
|
|
369
|
+
|
|
370
|
+
if (kind === 'flowchart') {
|
|
371
|
+
if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {
|
|
372
|
+
fail(state, `${path}.nodes`, 'flowchart requires a non-empty nodes array')
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
const nodes = []
|
|
376
|
+
const ids = new Set()
|
|
377
|
+
for (let index = 0; index < raw.nodes.length && nodes.length < 12; index += 1) {
|
|
378
|
+
const node = raw.nodes[index]
|
|
379
|
+
const at = `${path}.nodes[${index}]`
|
|
380
|
+
if (!isRecord(node)) {
|
|
381
|
+
fail(state, at, 'must be an object')
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
const id = asString(node.id, 64)
|
|
385
|
+
const label = asString(node.label, 128)
|
|
386
|
+
if (id === undefined || label === undefined) {
|
|
387
|
+
fail(state, at, 'requires id and label strings')
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
if (ids.has(id)) {
|
|
391
|
+
fail(state, at, `duplicate node id '${id}'`)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
ids.add(id)
|
|
395
|
+
nodes.push({
|
|
396
|
+
id,
|
|
397
|
+
label,
|
|
398
|
+
...(asString(node.sub, 128) === undefined ? {} : { sub: asString(node.sub, 128) }),
|
|
399
|
+
...(asEnum(node.tone, DIAGRAM_TONES) === undefined ? {} : { tone: asEnum(node.tone, DIAGRAM_TONES) }),
|
|
400
|
+
...(asEnum(node.shape, DIAGRAM_SHAPES) === undefined ? {} : { shape: asEnum(node.shape, DIAGRAM_SHAPES) }),
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
if (nodes.length === 0) return null
|
|
404
|
+
const edges = []
|
|
405
|
+
if (raw.edges !== undefined) {
|
|
406
|
+
if (!Array.isArray(raw.edges)) {
|
|
407
|
+
fail(state, `${path}.edges`, 'must be an array')
|
|
408
|
+
return null
|
|
409
|
+
}
|
|
410
|
+
for (let index = 0; index < raw.edges.length && edges.length < 20; index += 1) {
|
|
411
|
+
const edge = raw.edges[index]
|
|
412
|
+
const at = `${path}.edges[${index}]`
|
|
413
|
+
if (!isRecord(edge)) {
|
|
414
|
+
fail(state, at, 'must be an object')
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
const from = asString(edge.from, 64)
|
|
418
|
+
const to = asString(edge.to, 64)
|
|
419
|
+
if (from === undefined || to === undefined) {
|
|
420
|
+
fail(state, at, 'requires from and to strings')
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
if (!ids.has(from) || !ids.has(to)) {
|
|
424
|
+
fail(state, at, `unknown endpoint ${!ids.has(from) ? from : to}`)
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
edges.push({
|
|
428
|
+
from,
|
|
429
|
+
to,
|
|
430
|
+
...(asString(edge.label, 64) === undefined ? {} : { label: asString(edge.label, 64) }),
|
|
431
|
+
...(asEnum(edge.kind, DIAGRAM_EDGE_KINDS) === undefined ? {} : { kind: asEnum(edge.kind, DIAGRAM_EDGE_KINDS) }),
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
...common,
|
|
437
|
+
direction: asEnum(raw.direction, DIAGRAM_DIRECTIONS) ?? 'TB',
|
|
438
|
+
nodes,
|
|
439
|
+
edges,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (kind === 'sequence') {
|
|
444
|
+
if (!Array.isArray(raw.participants) || raw.participants.length === 0) {
|
|
445
|
+
fail(state, `${path}.participants`, 'sequence requires a non-empty participants array')
|
|
446
|
+
return null
|
|
447
|
+
}
|
|
448
|
+
const participants = []
|
|
449
|
+
const ids = new Set()
|
|
450
|
+
for (let index = 0; index < raw.participants.length && participants.length < 8; index += 1) {
|
|
451
|
+
const participant = raw.participants[index]
|
|
452
|
+
const at = `${path}.participants[${index}]`
|
|
453
|
+
if (!isRecord(participant)) {
|
|
454
|
+
fail(state, at, 'must be an object')
|
|
455
|
+
continue
|
|
456
|
+
}
|
|
457
|
+
const id = asString(participant.id, 64)
|
|
458
|
+
const label = asString(participant.label, 128)
|
|
459
|
+
if (id === undefined || label === undefined) {
|
|
460
|
+
fail(state, at, 'requires id and label strings')
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
if (ids.has(id)) {
|
|
464
|
+
fail(state, at, `duplicate participant id '${id}'`)
|
|
465
|
+
continue
|
|
466
|
+
}
|
|
467
|
+
ids.add(id)
|
|
468
|
+
participants.push({ id, label })
|
|
469
|
+
}
|
|
470
|
+
if (participants.length === 0) return null
|
|
471
|
+
if (!Array.isArray(raw.messages) || raw.messages.length === 0) {
|
|
472
|
+
fail(state, `${path}.messages`, 'sequence requires a non-empty messages array')
|
|
473
|
+
return null
|
|
474
|
+
}
|
|
475
|
+
const messages = []
|
|
476
|
+
for (let index = 0; index < raw.messages.length && messages.length < 20; index += 1) {
|
|
477
|
+
const message = raw.messages[index]
|
|
478
|
+
const at = `${path}.messages[${index}]`
|
|
479
|
+
if (!isRecord(message)) {
|
|
480
|
+
fail(state, at, 'must be an object')
|
|
481
|
+
continue
|
|
482
|
+
}
|
|
483
|
+
const from = asString(message.from, 64)
|
|
484
|
+
const to = asString(message.to, 64)
|
|
485
|
+
const label = asString(message.label, 128)
|
|
486
|
+
if (from === undefined || to === undefined || label === undefined) {
|
|
487
|
+
fail(state, at, 'requires from, to, and label strings')
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
490
|
+
if (!ids.has(from) || !ids.has(to)) {
|
|
491
|
+
fail(state, at, `unknown participant ${!ids.has(from) ? from : to}`)
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
messages.push({
|
|
495
|
+
from,
|
|
496
|
+
to,
|
|
497
|
+
label,
|
|
498
|
+
...(asEnum(message.kind, SEQUENCE_MESSAGE_KINDS) === undefined ? {} : { kind: asEnum(message.kind, SEQUENCE_MESSAGE_KINDS) }),
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
if (messages.length === 0) return null
|
|
502
|
+
return { ...common, participants, messages }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (kind === 'timeline') {
|
|
506
|
+
if (!Array.isArray(raw.items) || raw.items.length === 0) {
|
|
507
|
+
fail(state, `${path}.items`, 'timeline requires a non-empty items array')
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
const items = []
|
|
511
|
+
for (let index = 0; index < raw.items.length && items.length < 12; index += 1) {
|
|
512
|
+
const item = raw.items[index]
|
|
513
|
+
const at = `${path}.items[${index}]`
|
|
514
|
+
if (!isRecord(item)) {
|
|
515
|
+
fail(state, at, 'must be an object')
|
|
516
|
+
continue
|
|
517
|
+
}
|
|
518
|
+
const itemTitle = asString(item.title, 256)
|
|
519
|
+
if (itemTitle === undefined) {
|
|
520
|
+
fail(state, at, 'requires a title string')
|
|
521
|
+
continue
|
|
522
|
+
}
|
|
523
|
+
items.push({
|
|
524
|
+
title: itemTitle,
|
|
525
|
+
...(asString(item.desc, LIMITS.maxString) === undefined ? {} : { desc: asString(item.desc, LIMITS.maxString) }),
|
|
526
|
+
...(asString(item.time, 64) === undefined ? {} : { time: asString(item.time, 64) }),
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
return items.length === 0 ? null : { ...common, items }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (!Array.isArray(raw.layers) || raw.layers.length === 0) {
|
|
533
|
+
fail(state, `${path}.layers`, 'layers requires a non-empty layers array')
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
const layers = []
|
|
537
|
+
for (let index = 0; index < raw.layers.length && layers.length < 8; index += 1) {
|
|
538
|
+
const layer = raw.layers[index]
|
|
539
|
+
const at = `${path}.layers[${index}]`
|
|
540
|
+
if (!isRecord(layer)) {
|
|
541
|
+
fail(state, at, 'must be an object')
|
|
542
|
+
continue
|
|
543
|
+
}
|
|
544
|
+
const layerTitle = asString(layer.title, 128)
|
|
545
|
+
if (layerTitle === undefined) {
|
|
546
|
+
fail(state, at, 'requires a title string')
|
|
547
|
+
continue
|
|
548
|
+
}
|
|
549
|
+
const items = takeStringList(state, `${at}.items`, layer.items, 256, 6)
|
|
550
|
+
if (items === undefined || items.length === 0) {
|
|
551
|
+
fail(state, `${at}.items`, 'must be a non-empty string array')
|
|
552
|
+
continue
|
|
553
|
+
}
|
|
554
|
+
layers.push({ title: layerTitle, items })
|
|
555
|
+
}
|
|
556
|
+
return layers.length === 0 ? null : { ...common, layers }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Repair one node. Returns a JSON-safe node or null after pushing an error.
|
|
561
|
+
*/
|
|
562
|
+
function repairNode(state, raw, path, depth) {
|
|
563
|
+
if (depth > LIMITS.maxDepth) {
|
|
564
|
+
fail(state, path, `exceeds max depth ${LIMITS.maxDepth}`)
|
|
565
|
+
return null
|
|
566
|
+
}
|
|
567
|
+
if (state.count >= LIMITS.maxNodes) {
|
|
568
|
+
fail(state, path, `exceeds max nodes ${LIMITS.maxNodes}`)
|
|
569
|
+
return null
|
|
570
|
+
}
|
|
571
|
+
if (!isRecord(raw)) {
|
|
572
|
+
fail(state, path, 'must be an object')
|
|
573
|
+
return null
|
|
574
|
+
}
|
|
575
|
+
if (typeof raw.type !== 'string') {
|
|
576
|
+
fail(state, path, 'missing string type')
|
|
577
|
+
return null
|
|
578
|
+
}
|
|
579
|
+
state.count += 1
|
|
580
|
+
const type = raw.type
|
|
581
|
+
|
|
582
|
+
switch (type) {
|
|
583
|
+
case 'text': {
|
|
584
|
+
const content = asString(raw.content ?? raw.text, LIMITS.maxString)
|
|
585
|
+
if (content === undefined) {
|
|
586
|
+
fail(state, `${path}.content`, 'must be a string')
|
|
587
|
+
return null
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
type: 'text',
|
|
591
|
+
content,
|
|
592
|
+
...(asEnum(raw.size, TEXT_SIZES) === undefined ? {} : { size: asEnum(raw.size, TEXT_SIZES) }),
|
|
593
|
+
...(asEnum(raw.align, TEXT_ALIGNS) === undefined ? {} : { align: asEnum(raw.align, TEXT_ALIGNS) }),
|
|
594
|
+
...(asEnum(raw.tone, TEXT_TONES) === undefined ? {} : { tone: asEnum(raw.tone, TEXT_TONES) }),
|
|
595
|
+
...(asBoolean(raw.mono) === true ? { mono: true } : {}),
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
case 'row': {
|
|
599
|
+
const items = takeNodeList(state, `${path}.items`, raw.items, depth)
|
|
600
|
+
if (items === undefined || items.length === 0) return null
|
|
601
|
+
return {
|
|
602
|
+
type: 'row',
|
|
603
|
+
items,
|
|
604
|
+
...(raw.gap === undefined ? {} : { gap: takeOptionalNumber(state, `${path}.gap`, raw.gap, 0, 48) }),
|
|
605
|
+
...(raw.align === undefined ? {} : { align: asEnum(raw.align, new Set(['start', 'center', 'end', 'between'])) }),
|
|
606
|
+
...(asBoolean(raw.wrap) === true ? { wrap: true } : {}),
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
case 'col': {
|
|
610
|
+
const items = takeNodeList(state, `${path}.items`, raw.items, depth)
|
|
611
|
+
if (items === undefined || items.length === 0) return null
|
|
612
|
+
return {
|
|
613
|
+
type: 'col',
|
|
614
|
+
items,
|
|
615
|
+
...(raw.gap === undefined ? {} : { gap: takeOptionalNumber(state, `${path}.gap`, raw.gap, 0, 48) }),
|
|
616
|
+
...(raw.width === undefined ? {} : { width: takeOptionalInteger(state, `${path}.width`, raw.width, 1, 100) }),
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
case 'grid': {
|
|
620
|
+
const items = takeNodeList(state, `${path}.items`, raw.items, depth)
|
|
621
|
+
if (items === undefined || items.length === 0) return null
|
|
622
|
+
return {
|
|
623
|
+
type: 'grid',
|
|
624
|
+
cols: asInteger(raw.cols, 1, 6) ?? 2,
|
|
625
|
+
items,
|
|
626
|
+
...(raw.gap === undefined ? {} : { gap: takeOptionalNumber(state, `${path}.gap`, raw.gap, 0, 48) }),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
case 'card': {
|
|
630
|
+
const items = takeNodeList(state, `${path}.items`, raw.items, depth)
|
|
631
|
+
if (items === undefined || items.length === 0) return null
|
|
632
|
+
return {
|
|
633
|
+
type: 'card',
|
|
634
|
+
items,
|
|
635
|
+
...(asString(raw.title, LIMITS.maxString) === undefined ? {} : { title: asString(raw.title, LIMITS.maxString) }),
|
|
636
|
+
...(asString(raw.eyebrow, 128) === undefined ? {} : { eyebrow: asString(raw.eyebrow, 128) }),
|
|
637
|
+
...(asEnum(raw.tone, CARD_TONES) === undefined ? {} : { tone: asEnum(raw.tone, CARD_TONES) }),
|
|
638
|
+
...(asBoolean(raw.flat) === true ? { flat: true } : {}),
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
case 'divider':
|
|
642
|
+
return { type: 'divider', ...(asString(raw.label, 128) === undefined ? {} : { label: asString(raw.label, 128) }) }
|
|
643
|
+
case 'spacer':
|
|
644
|
+
return { type: 'spacer' }
|
|
645
|
+
case 'stat': {
|
|
646
|
+
const label = asString(raw.label ?? raw.title, LIMITS.maxString)
|
|
647
|
+
const value = asString(raw.value, 128)
|
|
648
|
+
if (label === undefined || value === undefined) {
|
|
649
|
+
fail(state, path, 'stat requires label and value')
|
|
650
|
+
return null
|
|
651
|
+
}
|
|
652
|
+
const spark = Array.isArray(raw.spark)
|
|
653
|
+
? raw.spark.slice(0, 20).filter((n) => typeof n === 'number' && Number.isFinite(n))
|
|
654
|
+
: undefined
|
|
655
|
+
return {
|
|
656
|
+
type: 'stat',
|
|
657
|
+
label,
|
|
658
|
+
value,
|
|
659
|
+
...(asString(raw.delta, 64) === undefined ? {} : { delta: asString(raw.delta, 64) }),
|
|
660
|
+
...(spark !== undefined && spark.length >= 2 ? { spark } : {}),
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
case 'badge': {
|
|
664
|
+
const label = asString(raw.label ?? raw.text, LIMITS.maxString)
|
|
665
|
+
if (label === undefined) {
|
|
666
|
+
fail(state, `${path}.label`, 'must be a string')
|
|
667
|
+
return null
|
|
668
|
+
}
|
|
669
|
+
return { type: 'badge', label, ...(asEnum(raw.tone, BADGE_TONES) === undefined ? {} : { tone: asEnum(raw.tone, BADGE_TONES) }) }
|
|
670
|
+
}
|
|
671
|
+
case 'progress': {
|
|
672
|
+
const number = asNumber(raw.value, 0, 100)
|
|
673
|
+
if (number === undefined) {
|
|
674
|
+
fail(state, `${path}.value`, 'must be a number from 0 to 100')
|
|
675
|
+
return null
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
type: 'progress',
|
|
679
|
+
value: number,
|
|
680
|
+
...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
|
|
681
|
+
...(asString(raw.valueLabel, 64) === undefined ? {} : { valueLabel: asString(raw.valueLabel, 64) }),
|
|
682
|
+
...(asEnum(raw.variant, new Set(['bar', 'ring'])) === undefined ? {} : { variant: asEnum(raw.variant, new Set(['bar', 'ring'])) }),
|
|
683
|
+
...(asNumber(raw.target, 0, 100) === undefined ? {} : { target: asNumber(raw.target, 0, 100) }),
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
case 'list': {
|
|
687
|
+
const items = normalizeListItems(state, `${path}.items`, raw.items, depth)
|
|
688
|
+
if (items === undefined || items.length === 0) return null
|
|
689
|
+
return { type: 'list', items, ...(asString(raw.filter, 200) === undefined ? {} : { filter: asString(raw.filter, 200) }) }
|
|
690
|
+
}
|
|
691
|
+
case 'table': {
|
|
692
|
+
const table = normalizeTable(state, path, raw)
|
|
693
|
+
return table
|
|
694
|
+
}
|
|
695
|
+
case 'keyvalue': {
|
|
696
|
+
if (!Array.isArray(raw.pairs)) {
|
|
697
|
+
fail(state, `${path}.pairs`, 'must be an array')
|
|
698
|
+
return null
|
|
699
|
+
}
|
|
700
|
+
const pairs = raw.pairs.slice(0, 20).flatMap((pair, index) => {
|
|
701
|
+
if (!isRecord(pair)) {
|
|
702
|
+
fail(state, `${path}.pairs[${index}]`, 'must be an object')
|
|
703
|
+
return []
|
|
704
|
+
}
|
|
705
|
+
const key = asString(pair.key ?? pair.label, 256)
|
|
706
|
+
const value = asString(pair.value, LIMITS.maxString)
|
|
707
|
+
if (key === undefined || value === undefined) {
|
|
708
|
+
fail(state, `${path}.pairs[${index}]`, 'needs key and value strings')
|
|
709
|
+
return []
|
|
710
|
+
}
|
|
711
|
+
return [{ key, value }]
|
|
712
|
+
})
|
|
713
|
+
if (pairs.length === 0) return null
|
|
714
|
+
return { type: 'keyvalue', pairs }
|
|
715
|
+
}
|
|
716
|
+
case 'steps': {
|
|
717
|
+
if (!Array.isArray(raw.steps)) {
|
|
718
|
+
fail(state, `${path}.steps`, 'must be an array')
|
|
719
|
+
return null
|
|
720
|
+
}
|
|
721
|
+
const steps = raw.steps.slice(0, 12).flatMap((step, index) => {
|
|
722
|
+
if (!isRecord(step)) {
|
|
723
|
+
fail(state, `${path}.steps[${index}]`, 'must be an object')
|
|
724
|
+
return []
|
|
725
|
+
}
|
|
726
|
+
const title = asString(step.title, 256)
|
|
727
|
+
if (title === undefined) {
|
|
728
|
+
fail(state, `${path}.steps[${index}].title`, 'must be a string')
|
|
729
|
+
return []
|
|
730
|
+
}
|
|
731
|
+
return [{ title, ...(asString(step.desc, LIMITS.maxString) === undefined ? {} : { desc: asString(step.desc, LIMITS.maxString) }) }]
|
|
732
|
+
})
|
|
733
|
+
if (steps.length === 0) return null
|
|
734
|
+
return {
|
|
735
|
+
type: 'steps',
|
|
736
|
+
steps,
|
|
737
|
+
...(asInteger(raw.current, 0, steps.length) === undefined ? {} : { current: asInteger(raw.current, 0, steps.length) }),
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
case 'callout': {
|
|
741
|
+
const content = asString(raw.content ?? raw.text ?? raw.body ?? raw.desc, LIMITS.maxString)
|
|
742
|
+
if (content === undefined) {
|
|
743
|
+
fail(state, `${path}.content`, 'must be a string')
|
|
744
|
+
return null
|
|
745
|
+
}
|
|
746
|
+
const toneRaw = raw.tone ?? raw.kind
|
|
747
|
+
const tone = toneRaw === 'danger' ? 'error' : toneRaw === 'warn' ? 'warning' : asEnum(toneRaw, CALLOUT_TONES)
|
|
748
|
+
return {
|
|
749
|
+
type: 'callout',
|
|
750
|
+
content,
|
|
751
|
+
...(tone === undefined ? {} : { tone }),
|
|
752
|
+
...(asString(raw.title, LIMITS.maxString) === undefined ? {} : { title: asString(raw.title, LIMITS.maxString) }),
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
case 'code': {
|
|
756
|
+
const code = asString(raw.code, LIMITS.maxCode)
|
|
757
|
+
if (code === undefined) {
|
|
758
|
+
fail(state, `${path}.code`, 'must be a string')
|
|
759
|
+
return null
|
|
760
|
+
}
|
|
761
|
+
return { type: 'code', code, ...(asString(raw.lang, 32) === undefined ? {} : { lang: asString(raw.lang, 32) }) }
|
|
762
|
+
}
|
|
763
|
+
case 'diff': {
|
|
764
|
+
if (!Array.isArray(raw.diffs)) {
|
|
765
|
+
fail(state, `${path}.diffs`, 'must be an array')
|
|
766
|
+
return null
|
|
767
|
+
}
|
|
768
|
+
const diffs = raw.diffs.slice(0, 8).flatMap((diff, index) => {
|
|
769
|
+
if (!isRecord(diff)) {
|
|
770
|
+
fail(state, `${path}.diffs[${index}]`, 'must be an object')
|
|
771
|
+
return []
|
|
772
|
+
}
|
|
773
|
+
const file = asString(diff.path, 512)
|
|
774
|
+
const newText = asString(diff.newText, LIMITS.maxCode)
|
|
775
|
+
if (file === undefined || newText === undefined) {
|
|
776
|
+
fail(state, `${path}.diffs[${index}]`, 'needs path and newText')
|
|
777
|
+
return []
|
|
778
|
+
}
|
|
779
|
+
return [{
|
|
780
|
+
path: file,
|
|
781
|
+
newText,
|
|
782
|
+
oldText: typeof diff.oldText === 'string' ? diff.oldText.slice(0, LIMITS.maxCode) : null,
|
|
783
|
+
}]
|
|
784
|
+
})
|
|
785
|
+
if (diffs.length === 0) return null
|
|
786
|
+
return { type: 'diff', diffs }
|
|
787
|
+
}
|
|
788
|
+
case 'chart':
|
|
789
|
+
return normalizeChart(state, path, raw)
|
|
790
|
+
case 'image': {
|
|
791
|
+
const src = safeUrl(raw.src ?? raw.url, { media: true })
|
|
792
|
+
if (src === undefined) {
|
|
793
|
+
fail(state, `${path}.src`, 'must be an http(s) or relative URL')
|
|
794
|
+
return null
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
type: 'image',
|
|
798
|
+
src,
|
|
799
|
+
...(asString(raw.alt, LIMITS.maxString) === undefined ? {} : { alt: asString(raw.alt, LIMITS.maxString) }),
|
|
800
|
+
...(asString(raw.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(raw.caption, LIMITS.maxString) }),
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
case 'video': {
|
|
804
|
+
const src = safeUrl(raw.src ?? raw.url, { media: true })
|
|
805
|
+
if (src === undefined) {
|
|
806
|
+
fail(state, `${path}.src`, 'must be an http(s) or relative URL')
|
|
807
|
+
return null
|
|
808
|
+
}
|
|
809
|
+
const poster = raw.poster === undefined ? undefined : safeUrl(raw.poster, { media: true })
|
|
810
|
+
if (raw.poster !== undefined && poster === undefined) {
|
|
811
|
+
fail(state, `${path}.poster`, 'must be a safe media URL')
|
|
812
|
+
return null
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
type: 'video',
|
|
816
|
+
src,
|
|
817
|
+
...(poster === undefined ? {} : { poster }),
|
|
818
|
+
...(asEnum(raw.aspect, ASPECTS) === undefined ? {} : { aspect: asEnum(raw.aspect, ASPECTS) }),
|
|
819
|
+
...(asString(raw.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(raw.caption, LIMITS.maxString) }),
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
case 'audio': {
|
|
823
|
+
const src = safeUrl(raw.src ?? raw.url, { media: true })
|
|
824
|
+
if (src === undefined) {
|
|
825
|
+
fail(state, `${path}.src`, 'must be an http(s) or relative URL')
|
|
826
|
+
return null
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
type: 'audio',
|
|
830
|
+
src,
|
|
831
|
+
...(asString(raw.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(raw.caption, LIMITS.maxString) }),
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
case 'pdf': {
|
|
835
|
+
const src = safeUrl(raw.src ?? raw.url, { media: true })
|
|
836
|
+
if (src === undefined) {
|
|
837
|
+
fail(state, `${path}.src`, 'must be an http(s) or relative URL')
|
|
838
|
+
return null
|
|
839
|
+
}
|
|
840
|
+
return {
|
|
841
|
+
type: 'pdf',
|
|
842
|
+
src,
|
|
843
|
+
...(asString(raw.title, 256) === undefined ? {} : { title: asString(raw.title, 256) }),
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
case 'link': {
|
|
847
|
+
const label = asString(raw.label ?? raw.text, LIMITS.maxString)
|
|
848
|
+
const href = safeUrl(raw.href ?? raw.url)
|
|
849
|
+
if (label === undefined || href === undefined) {
|
|
850
|
+
fail(state, path, 'link requires label and an http(s)/mailto href')
|
|
851
|
+
return null
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
type: 'link',
|
|
855
|
+
label,
|
|
856
|
+
href,
|
|
857
|
+
...(asString(raw.description, LIMITS.maxString) === undefined ? {} : { description: asString(raw.description, LIMITS.maxString) }),
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
case 'button': {
|
|
861
|
+
const label = asString(raw.label ?? raw.text, LIMITS.maxString)
|
|
862
|
+
if (label === undefined) {
|
|
863
|
+
fail(state, `${path}.label`, 'must be a string')
|
|
864
|
+
return null
|
|
865
|
+
}
|
|
866
|
+
const action = asString(raw.action, 200)
|
|
867
|
+
const local = asEnum(raw.local, LOCAL_ACTIONS)
|
|
868
|
+
if (local === 'copy' && typeof raw.text !== 'string') {
|
|
869
|
+
fail(state, `${path}.text`, 'is required for local copy')
|
|
870
|
+
return null
|
|
871
|
+
}
|
|
872
|
+
if (local === 'open' && safeUrl(raw.url) === undefined) {
|
|
873
|
+
fail(state, `${path}.url`, 'is required for local open')
|
|
874
|
+
return null
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
type: 'button',
|
|
878
|
+
label,
|
|
879
|
+
...(action === undefined ? {} : { action }),
|
|
880
|
+
...(local === undefined ? {} : { local }),
|
|
881
|
+
...(raw.text === undefined ? {} : { text: asString(raw.text, LIMITS.maxCode) }),
|
|
882
|
+
...(raw.url === undefined ? {} : { url: asString(raw.url, 2048) }),
|
|
883
|
+
...(asEnum(raw.tone, BUTTON_TONES) === undefined ? {} : { tone: asEnum(raw.tone, BUTTON_TONES) }),
|
|
884
|
+
...(asBoolean(raw.full) === true ? { full: true } : {}),
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
case 'input': {
|
|
888
|
+
return {
|
|
889
|
+
type: 'input',
|
|
890
|
+
...(asString(raw.id, 128) === undefined ? {} : { id: asString(raw.id, 128) }),
|
|
891
|
+
...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
|
|
892
|
+
...(asString(raw.placeholder, LIMITS.maxString) === undefined ? {} : { placeholder: asString(raw.placeholder, LIMITS.maxString) }),
|
|
893
|
+
...(asString(raw.value, LIMITS.maxString) === undefined ? {} : { value: asString(raw.value, LIMITS.maxString) }),
|
|
894
|
+
...(asString(raw.action, 200) === undefined ? {} : { action: asString(raw.action, 200) }),
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
case 'select': {
|
|
898
|
+
const options = takeStringList(state, `${path}.options`, raw.options, 256, 20)
|
|
899
|
+
if (options === undefined || options.length === 0) return null
|
|
900
|
+
return {
|
|
901
|
+
type: 'select',
|
|
902
|
+
options,
|
|
903
|
+
...(asString(raw.id, 128) === undefined ? {} : { id: asString(raw.id, 128) }),
|
|
904
|
+
...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
|
|
905
|
+
...(asInteger(raw.selected, 0, options.length - 1) === undefined ? {} : { selected: asInteger(raw.selected, 0, options.length - 1) }),
|
|
906
|
+
...(asString(raw.action, 200) === undefined ? {} : { action: asString(raw.action, 200) }),
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
case 'slider': {
|
|
910
|
+
const min = asNumber(raw.min, -1e9, 1e9) ?? 0
|
|
911
|
+
const max = asNumber(raw.max, -1e9, 1e9) ?? 100
|
|
912
|
+
const lo = Math.min(min, max)
|
|
913
|
+
const hi = Math.max(min, max)
|
|
914
|
+
return {
|
|
915
|
+
type: 'slider',
|
|
916
|
+
min: lo,
|
|
917
|
+
max: hi,
|
|
918
|
+
value: asNumber(raw.value, lo, hi) ?? lo,
|
|
919
|
+
...(asNumber(raw.step, 1e-9, Math.max(hi - lo, 1e-9)) === undefined ? {} : { step: asNumber(raw.step, 1e-9, Math.max(hi - lo, 1e-9)) }),
|
|
920
|
+
...(asString(raw.id, 128) === undefined ? {} : { id: asString(raw.id, 128) }),
|
|
921
|
+
...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
|
|
922
|
+
...(asString(raw.action, 200) === undefined ? {} : { action: asString(raw.action, 200) }),
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
case 'tabs': {
|
|
926
|
+
if (!Array.isArray(raw.tabs)) {
|
|
927
|
+
fail(state, `${path}.tabs`, 'must be an array')
|
|
928
|
+
return null
|
|
929
|
+
}
|
|
930
|
+
const tabs = raw.tabs.slice(0, LIMITS.maxTabs).flatMap((tab, index) => {
|
|
931
|
+
if (!isRecord(tab)) {
|
|
932
|
+
fail(state, `${path}.tabs[${index}]`, 'must be an object')
|
|
933
|
+
return []
|
|
934
|
+
}
|
|
935
|
+
const label = asString(tab.label, 128)
|
|
936
|
+
if (label === undefined) {
|
|
937
|
+
fail(state, `${path}.tabs[${index}].label`, 'must be a string')
|
|
938
|
+
return []
|
|
939
|
+
}
|
|
940
|
+
const items = takeNodeList(state, `${path}.tabs[${index}].items`, tab.items ?? [], depth)
|
|
941
|
+
return [{ label, items: items ?? [] }]
|
|
942
|
+
})
|
|
943
|
+
if (tabs.length === 0) return null
|
|
944
|
+
return { type: 'tabs', tabs }
|
|
945
|
+
}
|
|
946
|
+
case 'quiz': {
|
|
947
|
+
const question = asString(raw.question, LIMITS.maxString)
|
|
948
|
+
if (question === undefined || !Array.isArray(raw.options)) {
|
|
949
|
+
fail(state, path, 'quiz requires question and options')
|
|
950
|
+
return null
|
|
951
|
+
}
|
|
952
|
+
const options = raw.options.slice(0, LIMITS.maxQuizOptions).flatMap((option, index) => {
|
|
953
|
+
if (typeof option === 'string') return [{ label: option.slice(0, 512) }]
|
|
954
|
+
if (!isRecord(option)) {
|
|
955
|
+
fail(state, `${path}.options[${index}]`, 'must be a string or object')
|
|
956
|
+
return []
|
|
957
|
+
}
|
|
958
|
+
const label = asString(option.label, 512)
|
|
959
|
+
if (label === undefined) {
|
|
960
|
+
fail(state, `${path}.options[${index}].label`, 'must be a string')
|
|
961
|
+
return []
|
|
962
|
+
}
|
|
963
|
+
return [{
|
|
964
|
+
label,
|
|
965
|
+
...(asBoolean(option.correct) === true ? { correct: true } : {}),
|
|
966
|
+
...(asString(option.feedback, LIMITS.maxString) === undefined ? {} : { feedback: asString(option.feedback, LIMITS.maxString) }),
|
|
967
|
+
}]
|
|
968
|
+
})
|
|
969
|
+
if (options.length < 2) {
|
|
970
|
+
fail(state, `${path}.options`, 'needs at least two options')
|
|
971
|
+
return null
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
type: 'quiz',
|
|
975
|
+
question,
|
|
976
|
+
options,
|
|
977
|
+
...(asString(raw.explanation, LIMITS.maxString) === undefined ? {} : { explanation: asString(raw.explanation, LIMITS.maxString) }),
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
case 'widget':
|
|
981
|
+
return normalizeWidget(state, path, raw)
|
|
982
|
+
case 'diagram':
|
|
983
|
+
return normalizeDiagram(state, path, raw)
|
|
984
|
+
default:
|
|
985
|
+
fail(state, `${path}.type`, `unknown component type '${type}'`)
|
|
986
|
+
return null
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Validate and repair a raw view spec.
|
|
992
|
+
*
|
|
993
|
+
* @param {unknown} value Raw spec (object, JSON string, or single component).
|
|
994
|
+
* @returns {{ spec: object | null, errors: string[], warnings: string[] }}
|
|
995
|
+
*/
|
|
996
|
+
export function normalizeView(value) {
|
|
997
|
+
const state = { count: 0, errors: [], warnings: [] }
|
|
998
|
+
let root = value
|
|
999
|
+
if (typeof root === 'string') {
|
|
1000
|
+
try {
|
|
1001
|
+
root = JSON.parse(root)
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
return { spec: null, errors: [`invalid JSON: ${error instanceof Error ? error.message : String(error)}`], warnings: [] }
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (Array.isArray(root)) root = { items: root }
|
|
1007
|
+
if (looksLikeNode(root)) root = { items: [root] }
|
|
1008
|
+
if (!isRecord(root)) {
|
|
1009
|
+
return { spec: null, errors: ['spec root must be an object'], warnings: [] }
|
|
1010
|
+
}
|
|
1011
|
+
if (!Array.isArray(root.items)) {
|
|
1012
|
+
return { spec: null, errors: ['spec.items must be an array'], warnings: [] }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const items = []
|
|
1016
|
+
for (let index = 0; index < root.items.length; index += 1) {
|
|
1017
|
+
const node = repairNode(state, root.items[index], `items[${index}]`, 0)
|
|
1018
|
+
if (node !== null) items.push(node)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const spec = state.errors.length === 0 && items.length > 0
|
|
1022
|
+
? {
|
|
1023
|
+
...(asString(root.title, LIMITS.maxString) === undefined ? {} : { title: asString(root.title, LIMITS.maxString) }),
|
|
1024
|
+
...(asNumber(root.gap, 0, 48) === undefined ? {} : { gap: asNumber(root.gap, 0, 48) }),
|
|
1025
|
+
items,
|
|
1026
|
+
}
|
|
1027
|
+
: null
|
|
1028
|
+
|
|
1029
|
+
return { spec, errors: state.errors, warnings: state.warnings }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Deep-copy a value into lossless JSON: drops undefined/function/symbol
|
|
1034
|
+
* properties and non-finite numbers. The Harness rejects a tool
|
|
1035
|
+
* `presentationMeta` that is not lossless JSON, so root and nested optional
|
|
1036
|
+
* fields must never carry `undefined` keys.
|
|
1037
|
+
*/
|
|
1038
|
+
export function jsonSafe(value) {
|
|
1039
|
+
if (value === null) return null
|
|
1040
|
+
if (Array.isArray(value)) {
|
|
1041
|
+
const out = []
|
|
1042
|
+
for (const item of value) {
|
|
1043
|
+
const safe = jsonSafe(item)
|
|
1044
|
+
if (safe !== undefined) out.push(safe)
|
|
1045
|
+
}
|
|
1046
|
+
return out
|
|
1047
|
+
}
|
|
1048
|
+
if (typeof value === 'object') {
|
|
1049
|
+
const out = {}
|
|
1050
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1051
|
+
if (child === undefined || typeof child === 'function' || typeof child === 'symbol') continue
|
|
1052
|
+
const safe = jsonSafe(child)
|
|
1053
|
+
if (safe !== undefined) out[key] = safe
|
|
1054
|
+
}
|
|
1055
|
+
return out
|
|
1056
|
+
}
|
|
1057
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
|
|
1058
|
+
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') return undefined
|
|
1059
|
+
return value
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Whether a normalized result can be rendered. */
|
|
1063
|
+
export function isRenderable(result) {
|
|
1064
|
+
return result.spec !== null && result.errors.length === 0 && result.spec.items.length > 0
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** A compact human-readable failure for a tool protocol. */
|
|
1068
|
+
export function describeFailure(result) {
|
|
1069
|
+
return result.errors.length > 0 ? result.errors.join('; ') : 'empty spec'
|
|
1070
|
+
}
|