@pgcorp/ui-kit 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +332 -9
  2. package/docs/getting-started.md +135 -0
  3. package/docs/public-api.md +53 -4
  4. package/docs/rich-document.md +216 -0
  5. package/docs/scroll-viewport.md +167 -0
  6. package/docs/theming.md +17 -0
  7. package/package.json +108 -50
  8. package/src/components/layout/SAppShell.vue +3 -1
  9. package/src/components/layout/SDockRegion.vue +5 -3
  10. package/src/components/layout/SResizeHandle.vue +45 -5
  11. package/src/components/layout/SWorkbenchPanelShell.vue +4 -2
  12. package/src/components/shared/_internal/SDialogSurface.css +23 -0
  13. package/src/components/shared/_internal/SDialogSurface.vue +9 -2
  14. package/src/components/shared/_internal/SToastItem.css +4 -0
  15. package/src/components/shared/_internal/SToastItem.vue +44 -8
  16. package/src/components/shared/_internal/STreeNode.vue +24 -11
  17. package/src/components/shared/_internal/dialogSurface.ts +7 -1
  18. package/src/components/shared/complex/SKanbanLane.vue +4 -2
  19. package/src/components/shared/containers/SCloudChromeRow.vue +3 -2
  20. package/src/components/shared/containers/SDrawer.css +11 -2
  21. package/src/components/shared/containers/SDrawer.vue +48 -9
  22. package/src/components/shared/containers/SModal.vue +5 -2
  23. package/src/components/shared/containers/SPanel.vue +4 -2
  24. package/src/components/shared/containers/SPopover.vue +7 -3
  25. package/src/components/shared/containers/SUiKitLocaleProvider.vue +57 -0
  26. package/src/components/shared/controls/SButton.css +14 -10
  27. package/src/components/shared/controls/SCombobox.vue +8 -6
  28. package/src/components/shared/controls/SComboboxTrigger.css +7 -4
  29. package/src/components/shared/controls/SComboboxTrigger.vue +5 -2
  30. package/src/components/shared/controls/SContextToggleButton.vue +6 -4
  31. package/src/components/shared/controls/SDragHandle.vue +8 -4
  32. package/src/components/shared/controls/SDropdownMenu.vue +7 -4
  33. package/src/components/shared/controls/SInputText.css +5 -4
  34. package/src/components/shared/controls/SListbox.vue +4 -2
  35. package/src/components/shared/controls/SSelect.vue +4 -2
  36. package/src/components/shared/controls/STextarea.ts +13 -0
  37. package/src/components/shared/controls/STextarea.vue +391 -37
  38. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +4 -2
  39. package/src/components/shared/data-display/SChart.vue +7 -5
  40. package/src/components/shared/data-display/SChip.vue +3 -1
  41. package/src/components/shared/data-display/SCodeBlock.css +4 -0
  42. package/src/components/shared/data-display/SCodeBlock.vue +46 -22
  43. package/src/components/shared/data-display/SCodeEditor.css +17 -0
  44. package/src/components/shared/data-display/SCodeEditor.vue +173 -8
  45. package/src/components/shared/data-display/SCodeSearchPanel.vue +48 -33
  46. package/src/components/shared/data-display/SCodeWorkspaceEditor.css +13 -0
  47. package/src/components/shared/data-display/SCodeWorkspaceEditor.vue +355 -0
  48. package/src/components/shared/data-display/SControlledToastViewport.css +89 -0
  49. package/src/components/shared/data-display/SControlledToastViewport.vue +205 -0
  50. package/src/components/shared/data-display/SCopyField.vue +12 -10
  51. package/src/components/shared/data-display/SDiffViewer.css +122 -0
  52. package/src/components/shared/data-display/SDiffViewer.vue +499 -0
  53. package/src/components/shared/data-display/SDocBlock.vue +99 -71
  54. package/src/components/shared/data-display/SExpandableText.vue +10 -8
  55. package/src/components/shared/data-display/SJsonTree.vue +4 -2
  56. package/src/components/shared/data-display/SLinkedSystemsList.vue +4 -2
  57. package/src/components/shared/data-display/SMessage.vue +6 -6
  58. package/src/components/shared/data-display/SRichDocument.css +8 -0
  59. package/src/components/shared/data-display/SRichDocument.vue +65 -0
  60. package/src/components/shared/data-display/SScrollViewport.css +53 -0
  61. package/src/components/shared/data-display/SScrollViewport.vue +470 -0
  62. package/src/components/shared/data-display/STable.vue +38 -19
  63. package/src/components/shared/data-display/SToastContainer.vue +22 -17
  64. package/src/components/shared/data-display/SVirtualList.ts +33 -0
  65. package/src/components/shared/data-display/SVirtualList.vue +321 -36
  66. package/src/components/shared/data-display/_internal/SCodeTokenLine.css +72 -0
  67. package/src/components/shared/data-display/_internal/SCodeTokenLine.vue +79 -0
  68. package/src/components/shared/data-display/_internal/SRichBlockNode.css +85 -0
  69. package/src/components/shared/data-display/_internal/SRichBlockNode.vue +282 -0
  70. package/src/components/shared/data-display/_internal/SRichInlineNode.css +6 -0
  71. package/src/components/shared/data-display/_internal/SRichInlineNode.vue +76 -0
  72. package/src/components/shared/data-display/_internal/SRichMath.css +42 -0
  73. package/src/components/shared/data-display/_internal/SRichMath.vue +102 -0
  74. package/src/components/shared/data-display/codeEditor.ts +200 -0
  75. package/src/components/shared/data-display/codeWorkspace.ts +233 -0
  76. package/src/components/shared/data-display/diff.ts +541 -0
  77. package/src/components/shared/data-display/richDocument.ts +876 -0
  78. package/src/components/shared/data-display/scrollViewport.ts +47 -0
  79. package/src/components/shared/data-display/toast.ts +194 -0
  80. package/src/components/shared/database/SDataGrid.vue +7 -4
  81. package/src/components/shared/database/SSqlEditor.vue +39 -39
  82. package/src/components/shared/feedback/SAsyncState.vue +3 -1
  83. package/src/components/shared/graph/SGraphViewport.vue +11 -9
  84. package/src/components/shared/navigation/SBreadcrumbs.vue +8 -5
  85. package/src/components/shared/navigation/SCatalogNavigator.vue +26 -20
  86. package/src/components/shared/navigation/STabList.vue +10 -6
  87. package/src/components/shared/navigation/STabs.vue +15 -11
  88. package/src/components/shared/navigation/SWizardSteps.vue +4 -2
  89. package/src/components/shared/persona/SPersonaProjectPicker.vue +31 -27
  90. package/src/components/shared/persona/SPersonaRunWorkbench.vue +39 -33
  91. package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +8 -6
  92. package/src/components/shared/persona/_internal/labels.ts +66 -57
  93. package/src/components/shared/persona/presentation.ts +140 -110
  94. package/src/composables/useFloatingPosition.ts +39 -2
  95. package/src/composables/useUiKitLocale.ts +17 -0
  96. package/src/internal/codeHighlight.ts +110 -0
  97. package/src/internal/diffEngine.ts +464 -0
  98. package/src/internal/diffRuntime.ts +189 -0
  99. package/src/internal/feedbackPresentation.ts +33 -14
  100. package/src/internal/localeContext.ts +22 -0
  101. package/src/internal/ownedAttrs.ts +8 -0
  102. package/src/internal/runtimeContract.ts +11 -9
  103. package/src/internal/semanticSizing.ts +13 -0
  104. package/src/locales/catalog.ts +634 -0
  105. package/src/locales/locale.ts +472 -0
  106. package/src/locales/messages.generated.ts +1826 -0
  107. package/src/locales/messages.ts +150 -0
  108. package/src/stores/useNotifierStore.ts +61 -44
  109. package/src/styles/tokens.css +23 -2
  110. package/src/theme.ts +59 -32
  111. package/src/workers/diff.worker.ts +73 -0
  112. package/src/workers/diffProtocol.ts +155 -0
  113. package/src/components/shared/data-display/SToastContainer.css +0 -53
@@ -0,0 +1,876 @@
1
+ export const S_RICH_DOCUMENT_VERSION = 1 as const
2
+ export const S_RICH_DOCUMENT_MAX_NODES = 10_000
3
+ export const S_RICH_DOCUMENT_MAX_DEPTH = 32
4
+ export const S_RICH_DOCUMENT_MAX_TEXT_LENGTH = 1_048_576
5
+
6
+ export type SRichDocumentContractErrorCode =
7
+ | 'cyclic_value'
8
+ | 'duplicate_id'
9
+ | 'invalid_structure'
10
+ | 'invalid_type'
11
+ | 'invalid_value'
12
+ | 'limit_exceeded'
13
+ | 'missing_resource'
14
+ | 'unknown_field'
15
+ | 'unknown_node_type'
16
+ | 'unsafe_url'
17
+ | 'unsupported_version'
18
+
19
+ /** Typed contract failure with a stable machine code and exact source coordinate. */
20
+ export class SRichDocumentContractError extends TypeError {
21
+ readonly code: SRichDocumentContractErrorCode
22
+ readonly path: string
23
+
24
+ constructor(code: SRichDocumentContractErrorCode, path: string, detail: string) {
25
+ super(`SRichDocument [${code}] at ${path}: ${detail}`)
26
+ this.name = 'SRichDocumentContractError'
27
+ this.code = code
28
+ this.path = path
29
+ }
30
+ }
31
+
32
+ export interface SRichTextNode {
33
+ readonly type: 'text'
34
+ readonly value: string
35
+ }
36
+
37
+ export interface SRichStrongNode {
38
+ readonly type: 'strong'
39
+ readonly children: readonly SRichInlineNode[]
40
+ }
41
+
42
+ export interface SRichEmphasisNode {
43
+ readonly type: 'emphasis'
44
+ readonly children: readonly SRichInlineNode[]
45
+ }
46
+
47
+ export interface SRichInlineCodeNode {
48
+ readonly type: 'inlineCode'
49
+ readonly code: string
50
+ }
51
+
52
+ export interface SRichLinkNode {
53
+ readonly type: 'link'
54
+ readonly href: string
55
+ readonly title?: string
56
+ readonly current?: boolean
57
+ readonly children: readonly SRichInlineNode[]
58
+ }
59
+
60
+ export interface SRichMathInlineNode {
61
+ readonly type: 'mathInline'
62
+ readonly source: string
63
+ }
64
+
65
+ /** Closed inline content union. Raw HTML, VNodes, callbacks, and trust flags are intentionally absent. */
66
+ export type SRichInlineNode =
67
+ | SRichTextNode
68
+ | SRichStrongNode
69
+ | SRichEmphasisNode
70
+ | SRichInlineCodeNode
71
+ | SRichLinkNode
72
+ | SRichMathInlineNode
73
+
74
+ export interface SRichParagraphNode {
75
+ readonly type: 'paragraph'
76
+ readonly children: readonly SRichInlineNode[]
77
+ }
78
+
79
+ export interface SRichHeadingNode {
80
+ readonly type: 'heading'
81
+ readonly level: 1 | 2 | 3 | 4 | 5 | 6
82
+ readonly children: readonly SRichInlineNode[]
83
+ }
84
+
85
+ export interface SRichListItemNode {
86
+ readonly type: 'listItem'
87
+ readonly children: readonly SRichBlockNode[]
88
+ }
89
+
90
+ export interface SRichListNode {
91
+ readonly type: 'list'
92
+ readonly ordered: boolean
93
+ readonly start?: number
94
+ readonly children: readonly SRichListItemNode[]
95
+ }
96
+
97
+ export interface SRichQuoteNode {
98
+ readonly type: 'quote'
99
+ readonly children: readonly SRichBlockNode[]
100
+ }
101
+
102
+ export interface SRichDividerNode {
103
+ readonly type: 'divider'
104
+ }
105
+
106
+ export interface SRichImageNode {
107
+ readonly type: 'image'
108
+ readonly resourceId: string
109
+ readonly caption?: readonly SRichInlineNode[]
110
+ }
111
+
112
+ export interface SRichResourceNode {
113
+ readonly type: 'resource'
114
+ readonly resourceId: string
115
+ readonly label: string
116
+ readonly description?: string
117
+ }
118
+
119
+ export interface SRichCodeBlockNode {
120
+ readonly type: 'codeBlock'
121
+ readonly code: string
122
+ readonly language?: string
123
+ readonly label?: string
124
+ readonly showLineNumbers?: boolean
125
+ }
126
+
127
+ export interface SRichMathBlockNode {
128
+ readonly type: 'mathBlock'
129
+ readonly source: string
130
+ readonly label?: string
131
+ }
132
+
133
+ export type SRichTableAlignment = 'left' | 'center' | 'right'
134
+ export type SRichTableHeaderPresentation = 'visible' | 'assistive'
135
+
136
+ export interface SRichTableColumn {
137
+ readonly id: string
138
+ readonly header: string
139
+ readonly headerPresentation?: SRichTableHeaderPresentation
140
+ readonly align?: SRichTableAlignment
141
+ }
142
+
143
+ export interface SRichTableCell {
144
+ readonly children: readonly SRichInlineNode[]
145
+ }
146
+
147
+ export interface SRichTableRow {
148
+ readonly id: string
149
+ readonly cells: readonly SRichTableCell[]
150
+ }
151
+
152
+ export interface SRichTableNode {
153
+ readonly type: 'table'
154
+ readonly label: string
155
+ readonly columns: readonly SRichTableColumn[]
156
+ readonly rows: readonly SRichTableRow[]
157
+ }
158
+
159
+ export type SRichCalloutTone = 'neutral' | 'information' | 'success' | 'warning' | 'danger'
160
+
161
+ export interface SRichCalloutNode {
162
+ readonly type: 'callout'
163
+ readonly tone: SRichCalloutTone
164
+ readonly title?: string
165
+ readonly children: readonly SRichBlockNode[]
166
+ }
167
+
168
+ export type SRichActionAppearance = 'primary' | 'secondary' | 'danger'
169
+
170
+ export interface SRichActionNode {
171
+ readonly type: 'action'
172
+ readonly id: string
173
+ readonly label: string
174
+ readonly appearance?: SRichActionAppearance
175
+ readonly disabled?: boolean
176
+ }
177
+
178
+ export interface SRichActionGroupNode {
179
+ readonly type: 'actionGroup'
180
+ readonly label?: string
181
+ readonly actions: readonly SRichActionNode[]
182
+ }
183
+
184
+ /** Closed block content union accepted by the renderer. */
185
+ export type SRichBlockNode =
186
+ | SRichParagraphNode
187
+ | SRichHeadingNode
188
+ | SRichListNode
189
+ | SRichQuoteNode
190
+ | SRichDividerNode
191
+ | SRichImageNode
192
+ | SRichResourceNode
193
+ | SRichCodeBlockNode
194
+ | SRichMathBlockNode
195
+ | SRichTableNode
196
+ | SRichCalloutNode
197
+ | SRichActionGroupNode
198
+
199
+ export type SRichDocumentNode = SRichInlineNode | SRichBlockNode | SRichListItemNode | SRichActionNode
200
+
201
+ /** Versioned, serializable rich-document input. */
202
+ export interface SRichDocumentV1 {
203
+ readonly version: typeof S_RICH_DOCUMENT_VERSION
204
+ readonly id: string
205
+ readonly nodes: readonly SRichBlockNode[]
206
+ }
207
+
208
+ interface SRichDocumentResourceBase {
209
+ readonly id: string
210
+ readonly url: string
211
+ readonly title?: string
212
+ }
213
+
214
+ export interface SRichDocumentImageResource extends SRichDocumentResourceBase {
215
+ readonly kind: 'image'
216
+ readonly alt: string
217
+ }
218
+
219
+ export interface SRichDocumentFileResource extends SRichDocumentResourceBase {
220
+ readonly kind: 'file'
221
+ readonly label: string
222
+ readonly mediaType?: string
223
+ readonly byteSize?: number
224
+ }
225
+
226
+ /** Consumer-owned resource descriptor resolved independently from the document AST. */
227
+ export type SRichDocumentResource = SRichDocumentImageResource | SRichDocumentFileResource
228
+
229
+ /** Fully validated renderer input with resource references resolved by stable IDs. */
230
+ export interface SRichDocumentModel {
231
+ readonly document: SRichDocumentV1
232
+ readonly resources: readonly SRichDocumentResource[]
233
+ }
234
+
235
+ export interface SRichDocumentLinkActivate {
236
+ readonly documentId: string
237
+ readonly path: string
238
+ readonly href: string
239
+ readonly title?: string
240
+ }
241
+
242
+ export interface SRichDocumentResourceActivate {
243
+ readonly documentId: string
244
+ readonly path: string
245
+ readonly resourceId: string
246
+ }
247
+
248
+ export interface SRichDocumentActionIntent {
249
+ readonly documentId: string
250
+ readonly path: string
251
+ readonly actionId: string
252
+ }
253
+
254
+ type PlainRecord = Readonly<Record<string, unknown>>
255
+
256
+ interface ValidationContext {
257
+ readonly ancestors: Set<object>
258
+ readonly actionIds: Set<string>
259
+ nodeCount: number
260
+ }
261
+
262
+ const DOCUMENT_FIELDS = new Set(['version', 'id', 'nodes'])
263
+ const RESOURCE_FIELDS = new Set(['id', 'kind', 'url', 'title', 'alt', 'label', 'mediaType', 'byteSize'])
264
+ const NODE_FIELDS = {
265
+ action: new Set(['type', 'id', 'label', 'appearance', 'disabled']),
266
+ actionGroup: new Set(['type', 'label', 'actions']),
267
+ callout: new Set(['type', 'tone', 'title', 'children']),
268
+ codeBlock: new Set(['type', 'code', 'language', 'label', 'showLineNumbers']),
269
+ divider: new Set(['type']),
270
+ emphasis: new Set(['type', 'children']),
271
+ heading: new Set(['type', 'level', 'children']),
272
+ image: new Set(['type', 'resourceId', 'caption']),
273
+ inlineCode: new Set(['type', 'code']),
274
+ link: new Set(['type', 'href', 'title', 'current', 'children']),
275
+ list: new Set(['type', 'ordered', 'start', 'children']),
276
+ listItem: new Set(['type', 'children']),
277
+ mathBlock: new Set(['type', 'source', 'label']),
278
+ mathInline: new Set(['type', 'source']),
279
+ paragraph: new Set(['type', 'children']),
280
+ quote: new Set(['type', 'children']),
281
+ resource: new Set(['type', 'resourceId', 'label', 'description']),
282
+ strong: new Set(['type', 'children']),
283
+ table: new Set(['type', 'label', 'columns', 'rows']),
284
+ text: new Set(['type', 'value']),
285
+ } as const
286
+ const TABLE_COLUMN_FIELDS = new Set(['id', 'header', 'headerPresentation', 'align'])
287
+ const TABLE_ROW_FIELDS = new Set(['id', 'cells'])
288
+ const TABLE_CELL_FIELDS = new Set(['children'])
289
+ const INLINE_NODE_TYPES = new Set(['text', 'strong', 'emphasis', 'inlineCode', 'link', 'mathInline'])
290
+ const BLOCK_NODE_TYPES = new Set([
291
+ 'paragraph', 'heading', 'list', 'quote', 'divider', 'image', 'resource', 'codeBlock',
292
+ 'mathBlock', 'table', 'callout', 'actionGroup',
293
+ ])
294
+ const CALLOUT_TONES = new Set<SRichCalloutTone>(['neutral', 'information', 'success', 'warning', 'danger'])
295
+ const ACTION_APPEARANCES = new Set<SRichActionAppearance>(['primary', 'secondary', 'danger'])
296
+ const TABLE_ALIGNMENTS = new Set<SRichTableAlignment>(['left', 'center', 'right'])
297
+ const HEADER_PRESENTATIONS = new Set<SRichTableHeaderPresentation>(['visible', 'assistive'])
298
+
299
+ function fail(code: SRichDocumentContractErrorCode, path: string, detail: string): never {
300
+ throw new SRichDocumentContractError(code, path, detail)
301
+ }
302
+
303
+ function enter(value: object, path: string, context: ValidationContext): void {
304
+ if (context.ancestors.has(value)) fail('cyclic_value', path, 'cyclic values are not allowed')
305
+ context.ancestors.add(value)
306
+ }
307
+
308
+ function readRecord(value: unknown, path: string, context: ValidationContext): PlainRecord {
309
+ if (
310
+ typeof value !== 'object'
311
+ || value === null
312
+ || Array.isArray(value)
313
+ || Object.getPrototypeOf(value) !== Object.prototype
314
+ ) {
315
+ fail('invalid_type', path, 'expected a plain object')
316
+ }
317
+ enter(value, path, context)
318
+ const record: Record<string, unknown> = {}
319
+ for (const key of Reflect.ownKeys(value)) {
320
+ if (typeof key !== 'string') fail('unknown_field', path, 'symbol fields are not allowed')
321
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
322
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
323
+ fail('invalid_structure', `${path}.${key}`, 'expected an enumerable data property')
324
+ }
325
+ record[key] = descriptor.value
326
+ }
327
+ return record
328
+ }
329
+
330
+ function leave(value: object, context: ValidationContext): void {
331
+ context.ancestors.delete(value)
332
+ }
333
+
334
+ function assertFields(record: PlainRecord, path: string, fields: ReadonlySet<string>): void {
335
+ const unexpected = Object.keys(record).filter((field) => !fields.has(field))
336
+ if (unexpected.length > 0) {
337
+ fail('unknown_field', `${path}.${unexpected[0]}`, 'field is not part of the closed contract')
338
+ }
339
+ }
340
+
341
+ function readArray(
342
+ value: unknown,
343
+ path: string,
344
+ context: ValidationContext,
345
+ bounds: { readonly minimum: number; readonly maximum: number },
346
+ ): readonly unknown[] {
347
+ if (!Array.isArray(value)) fail('invalid_type', path, 'expected an array')
348
+ enter(value, path, context)
349
+ if (value.length < bounds.minimum) fail('invalid_value', path, `expected at least ${bounds.minimum} items`)
350
+ if (value.length > bounds.maximum) fail('limit_exceeded', path, `maximum is ${bounds.maximum} items`)
351
+ const items: unknown[] = []
352
+ for (const key of Reflect.ownKeys(value)) {
353
+ if (key === 'length') continue
354
+ if (typeof key !== 'string' || !/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length) {
355
+ fail('unknown_field', path, `array field ${String(key)} is not allowed`)
356
+ }
357
+ }
358
+ for (let index = 0; index < value.length; index += 1) {
359
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
360
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
361
+ fail('invalid_structure', `${path}[${index}]`, 'sparse arrays and accessors are not allowed')
362
+ }
363
+ items.push(descriptor.value)
364
+ }
365
+ return items
366
+ }
367
+
368
+ function readString(
369
+ value: unknown,
370
+ path: string,
371
+ options: { readonly allowEmpty?: boolean; readonly stable?: boolean; readonly maximum?: number } = {},
372
+ ): string {
373
+ if (typeof value !== 'string') fail('invalid_type', path, 'expected a string')
374
+ const maximum = options.maximum ?? S_RICH_DOCUMENT_MAX_TEXT_LENGTH
375
+ if (!options.allowEmpty && value.length === 0) fail('invalid_value', path, 'expected a non-empty string')
376
+ if (value.length > maximum) fail('limit_exceeded', path, `maximum length is ${maximum}`)
377
+ if (options.stable && value !== value.trim()) fail('invalid_value', path, 'surrounding whitespace is not allowed')
378
+ return value
379
+ }
380
+
381
+ function readOptionalString(value: unknown, path: string): string | undefined {
382
+ return value === undefined ? undefined : readString(value, path)
383
+ }
384
+
385
+ function readBoolean(value: unknown, path: string): boolean {
386
+ if (typeof value !== 'boolean') fail('invalid_type', path, 'expected a boolean')
387
+ return value
388
+ }
389
+
390
+ function readInteger(value: unknown, path: string, minimum: number, maximum: number): number {
391
+ if (!Number.isSafeInteger(value) || typeof value !== 'number') {
392
+ fail('invalid_type', path, 'expected a safe integer')
393
+ }
394
+ if (value < minimum || value > maximum) fail('invalid_value', path, `expected ${minimum}..${maximum}`)
395
+ return value
396
+ }
397
+
398
+ function readEnum<Value extends string>(
399
+ value: unknown,
400
+ path: string,
401
+ values: ReadonlySet<Value>,
402
+ ): Value {
403
+ if (typeof value !== 'string' || !values.has(value as Value)) {
404
+ fail('invalid_value', path, `expected one of ${[...values].join(', ')}`)
405
+ }
406
+ return value as Value
407
+ }
408
+
409
+ function validateUrl(value: unknown, path: string, kind: 'link' | 'resource'): string {
410
+ const url = readString(value, path, { stable: true, maximum: 8_192 })
411
+ if (/\s|[\u0000-\u001F\u007F]/u.test(url) || url.includes('\\')) {
412
+ fail('unsafe_url', path, 'whitespace, control characters, and backslashes are not allowed')
413
+ }
414
+ if (url.startsWith('//')) fail('unsafe_url', path, 'scheme-relative URLs are not allowed')
415
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/u.exec(url)?.[1]?.toLowerCase()
416
+ if (scheme === undefined) {
417
+ if (kind === 'resource' && url.startsWith('#')) fail('unsafe_url', path, 'fragment-only resource URLs are not allowed')
418
+ return url
419
+ }
420
+ const allowed = kind === 'link'
421
+ ? new Set(['http', 'https', 'mailto', 'tel'])
422
+ : new Set(['http', 'https', 'blob'])
423
+ if (!allowed.has(scheme)) fail('unsafe_url', path, `protocol ${scheme}: is not allowed for ${kind}`)
424
+ let parsed: URL
425
+ try {
426
+ parsed = new URL(url)
427
+ } catch {
428
+ fail('unsafe_url', path, 'URL syntax is invalid')
429
+ }
430
+ if ((scheme === 'http' || scheme === 'https') && (parsed.username.length > 0 || parsed.password.length > 0)) {
431
+ fail('unsafe_url', path, 'URL credentials are not allowed')
432
+ }
433
+ return url
434
+ }
435
+
436
+ function validateInlineChildren(
437
+ value: unknown,
438
+ path: string,
439
+ context: ValidationContext,
440
+ depth: number,
441
+ insideLink: boolean,
442
+ minimum = 1,
443
+ ): readonly SRichInlineNode[] {
444
+ const source = readArray(value, path, context, { minimum, maximum: S_RICH_DOCUMENT_MAX_NODES })
445
+ try {
446
+ return Object.freeze(source.map((item, index) => validateNode(
447
+ item,
448
+ `${path}[${index}]`,
449
+ context,
450
+ depth,
451
+ 'inline',
452
+ insideLink,
453
+ ) as SRichInlineNode))
454
+ } finally {
455
+ leave(value as object, context)
456
+ }
457
+ }
458
+
459
+ function validateBlockChildren(
460
+ value: unknown,
461
+ path: string,
462
+ context: ValidationContext,
463
+ depth: number,
464
+ minimum = 1,
465
+ ): readonly SRichBlockNode[] {
466
+ const source = readArray(value, path, context, { minimum, maximum: S_RICH_DOCUMENT_MAX_NODES })
467
+ try {
468
+ return Object.freeze(source.map((item, index) => validateNode(
469
+ item,
470
+ `${path}[${index}]`,
471
+ context,
472
+ depth,
473
+ 'block',
474
+ false,
475
+ ) as SRichBlockNode))
476
+ } finally {
477
+ leave(value as object, context)
478
+ }
479
+ }
480
+
481
+ function validateTableColumn(
482
+ value: unknown,
483
+ path: string,
484
+ context: ValidationContext,
485
+ ): SRichTableColumn {
486
+ const record = readRecord(value, path, context)
487
+ try {
488
+ assertFields(record, path, TABLE_COLUMN_FIELDS)
489
+ const headerPresentation = record.headerPresentation === undefined
490
+ ? undefined
491
+ : readEnum(record.headerPresentation, `${path}.headerPresentation`, HEADER_PRESENTATIONS)
492
+ const align = record.align === undefined
493
+ ? undefined
494
+ : readEnum(record.align, `${path}.align`, TABLE_ALIGNMENTS)
495
+ return Object.freeze({
496
+ id: readString(record.id, `${path}.id`, { stable: true, maximum: 256 }),
497
+ header: readString(record.header, `${path}.header`),
498
+ ...(headerPresentation === undefined ? {} : { headerPresentation }),
499
+ ...(align === undefined ? {} : { align }),
500
+ })
501
+ } finally {
502
+ leave(value as object, context)
503
+ }
504
+ }
505
+
506
+ function validateTableCell(
507
+ value: unknown,
508
+ path: string,
509
+ context: ValidationContext,
510
+ depth: number,
511
+ ): SRichTableCell {
512
+ const record = readRecord(value, path, context)
513
+ try {
514
+ assertFields(record, path, TABLE_CELL_FIELDS)
515
+ return Object.freeze({ children: validateInlineChildren(record.children, `${path}.children`, context, depth + 1, false, 0) })
516
+ } finally {
517
+ leave(value as object, context)
518
+ }
519
+ }
520
+
521
+ function validateTableRow(
522
+ value: unknown,
523
+ path: string,
524
+ context: ValidationContext,
525
+ depth: number,
526
+ columnCount: number,
527
+ ): SRichTableRow {
528
+ const record = readRecord(value, path, context)
529
+ try {
530
+ assertFields(record, path, TABLE_ROW_FIELDS)
531
+ const cells = readArray(record.cells, `${path}.cells`, context, { minimum: columnCount, maximum: columnCount })
532
+ try {
533
+ return Object.freeze({
534
+ id: readString(record.id, `${path}.id`, { stable: true, maximum: 256 }),
535
+ cells: Object.freeze(cells.map((cell, index) => validateTableCell(
536
+ cell,
537
+ `${path}.cells[${index}]`,
538
+ context,
539
+ depth,
540
+ ))),
541
+ })
542
+ } finally {
543
+ leave(record.cells as object, context)
544
+ }
545
+ } finally {
546
+ leave(value as object, context)
547
+ }
548
+ }
549
+
550
+ function validateNode(
551
+ value: unknown,
552
+ path: string,
553
+ context: ValidationContext,
554
+ depth: number,
555
+ expected: 'inline' | 'block' | 'listItem' | 'action',
556
+ insideLink: boolean,
557
+ ): SRichDocumentNode {
558
+ if (depth > S_RICH_DOCUMENT_MAX_DEPTH) fail('limit_exceeded', path, `maximum depth is ${S_RICH_DOCUMENT_MAX_DEPTH}`)
559
+ context.nodeCount += 1
560
+ if (context.nodeCount > S_RICH_DOCUMENT_MAX_NODES) {
561
+ fail('limit_exceeded', path, `maximum node count is ${S_RICH_DOCUMENT_MAX_NODES}`)
562
+ }
563
+ const record = readRecord(value, path, context)
564
+ try {
565
+ if (typeof record.type !== 'string' || !hasOwn(NODE_FIELDS, record.type)) {
566
+ fail('unknown_node_type', `${path}.type`, `unsupported node type ${JSON.stringify(record.type)}`)
567
+ }
568
+ const type = record.type as keyof typeof NODE_FIELDS
569
+ assertFields(record, path, NODE_FIELDS[type])
570
+ if (expected === 'inline' && !INLINE_NODE_TYPES.has(type)) fail('invalid_structure', path, `${type} is not an inline node`)
571
+ if (expected === 'block' && !BLOCK_NODE_TYPES.has(type)) fail('invalid_structure', path, `${type} is not a block node`)
572
+ if (expected === 'listItem' && type !== 'listItem') fail('invalid_structure', path, 'list children must be listItem nodes')
573
+ if (expected === 'action' && type !== 'action') fail('invalid_structure', path, 'actionGroup children must be action nodes')
574
+
575
+ if (type === 'text') return Object.freeze({ type, value: readString(record.value, `${path}.value`) })
576
+ if (type === 'inlineCode') return Object.freeze({ type, code: readString(record.code, `${path}.code`, { allowEmpty: true }) })
577
+ if (type === 'mathInline') return Object.freeze({ type, source: readString(record.source, `${path}.source`) })
578
+ if (type === 'strong' || type === 'emphasis') {
579
+ return Object.freeze({
580
+ type,
581
+ children: validateInlineChildren(record.children, `${path}.children`, context, depth + 1, insideLink),
582
+ })
583
+ }
584
+ if (type === 'link') {
585
+ if (insideLink) fail('invalid_structure', path, 'nested links are not allowed')
586
+ const title = readOptionalString(record.title, `${path}.title`)
587
+ const current = record.current === undefined
588
+ ? undefined
589
+ : readBoolean(record.current, `${path}.current`)
590
+ return Object.freeze({
591
+ type,
592
+ href: validateUrl(record.href, `${path}.href`, 'link'),
593
+ ...(title === undefined ? {} : { title }),
594
+ ...(current === undefined ? {} : { current }),
595
+ children: validateInlineChildren(record.children, `${path}.children`, context, depth + 1, true),
596
+ })
597
+ }
598
+ if (type === 'paragraph') {
599
+ return Object.freeze({ type, children: validateInlineChildren(record.children, `${path}.children`, context, depth + 1, false) })
600
+ }
601
+ if (type === 'heading') {
602
+ return Object.freeze({
603
+ type,
604
+ level: readInteger(record.level, `${path}.level`, 1, 6) as SRichHeadingNode['level'],
605
+ children: validateInlineChildren(record.children, `${path}.children`, context, depth + 1, false),
606
+ })
607
+ }
608
+ if (type === 'listItem') {
609
+ return Object.freeze({ type, children: validateBlockChildren(record.children, `${path}.children`, context, depth + 1) })
610
+ }
611
+ if (type === 'list') {
612
+ const items = readArray(record.children, `${path}.children`, context, { minimum: 1, maximum: S_RICH_DOCUMENT_MAX_NODES })
613
+ try {
614
+ const ordered = readBoolean(record.ordered, `${path}.ordered`)
615
+ if (!ordered && record.start !== undefined) fail('invalid_structure', `${path}.start`, 'start is only valid for ordered lists')
616
+ const start = record.start === undefined ? undefined : readInteger(record.start, `${path}.start`, 1, Number.MAX_SAFE_INTEGER)
617
+ return Object.freeze({
618
+ type,
619
+ ordered,
620
+ ...(start === undefined ? {} : { start }),
621
+ children: Object.freeze(items.map((item, index) => validateNode(
622
+ item,
623
+ `${path}.children[${index}]`,
624
+ context,
625
+ depth + 1,
626
+ 'listItem',
627
+ false,
628
+ ) as SRichListItemNode)),
629
+ })
630
+ } finally {
631
+ leave(record.children as object, context)
632
+ }
633
+ }
634
+ if (type === 'quote') {
635
+ return Object.freeze({ type, children: validateBlockChildren(record.children, `${path}.children`, context, depth + 1) })
636
+ }
637
+ if (type === 'divider') return Object.freeze({ type })
638
+ if (type === 'image') {
639
+ const caption = record.caption === undefined
640
+ ? undefined
641
+ : validateInlineChildren(record.caption, `${path}.caption`, context, depth + 1, false)
642
+ return Object.freeze({
643
+ type,
644
+ resourceId: readString(record.resourceId, `${path}.resourceId`, { stable: true, maximum: 256 }),
645
+ ...(caption === undefined ? {} : { caption }),
646
+ })
647
+ }
648
+ if (type === 'resource') {
649
+ const description = readOptionalString(record.description, `${path}.description`)
650
+ return Object.freeze({
651
+ type,
652
+ resourceId: readString(record.resourceId, `${path}.resourceId`, { stable: true, maximum: 256 }),
653
+ label: readString(record.label, `${path}.label`),
654
+ ...(description === undefined ? {} : { description }),
655
+ })
656
+ }
657
+ if (type === 'codeBlock') {
658
+ const language = readOptionalString(record.language, `${path}.language`)
659
+ const label = readOptionalString(record.label, `${path}.label`)
660
+ const showLineNumbers = record.showLineNumbers === undefined
661
+ ? undefined
662
+ : readBoolean(record.showLineNumbers, `${path}.showLineNumbers`)
663
+ return Object.freeze({
664
+ type,
665
+ code: readString(record.code, `${path}.code`, { allowEmpty: true }),
666
+ ...(language === undefined ? {} : { language }),
667
+ ...(label === undefined ? {} : { label }),
668
+ ...(showLineNumbers === undefined ? {} : { showLineNumbers }),
669
+ })
670
+ }
671
+ if (type === 'mathBlock') {
672
+ const label = readOptionalString(record.label, `${path}.label`)
673
+ return Object.freeze({
674
+ type,
675
+ source: readString(record.source, `${path}.source`),
676
+ ...(label === undefined ? {} : { label }),
677
+ })
678
+ }
679
+ if (type === 'table') {
680
+ const columnsSource = readArray(record.columns, `${path}.columns`, context, { minimum: 1, maximum: 100 })
681
+ let columns: readonly SRichTableColumn[]
682
+ try {
683
+ columns = Object.freeze(columnsSource.map((column, index) => validateTableColumn(
684
+ column,
685
+ `${path}.columns[${index}]`,
686
+ context,
687
+ )))
688
+ } finally {
689
+ leave(record.columns as object, context)
690
+ }
691
+ const columnIds = new Set<string>()
692
+ for (const [index, column] of columns.entries()) {
693
+ if (columnIds.has(column.id)) fail('duplicate_id', `${path}.columns[${index}].id`, `duplicate column ID ${JSON.stringify(column.id)}`)
694
+ columnIds.add(column.id)
695
+ }
696
+ const rowsSource = readArray(record.rows, `${path}.rows`, context, { minimum: 0, maximum: 10_000 })
697
+ let rows: readonly SRichTableRow[]
698
+ try {
699
+ rows = Object.freeze(rowsSource.map((row, index) => validateTableRow(
700
+ row,
701
+ `${path}.rows[${index}]`,
702
+ context,
703
+ depth + 1,
704
+ columns.length,
705
+ )))
706
+ } finally {
707
+ leave(record.rows as object, context)
708
+ }
709
+ const rowIds = new Set<string>()
710
+ for (const [index, row] of rows.entries()) {
711
+ if (rowIds.has(row.id)) fail('duplicate_id', `${path}.rows[${index}].id`, `duplicate row ID ${JSON.stringify(row.id)}`)
712
+ rowIds.add(row.id)
713
+ }
714
+ return Object.freeze({ type, label: readString(record.label, `${path}.label`), columns, rows })
715
+ }
716
+ if (type === 'callout') {
717
+ const title = readOptionalString(record.title, `${path}.title`)
718
+ return Object.freeze({
719
+ type,
720
+ tone: readEnum(record.tone, `${path}.tone`, CALLOUT_TONES),
721
+ ...(title === undefined ? {} : { title }),
722
+ children: validateBlockChildren(record.children, `${path}.children`, context, depth + 1),
723
+ })
724
+ }
725
+ if (type === 'action') {
726
+ const id = readString(record.id, `${path}.id`, { stable: true, maximum: 256 })
727
+ if (context.actionIds.has(id)) fail('duplicate_id', `${path}.id`, `duplicate action ID ${JSON.stringify(id)}`)
728
+ context.actionIds.add(id)
729
+ const appearance = record.appearance === undefined
730
+ ? undefined
731
+ : readEnum(record.appearance, `${path}.appearance`, ACTION_APPEARANCES)
732
+ const disabled = record.disabled === undefined ? undefined : readBoolean(record.disabled, `${path}.disabled`)
733
+ return Object.freeze({
734
+ type,
735
+ id,
736
+ label: readString(record.label, `${path}.label`),
737
+ ...(appearance === undefined ? {} : { appearance }),
738
+ ...(disabled === undefined ? {} : { disabled }),
739
+ })
740
+ }
741
+ const actions = readArray(record.actions, `${path}.actions`, context, { minimum: 1, maximum: 100 })
742
+ try {
743
+ const label = readOptionalString(record.label, `${path}.label`)
744
+ return Object.freeze({
745
+ type: 'actionGroup',
746
+ ...(label === undefined ? {} : { label }),
747
+ actions: Object.freeze(actions.map((action, index) => validateNode(
748
+ action,
749
+ `${path}.actions[${index}]`,
750
+ context,
751
+ depth + 1,
752
+ 'action',
753
+ false,
754
+ ) as SRichActionNode)),
755
+ })
756
+ } finally {
757
+ leave(record.actions as object, context)
758
+ }
759
+ } finally {
760
+ leave(value as object, context)
761
+ }
762
+ }
763
+
764
+ /** Validates, clones, and freezes a closed V1 document before rendering. */
765
+ export function validateRichDocument(value: unknown): SRichDocumentV1 {
766
+ const context: ValidationContext = { ancestors: new Set<object>(), actionIds: new Set<string>(), nodeCount: 0 }
767
+ const record = readRecord(value, 'document', context)
768
+ try {
769
+ assertFields(record, 'document', DOCUMENT_FIELDS)
770
+ if (record.version !== S_RICH_DOCUMENT_VERSION) {
771
+ fail('unsupported_version', 'document.version', `expected ${S_RICH_DOCUMENT_VERSION}`)
772
+ }
773
+ return Object.freeze({
774
+ version: S_RICH_DOCUMENT_VERSION,
775
+ id: readString(record.id, 'document.id', { stable: true, maximum: 256 }),
776
+ nodes: validateBlockChildren(record.nodes, 'document.nodes', context, 1, 0),
777
+ })
778
+ } finally {
779
+ leave(value as object, context)
780
+ }
781
+ }
782
+
783
+ function validateResource(
784
+ value: unknown,
785
+ path: string,
786
+ context: ValidationContext,
787
+ ): SRichDocumentResource {
788
+ const record = readRecord(value, path, context)
789
+ try {
790
+ assertFields(record, path, RESOURCE_FIELDS)
791
+ const kind = readEnum(record.kind, `${path}.kind`, new Set(['image', 'file'] as const))
792
+ const common = {
793
+ id: readString(record.id, `${path}.id`, { stable: true, maximum: 256 }),
794
+ url: validateUrl(record.url, `${path}.url`, 'resource'),
795
+ ...(record.title === undefined ? {} : { title: readString(record.title, `${path}.title`) }),
796
+ }
797
+ if (kind === 'image') {
798
+ for (const field of ['label', 'mediaType', 'byteSize']) {
799
+ if (record[field] !== undefined) fail('unknown_field', `${path}.${field}`, `field is not valid for ${kind}`)
800
+ }
801
+ return Object.freeze({ ...common, kind, alt: readString(record.alt, `${path}.alt`, { allowEmpty: true }) })
802
+ }
803
+ if (record.alt !== undefined) fail('unknown_field', `${path}.alt`, `field is not valid for ${kind}`)
804
+ const mediaType = readOptionalString(record.mediaType, `${path}.mediaType`)
805
+ const byteSize = record.byteSize === undefined
806
+ ? undefined
807
+ : readInteger(record.byteSize, `${path}.byteSize`, 0, Number.MAX_SAFE_INTEGER)
808
+ return Object.freeze({
809
+ ...common,
810
+ kind,
811
+ label: readString(record.label, `${path}.label`),
812
+ ...(mediaType === undefined ? {} : { mediaType }),
813
+ ...(byteSize === undefined ? {} : { byteSize }),
814
+ })
815
+ } finally {
816
+ leave(value as object, context)
817
+ }
818
+ }
819
+
820
+ /** Validates and freezes consumer-owned resource records under the resource URL policy. */
821
+ export function validateRichDocumentResources(value: unknown): readonly SRichDocumentResource[] {
822
+ const context: ValidationContext = { ancestors: new Set<object>(), actionIds: new Set<string>(), nodeCount: 0 }
823
+ const source = readArray(value, 'resources', context, { minimum: 0, maximum: S_RICH_DOCUMENT_MAX_NODES })
824
+ try {
825
+ const resources = Object.freeze(source.map((resource, index) => validateResource(
826
+ resource,
827
+ `resources[${index}]`,
828
+ context,
829
+ )))
830
+ const ids = new Set<string>()
831
+ for (const [index, resource] of resources.entries()) {
832
+ if (ids.has(resource.id)) fail('duplicate_id', `resources[${index}].id`, `duplicate resource ID ${JSON.stringify(resource.id)}`)
833
+ ids.add(resource.id)
834
+ }
835
+ return resources
836
+ } finally {
837
+ leave(value as object, context)
838
+ }
839
+ }
840
+
841
+ function visitResourceReferences(
842
+ nodes: readonly SRichBlockNode[],
843
+ visit: (resourceId: string, path: string, imageOnly: boolean) => void,
844
+ path = 'document.nodes',
845
+ ): void {
846
+ for (const [index, node] of nodes.entries()) {
847
+ const nodePath = `${path}[${index}]`
848
+ if (node.type === 'image') visit(node.resourceId, `${nodePath}.resourceId`, true)
849
+ if (node.type === 'resource') visit(node.resourceId, `${nodePath}.resourceId`, false)
850
+ if (node.type === 'list') {
851
+ for (const [itemIndex, item] of node.children.entries()) {
852
+ visitResourceReferences(item.children, visit, `${nodePath}.children[${itemIndex}].children`)
853
+ }
854
+ }
855
+ if (node.type === 'quote' || node.type === 'callout') {
856
+ visitResourceReferences(node.children, visit, `${nodePath}.children`)
857
+ }
858
+ }
859
+ }
860
+
861
+ /** Validates the document and resources as one referentially complete renderer model. */
862
+ export function validateRichDocumentModel(document: unknown, resources: unknown): SRichDocumentModel {
863
+ const validatedDocument = validateRichDocument(document)
864
+ const validatedResources = validateRichDocumentResources(resources)
865
+ const resourcesById = new Map(validatedResources.map((resource) => [resource.id, resource] as const))
866
+ visitResourceReferences(validatedDocument.nodes, (resourceId, path, imageOnly) => {
867
+ const resource = resourcesById.get(resourceId)
868
+ if (resource === undefined) fail('missing_resource', path, `resource ${JSON.stringify(resourceId)} is not declared`)
869
+ if (imageOnly && resource.kind !== 'image') fail('invalid_structure', path, 'image nodes require an image resource')
870
+ })
871
+ return Object.freeze({ document: validatedDocument, resources: validatedResources })
872
+ }
873
+
874
+ function hasOwn(value: object, key: PropertyKey): boolean {
875
+ return Object.prototype.hasOwnProperty.call(value, key)
876
+ }