@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.
- package/README.md +332 -9
- package/docs/getting-started.md +135 -0
- package/docs/public-api.md +53 -4
- package/docs/rich-document.md +216 -0
- package/docs/scroll-viewport.md +167 -0
- package/docs/theming.md +17 -0
- package/package.json +108 -50
- package/src/components/layout/SAppShell.vue +3 -1
- package/src/components/layout/SDockRegion.vue +5 -3
- package/src/components/layout/SResizeHandle.vue +45 -5
- package/src/components/layout/SWorkbenchPanelShell.vue +4 -2
- package/src/components/shared/_internal/SDialogSurface.css +23 -0
- package/src/components/shared/_internal/SDialogSurface.vue +9 -2
- package/src/components/shared/_internal/SToastItem.css +4 -0
- package/src/components/shared/_internal/SToastItem.vue +44 -8
- package/src/components/shared/_internal/STreeNode.vue +24 -11
- package/src/components/shared/_internal/dialogSurface.ts +7 -1
- package/src/components/shared/complex/SKanbanLane.vue +4 -2
- package/src/components/shared/containers/SCloudChromeRow.vue +3 -2
- package/src/components/shared/containers/SDrawer.css +11 -2
- package/src/components/shared/containers/SDrawer.vue +48 -9
- package/src/components/shared/containers/SModal.vue +5 -2
- package/src/components/shared/containers/SPanel.vue +4 -2
- package/src/components/shared/containers/SPopover.vue +7 -3
- package/src/components/shared/containers/SUiKitLocaleProvider.vue +57 -0
- package/src/components/shared/controls/SButton.css +14 -10
- package/src/components/shared/controls/SCombobox.vue +8 -6
- package/src/components/shared/controls/SComboboxTrigger.css +7 -4
- package/src/components/shared/controls/SComboboxTrigger.vue +5 -2
- package/src/components/shared/controls/SContextToggleButton.vue +6 -4
- package/src/components/shared/controls/SDragHandle.vue +8 -4
- package/src/components/shared/controls/SDropdownMenu.vue +7 -4
- package/src/components/shared/controls/SInputText.css +5 -4
- package/src/components/shared/controls/SListbox.vue +4 -2
- package/src/components/shared/controls/SSelect.vue +4 -2
- package/src/components/shared/controls/STextarea.ts +13 -0
- package/src/components/shared/controls/STextarea.vue +391 -37
- package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +4 -2
- package/src/components/shared/data-display/SChart.vue +7 -5
- package/src/components/shared/data-display/SChip.vue +3 -1
- package/src/components/shared/data-display/SCodeBlock.css +4 -0
- package/src/components/shared/data-display/SCodeBlock.vue +46 -22
- package/src/components/shared/data-display/SCodeEditor.css +17 -0
- package/src/components/shared/data-display/SCodeEditor.vue +173 -8
- package/src/components/shared/data-display/SCodeSearchPanel.vue +48 -33
- package/src/components/shared/data-display/SCodeWorkspaceEditor.css +13 -0
- package/src/components/shared/data-display/SCodeWorkspaceEditor.vue +355 -0
- package/src/components/shared/data-display/SControlledToastViewport.css +89 -0
- package/src/components/shared/data-display/SControlledToastViewport.vue +205 -0
- package/src/components/shared/data-display/SCopyField.vue +12 -10
- package/src/components/shared/data-display/SDiffViewer.css +122 -0
- package/src/components/shared/data-display/SDiffViewer.vue +499 -0
- package/src/components/shared/data-display/SDocBlock.vue +99 -71
- package/src/components/shared/data-display/SExpandableText.vue +10 -8
- package/src/components/shared/data-display/SJsonTree.vue +4 -2
- package/src/components/shared/data-display/SLinkedSystemsList.vue +4 -2
- package/src/components/shared/data-display/SMessage.vue +6 -6
- package/src/components/shared/data-display/SRichDocument.css +8 -0
- package/src/components/shared/data-display/SRichDocument.vue +65 -0
- package/src/components/shared/data-display/SScrollViewport.css +53 -0
- package/src/components/shared/data-display/SScrollViewport.vue +470 -0
- package/src/components/shared/data-display/STable.vue +38 -19
- package/src/components/shared/data-display/SToastContainer.vue +22 -17
- package/src/components/shared/data-display/SVirtualList.ts +33 -0
- package/src/components/shared/data-display/SVirtualList.vue +321 -36
- package/src/components/shared/data-display/_internal/SCodeTokenLine.css +72 -0
- package/src/components/shared/data-display/_internal/SCodeTokenLine.vue +79 -0
- package/src/components/shared/data-display/_internal/SRichBlockNode.css +85 -0
- package/src/components/shared/data-display/_internal/SRichBlockNode.vue +282 -0
- package/src/components/shared/data-display/_internal/SRichInlineNode.css +6 -0
- package/src/components/shared/data-display/_internal/SRichInlineNode.vue +76 -0
- package/src/components/shared/data-display/_internal/SRichMath.css +42 -0
- package/src/components/shared/data-display/_internal/SRichMath.vue +102 -0
- package/src/components/shared/data-display/codeEditor.ts +200 -0
- package/src/components/shared/data-display/codeWorkspace.ts +233 -0
- package/src/components/shared/data-display/diff.ts +541 -0
- package/src/components/shared/data-display/richDocument.ts +876 -0
- package/src/components/shared/data-display/scrollViewport.ts +47 -0
- package/src/components/shared/data-display/toast.ts +194 -0
- package/src/components/shared/database/SDataGrid.vue +7 -4
- package/src/components/shared/database/SSqlEditor.vue +39 -39
- package/src/components/shared/feedback/SAsyncState.vue +3 -1
- package/src/components/shared/graph/SGraphViewport.vue +11 -9
- package/src/components/shared/navigation/SBreadcrumbs.vue +8 -5
- package/src/components/shared/navigation/SCatalogNavigator.vue +26 -20
- package/src/components/shared/navigation/STabList.vue +10 -6
- package/src/components/shared/navigation/STabs.vue +15 -11
- package/src/components/shared/navigation/SWizardSteps.vue +4 -2
- package/src/components/shared/persona/SPersonaProjectPicker.vue +31 -27
- package/src/components/shared/persona/SPersonaRunWorkbench.vue +39 -33
- package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +8 -6
- package/src/components/shared/persona/_internal/labels.ts +66 -57
- package/src/components/shared/persona/presentation.ts +140 -110
- package/src/composables/useFloatingPosition.ts +39 -2
- package/src/composables/useUiKitLocale.ts +17 -0
- package/src/internal/codeHighlight.ts +110 -0
- package/src/internal/diffEngine.ts +464 -0
- package/src/internal/diffRuntime.ts +189 -0
- package/src/internal/feedbackPresentation.ts +33 -14
- package/src/internal/localeContext.ts +22 -0
- package/src/internal/ownedAttrs.ts +8 -0
- package/src/internal/runtimeContract.ts +11 -9
- package/src/internal/semanticSizing.ts +13 -0
- package/src/locales/catalog.ts +634 -0
- package/src/locales/locale.ts +472 -0
- package/src/locales/messages.generated.ts +1826 -0
- package/src/locales/messages.ts +150 -0
- package/src/stores/useNotifierStore.ts +61 -44
- package/src/styles/tokens.css +23 -2
- package/src/theme.ts +59 -32
- package/src/workers/diff.worker.ts +73 -0
- package/src/workers/diffProtocol.ts +155 -0
- 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
|
+
}
|