@asd20/ui-next 2.14.1 → 2.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ # [2.15.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.14.1...ui-next-v2.15.0) (2026-07-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * align TipTap document validation ([3e0d95c](https://github.com/academydistrict20/asd20-ui-next/commit/3e0d95cf040a1b1e1d0b22a6ff6b03d635e0c019))
9
+ * preserve TipTap rendering semantics ([3fcec8e](https://github.com/academydistrict20/asd20-ui-next/commit/3fcec8ea4c2c34418d876ea17e4939c73fe32855))
10
+
11
+
12
+ ### Features
13
+
14
+ * add TipTap message body dual reader ([1552256](https://github.com/academydistrict20/asd20-ui-next/commit/1552256630fde93026c9667b9854ccf9cd288914))
15
+
3
16
  ## [2.14.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.14.0...ui-next-v2.14.1) (2026-07-28)
4
17
 
5
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.14.1",
3
+ "version": "2.15.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -2,6 +2,7 @@
2
2
  <div
3
3
  v-if="renderNodes.length"
4
4
  class="asd20-rich-body-content"
5
+ :data-message-document-status="renderModel.status"
5
6
  @click="onContentClick"
6
7
  >
7
8
  <template v-for="(node, index) in renderNodes">
@@ -40,76 +41,10 @@ import Asd20BodyAccordion from '../../molecules/Asd20BodyAccordion'
40
41
  import Asd20TableauEmbed from '../../molecules/Asd20TableauEmbed'
41
42
  import { navigateInternalHref } from '../../../helpers/linkPolicy'
42
43
  import { applyRichListColumnExceptions } from '../../../helpers/richBodyListColumns'
43
-
44
- const BODY_BLOCK_PLACEHOLDER_PATTERN =
45
- /<div\b(?=[^>]*\bdata-cc-block=(['"])([^'"]+)\1)(?=[^>]*\bdata-block-id=(['"])([^'"]+)\3)[^>]*><\/div>/gi
46
-
47
- function containsBodyBlockPlaceholder(html) {
48
- if (!html) return false
49
- return new RegExp(BODY_BLOCK_PLACEHOLDER_PATTERN).test(html)
50
- }
51
-
52
- function buildRenderNodes(html, blockMap) {
53
- const nodes = []
54
- const placeholderPattern = new RegExp(BODY_BLOCK_PLACEHOLDER_PATTERN)
55
- let lastIndex = 0
56
- let match
57
-
58
- while ((match = placeholderPattern.exec(html))) {
59
- const [fullMatch, , blockType, , blockId] = match
60
- const htmlBefore = html.slice(lastIndex, match.index)
61
- if (htmlBefore) {
62
- nodes.push({
63
- type: 'html',
64
- html: htmlBefore,
65
- })
66
- }
67
-
68
- const block = blockMap[blockId]
69
- if (block && block.type === blockType) {
70
- nodes.push({
71
- type: blockType,
72
- block,
73
- })
74
- } else {
75
- nodes.push({
76
- type: 'html',
77
- html: fullMatch,
78
- })
79
- }
80
-
81
- lastIndex = match.index + fullMatch.length
82
- }
83
-
84
- const htmlAfter = html.slice(lastIndex)
85
- if (htmlAfter) {
86
- nodes.push({
87
- type: 'html',
88
- html: htmlAfter,
89
- })
90
- }
91
-
92
- return nodes
93
- }
94
-
95
- function parseBodyBlocks(bodyJson) {
96
- if (!bodyJson) return []
97
-
98
- let parsed = bodyJson
99
- if (typeof bodyJson === 'string') {
100
- try {
101
- parsed = JSON.parse(bodyJson)
102
- } catch (error) {
103
- return []
104
- }
105
- }
106
-
107
- if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.blocks)) {
108
- return []
109
- }
110
-
111
- return parsed.blocks.filter(block => block && typeof block === 'object')
112
- }
44
+ import {
45
+ buildRichBodyRenderModel,
46
+ containsBodyBlockPlaceholder,
47
+ } from './messageDocumentRenderer'
113
48
 
114
49
  export default {
115
50
  name: 'Asd20RichBodyContent',
@@ -126,22 +61,24 @@ export default {
126
61
  }),
127
62
 
128
63
  computed: {
129
- blockMap() {
130
- return parseBodyBlocks(this.bodyJson).reduce((accumulator, block) => {
131
- if (!block.id) return accumulator
132
- accumulator[block.id] = block
133
- return accumulator
134
- }, {})
64
+ renderModel() {
65
+ return buildRichBodyRenderModel({
66
+ bodyContent: this.bodyContent,
67
+ bodyJson: this.bodyJson,
68
+ })
135
69
  },
136
70
  renderNodes() {
137
71
  const html = this.bodyContent || ''
138
- if (!html) return []
139
72
  // Keep SSR and the initial client render on the same raw HTML markup,
140
73
  // then enhance block placeholders after hydration.
141
- if (!this.hasMounted && containsBodyBlockPlaceholder(html)) {
74
+ if (
75
+ this.renderModel.status === 'legacy' &&
76
+ !this.hasMounted &&
77
+ containsBodyBlockPlaceholder(html)
78
+ ) {
142
79
  return [{ type: 'html', html }]
143
80
  }
144
- return buildRenderNodes(html, this.blockMap)
81
+ return this.renderModel.nodes
145
82
  },
146
83
  },
147
84
 
@@ -0,0 +1,808 @@
1
+ const MESSAGE_DOCUMENT_SCHEMA_VERSION = 1
2
+
3
+ export const BODY_JSON_FORMAT = Object.freeze({
4
+ EMPTY: 'empty',
5
+ INVALID: 'invalid',
6
+ LEGACY_BLOCKS: 'legacy-blocks',
7
+ TIPTAP: 'tiptap',
8
+ UNKNOWN: 'unknown',
9
+ })
10
+
11
+ export const MESSAGE_DOCUMENT_STATUS = Object.freeze({
12
+ LEGACY: 'legacy',
13
+ SUPPORTED: 'supported',
14
+ UNSUPPORTED: 'unsupported',
15
+ })
16
+
17
+ const LEGACY_PLACEHOLDER_PATTERN =
18
+ /<div\b(?=[^>]*\bdata-cc-block=(['"])([^'"]+)\1)(?=[^>]*\bdata-block-id=(['"])([^'"]+)\3)[^>]*><\/div>/gi
19
+
20
+ const INLINE_NODE_TYPES = new Set(['text', 'hardBreak'])
21
+ const STANDARD_BLOCK_NODE_TYPES = new Set([
22
+ 'paragraph',
23
+ 'heading',
24
+ 'blockquote',
25
+ 'codeBlock',
26
+ 'horizontalRule',
27
+ 'bulletList',
28
+ 'orderedList',
29
+ 'image',
30
+ 'videoEmbed',
31
+ 'table',
32
+ ])
33
+ const TOP_LEVEL_NODE_TYPES = new Set([
34
+ ...STANDARD_BLOCK_NODE_TYPES,
35
+ 'accordionBlock',
36
+ 'tableauBlock',
37
+ ])
38
+ const KNOWN_NODE_TYPES = new Set([
39
+ 'doc',
40
+ ...TOP_LEVEL_NODE_TYPES,
41
+ ...INLINE_NODE_TYPES,
42
+ 'listItem',
43
+ 'tableRow',
44
+ 'tableCell',
45
+ 'tableHeader',
46
+ 'accordionItem',
47
+ 'legacyRecovery',
48
+ ])
49
+ const MARK_TYPES = new Set([
50
+ 'bold',
51
+ 'italic',
52
+ 'strike',
53
+ 'code',
54
+ 'link',
55
+ 'subscript',
56
+ 'superscript',
57
+ ])
58
+ const MARK_RENDER_ORDER = Object.freeze({
59
+ link: 0,
60
+ bold: 1,
61
+ code: 2,
62
+ italic: 3,
63
+ strike: 4,
64
+ subscript: 5,
65
+ superscript: 6,
66
+ })
67
+ const NODE_ATTRIBUTE_NAMES = Object.freeze({
68
+ doc: ['schemaVersion'],
69
+ text: [],
70
+ hardBreak: [],
71
+ paragraph: [],
72
+ heading: ['level'],
73
+ blockquote: [],
74
+ codeBlock: ['language'],
75
+ horizontalRule: [],
76
+ bulletList: [],
77
+ orderedList: ['start', 'type'],
78
+ listItem: [],
79
+ image: ['src', 'alt', 'title', 'width', 'height'],
80
+ videoEmbed: ['src', 'title', 'descriptiveText'],
81
+ table: [],
82
+ tableRow: [],
83
+ tableCell: ['colspan', 'rowspan', 'colwidth', 'align'],
84
+ tableHeader: ['colspan', 'rowspan', 'colwidth', 'align'],
85
+ tableauBlock: [
86
+ 'blockId',
87
+ 'src',
88
+ 'height',
89
+ 'toolbar',
90
+ 'hideTabs',
91
+ 'descriptiveText',
92
+ ],
93
+ accordionBlock: ['blockId'],
94
+ accordionItem: ['itemId', 'heading', 'opened'],
95
+ legacyRecovery: ['reason', 'blockId', 'blockType', 'rawHtml', 'payloadJson'],
96
+ })
97
+
98
+ function isRecord(value) {
99
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
100
+ }
101
+
102
+ function parseBodyJson(value) {
103
+ if (
104
+ value === null ||
105
+ value === undefined ||
106
+ (typeof value === 'string' && value.trim() === '')
107
+ ) {
108
+ return { parsed: null, error: null }
109
+ }
110
+ if (typeof value !== 'string') return { parsed: value, error: null }
111
+
112
+ try {
113
+ return { parsed: JSON.parse(value), error: null }
114
+ } catch (error) {
115
+ return { parsed: null, error }
116
+ }
117
+ }
118
+
119
+ function detectBodyJsonFormat(value) {
120
+ const { parsed, error } = parseBodyJson(value)
121
+
122
+ if (error) {
123
+ return {
124
+ format: BODY_JSON_FORMAT.INVALID,
125
+ value: null,
126
+ reason: 'bodyJson is not valid JSON.',
127
+ }
128
+ }
129
+ if (parsed === null) {
130
+ return { format: BODY_JSON_FORMAT.EMPTY, value: null, reason: '' }
131
+ }
132
+ if (!isRecord(parsed)) {
133
+ return {
134
+ format: BODY_JSON_FORMAT.UNKNOWN,
135
+ value: parsed,
136
+ reason: 'bodyJson must contain an object.',
137
+ }
138
+ }
139
+ if (parsed.type === 'doc') {
140
+ return { format: BODY_JSON_FORMAT.TIPTAP, value: parsed, reason: '' }
141
+ }
142
+ if (Array.isArray(parsed.blocks)) {
143
+ return {
144
+ format: BODY_JSON_FORMAT.LEGACY_BLOCKS,
145
+ value: parsed,
146
+ reason: parsed.version === 1 ? '' : 'Legacy bodyJson must use version 1.',
147
+ }
148
+ }
149
+ return {
150
+ format: BODY_JSON_FORMAT.UNKNOWN,
151
+ value: parsed,
152
+ reason: 'bodyJson is neither a TipTap document nor a legacy block sidecar.',
153
+ }
154
+ }
155
+
156
+ function issue(code, path, detail = '') {
157
+ return { code, path, detail }
158
+ }
159
+
160
+ function validateAttributeNames(node, path) {
161
+ const allowedNames = new Set(NODE_ATTRIBUTE_NAMES[node.type] || [])
162
+ const unknownName = Object.keys(node.attrs || {}).find(
163
+ name => !allowedNames.has(name)
164
+ )
165
+ return unknownName ? issue('unknown-attribute', path, unknownName) : null
166
+ }
167
+
168
+ function validateStringAttribute(node, name, path, required = false) {
169
+ const value = node.attrs?.[name]
170
+ if (value === undefined || value === null) {
171
+ return required ? issue('malformed-attribute', path, name) : null
172
+ }
173
+ return typeof value === 'string'
174
+ ? null
175
+ : issue('malformed-attribute', path, name)
176
+ }
177
+
178
+ function isSafeResource(value, allowHash = false) {
179
+ if (typeof value !== 'string' || !value.trim()) return false
180
+ if (
181
+ value.startsWith('/') ||
182
+ value.startsWith('?') ||
183
+ value.startsWith('./') ||
184
+ value.startsWith('../') ||
185
+ (allowHash && value.startsWith('#'))
186
+ ) {
187
+ return true
188
+ }
189
+
190
+ try {
191
+ const url = new URL(value, 'https://ui-next.invalid')
192
+ return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)
193
+ } catch (_error) {
194
+ return false
195
+ }
196
+ }
197
+
198
+ function validateMarks(marks, path) {
199
+ if (marks === undefined) return null
200
+ if (!Array.isArray(marks)) return issue('malformed-marks', path)
201
+
202
+ const seenTypes = new Set()
203
+ for (let index = 0; index < marks.length; index += 1) {
204
+ const mark = marks[index]
205
+ const markPath = `${path}.marks[${index}]`
206
+ if (!isRecord(mark) || !MARK_TYPES.has(mark.type)) {
207
+ return issue('unsupported-mark', markPath, mark?.type || 'unknown')
208
+ }
209
+ if (mark.attrs !== undefined && !isRecord(mark.attrs)) {
210
+ return issue('malformed-mark-attributes', markPath)
211
+ }
212
+ if (seenTypes.has(mark.type)) {
213
+ return issue('duplicate-mark', markPath, mark.type)
214
+ }
215
+ seenTypes.add(mark.type)
216
+ if (mark.type === 'link') {
217
+ const href = mark.attrs?.href
218
+ if (!isSafeResource(href, true)) {
219
+ return issue('unsafe-link', markPath, href || '')
220
+ }
221
+ const unknownName = Object.keys(mark.attrs || {}).find(
222
+ name => !['href', 'target', 'rel', 'class', 'title'].includes(name)
223
+ )
224
+ if (unknownName) {
225
+ return issue('unknown-mark-attribute', markPath, unknownName)
226
+ }
227
+ for (const name of ['target', 'rel', 'class', 'title']) {
228
+ const value = mark.attrs?.[name]
229
+ if (
230
+ value !== undefined &&
231
+ value !== null &&
232
+ typeof value !== 'string'
233
+ ) {
234
+ return issue('malformed-mark-attributes', markPath, name)
235
+ }
236
+ }
237
+ } else if (Object.keys(mark.attrs || {}).length) {
238
+ return issue('unknown-mark-attribute', markPath)
239
+ }
240
+ }
241
+ return null
242
+ }
243
+
244
+ function validateChildren(node, path, allowedTypes, required = false) {
245
+ if (node.content === undefined) {
246
+ return required ? issue('malformed-content', path) : null
247
+ }
248
+ if (!Array.isArray(node.content) || (required && !node.content.length)) {
249
+ return issue('malformed-content', path)
250
+ }
251
+
252
+ for (let index = 0; index < node.content.length; index += 1) {
253
+ const child = node.content[index]
254
+ const childPath = `${path}.content[${index}]`
255
+ if (!isRecord(child)) return issue('malformed-node', childPath)
256
+ const childIssue = validateNode(child, childPath)
257
+ if (childIssue) return childIssue
258
+ if (!allowedTypes.has(child.type)) {
259
+ return issue('invalid-child-node', childPath, child.type)
260
+ }
261
+ }
262
+ return null
263
+ }
264
+
265
+ function validateAtom(node, path) {
266
+ if (
267
+ node.content !== undefined &&
268
+ (!Array.isArray(node.content) || node.content.length)
269
+ ) {
270
+ return issue('malformed-content', path)
271
+ }
272
+ return null
273
+ }
274
+
275
+ function validateNode(node, path) {
276
+ if (!isRecord(node) || typeof node.type !== 'string') {
277
+ return issue('malformed-node', path)
278
+ }
279
+ if (node.type === 'legacyRecovery') return issue('recovery-node', path)
280
+ if (!KNOWN_NODE_TYPES.has(node.type)) {
281
+ return issue('unknown-node', path, node.type)
282
+ }
283
+ if (node.attrs !== undefined && !isRecord(node.attrs)) {
284
+ return issue('malformed-attributes', path)
285
+ }
286
+ const attributeIssue = validateAttributeNames(node, path)
287
+ if (attributeIssue) return attributeIssue
288
+
289
+ if (node.type === 'doc') {
290
+ if (node.attrs?.schemaVersion !== MESSAGE_DOCUMENT_SCHEMA_VERSION) {
291
+ return issue(
292
+ 'unsupported-schema-version',
293
+ path,
294
+ String(node.attrs?.schemaVersion ?? 'missing')
295
+ )
296
+ }
297
+ return validateChildren(node, path, TOP_LEVEL_NODE_TYPES, true)
298
+ }
299
+ if (node.type === 'text') {
300
+ if (
301
+ typeof node.text !== 'string' ||
302
+ node.text.length === 0 ||
303
+ node.content !== undefined
304
+ ) {
305
+ return issue('malformed-text', path)
306
+ }
307
+ return validateMarks(node.marks, path)
308
+ }
309
+ if (node.type === 'hardBreak') {
310
+ return validateMarks(node.marks, path) || validateAtom(node, path)
311
+ }
312
+ if (node.marks !== undefined) return issue('invalid-node-marks', path)
313
+
314
+ if (node.type === 'paragraph') {
315
+ return node.content === undefined
316
+ ? null
317
+ : validateChildren(node, path, INLINE_NODE_TYPES)
318
+ }
319
+ if (node.type === 'heading') {
320
+ const level = node.attrs?.level
321
+ if (!Number.isInteger(level) || level < 1 || level > 6) {
322
+ return issue('malformed-attribute', path, 'level')
323
+ }
324
+ return node.content === undefined
325
+ ? null
326
+ : validateChildren(node, path, INLINE_NODE_TYPES)
327
+ }
328
+ if (node.type === 'horizontalRule') {
329
+ return validateAtom(node, path)
330
+ }
331
+ if (node.type === 'codeBlock') {
332
+ const languageIssue = validateStringAttribute(node, 'language', path)
333
+ if (languageIssue) return languageIssue
334
+ const contentIssue =
335
+ node.content === undefined
336
+ ? null
337
+ : validateChildren(node, path, new Set(['text']))
338
+ if (contentIssue) return contentIssue
339
+ return (node.content || []).some(child => child.marks?.length)
340
+ ? issue('invalid-code-block-marks', path)
341
+ : null
342
+ }
343
+ if (node.type === 'blockquote') {
344
+ return validateChildren(node, path, STANDARD_BLOCK_NODE_TYPES, true)
345
+ }
346
+ if (node.type === 'bulletList' || node.type === 'orderedList') {
347
+ if (
348
+ node.type === 'orderedList' &&
349
+ node.attrs?.start !== undefined &&
350
+ (!Number.isInteger(node.attrs.start) || node.attrs.start < 1)
351
+ ) {
352
+ return issue('malformed-attribute', path, 'start')
353
+ }
354
+ if (
355
+ node.type === 'orderedList' &&
356
+ node.attrs?.type !== undefined &&
357
+ node.attrs.type !== null &&
358
+ !['1', 'a', 'A', 'i', 'I'].includes(node.attrs.type)
359
+ ) {
360
+ return issue('malformed-attribute', path, 'type')
361
+ }
362
+ return validateChildren(node, path, new Set(['listItem']), true)
363
+ }
364
+ if (node.type === 'listItem') {
365
+ const contentIssue = validateChildren(
366
+ node,
367
+ path,
368
+ STANDARD_BLOCK_NODE_TYPES,
369
+ true
370
+ )
371
+ if (contentIssue) return contentIssue
372
+ return node.content[0].type === 'paragraph'
373
+ ? null
374
+ : issue('invalid-child-node', `${path}.content[0]`, node.content[0].type)
375
+ }
376
+ if (node.type === 'image') {
377
+ const srcIssue = validateStringAttribute(node, 'src', path)
378
+ if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
379
+ return srcIssue || issue('unsafe-resource', path, 'src')
380
+ }
381
+ for (const name of ['alt', 'title']) {
382
+ const attrIssue = validateStringAttribute(node, name, path)
383
+ if (attrIssue) return attrIssue
384
+ }
385
+ for (const name of ['width', 'height']) {
386
+ const value = node.attrs?.[name]
387
+ if (
388
+ value !== undefined &&
389
+ value !== null &&
390
+ (!Number.isFinite(value) || value <= 0)
391
+ ) {
392
+ return issue('malformed-attribute', path, name)
393
+ }
394
+ }
395
+ return validateAtom(node, path)
396
+ }
397
+ if (node.type === 'videoEmbed') {
398
+ const srcIssue = validateStringAttribute(node, 'src', path)
399
+ if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
400
+ return srcIssue || issue('unsafe-resource', path, 'src')
401
+ }
402
+ for (const name of ['title', 'descriptiveText']) {
403
+ const attrIssue = validateStringAttribute(node, name, path)
404
+ if (attrIssue) return attrIssue
405
+ }
406
+ return validateAtom(node, path)
407
+ }
408
+ if (node.type === 'table') {
409
+ return validateChildren(node, path, new Set(['tableRow']), true)
410
+ }
411
+ if (node.type === 'tableRow') {
412
+ return validateChildren(
413
+ node,
414
+ path,
415
+ new Set(['tableCell', 'tableHeader']),
416
+ true
417
+ )
418
+ }
419
+ if (node.type === 'tableCell' || node.type === 'tableHeader') {
420
+ const colspan = node.attrs?.colspan ?? 1
421
+ const rowspan = node.attrs?.rowspan ?? 1
422
+ const colwidth = node.attrs?.colwidth
423
+ const align = node.attrs?.align
424
+ if (!Number.isInteger(colspan) || colspan < 1) {
425
+ return issue('malformed-attribute', path, 'colspan')
426
+ }
427
+ if (!Number.isInteger(rowspan) || rowspan < 1) {
428
+ return issue('malformed-attribute', path, 'rowspan')
429
+ }
430
+ if (
431
+ colwidth !== undefined &&
432
+ colwidth !== null &&
433
+ (!Array.isArray(colwidth) ||
434
+ colwidth.length !== colspan ||
435
+ colwidth.some(width => !Number.isFinite(width) || width <= 0))
436
+ ) {
437
+ return issue('malformed-attribute', path, 'colwidth')
438
+ }
439
+ if (
440
+ align !== undefined &&
441
+ align !== null &&
442
+ !['left', 'center', 'right'].includes(align)
443
+ ) {
444
+ return issue('malformed-attribute', path, 'align')
445
+ }
446
+ return validateChildren(node, path, STANDARD_BLOCK_NODE_TYPES, true)
447
+ }
448
+ if (node.type === 'tableauBlock') {
449
+ for (const name of ['blockId', 'src', 'toolbar', 'descriptiveText']) {
450
+ const attrIssue = validateStringAttribute(node, name, path, false)
451
+ if (attrIssue) return attrIssue
452
+ }
453
+ if (node.attrs?.src && !isSafeResource(node.attrs.src)) {
454
+ return issue('unsafe-resource', path, 'src')
455
+ }
456
+ if (
457
+ node.attrs?.height !== undefined &&
458
+ (!Number.isFinite(Number(node.attrs.height)) ||
459
+ Number(node.attrs.height) <= 0)
460
+ ) {
461
+ return issue('malformed-attribute', path, 'height')
462
+ }
463
+ if (
464
+ node.attrs?.hideTabs !== undefined &&
465
+ typeof node.attrs.hideTabs !== 'boolean'
466
+ ) {
467
+ return issue('malformed-attribute', path, 'hideTabs')
468
+ }
469
+ return validateAtom(node, path)
470
+ }
471
+ if (node.type === 'accordionBlock') {
472
+ const blockIdIssue = validateStringAttribute(node, 'blockId', path)
473
+ return (
474
+ blockIdIssue ||
475
+ validateChildren(node, path, new Set(['accordionItem']), true)
476
+ )
477
+ }
478
+ if (node.type === 'accordionItem') {
479
+ for (const name of ['itemId', 'heading']) {
480
+ const attrIssue = validateStringAttribute(node, name, path)
481
+ if (attrIssue) return attrIssue
482
+ }
483
+ if (
484
+ node.attrs?.opened !== undefined &&
485
+ typeof node.attrs.opened !== 'boolean'
486
+ ) {
487
+ return issue('malformed-attribute', path, 'opened')
488
+ }
489
+ if (!Array.isArray(node.content) || !node.content.length) {
490
+ return issue('malformed-content', path)
491
+ }
492
+ const tableauChildren = node.content.filter(
493
+ child => child?.type === 'tableauBlock'
494
+ )
495
+ if (
496
+ tableauChildren.length &&
497
+ (tableauChildren.length !== 1 || node.content.length !== 1)
498
+ ) {
499
+ return issue('unsupported-accordion-content', path)
500
+ }
501
+ return validateChildren(
502
+ node,
503
+ path,
504
+ tableauChildren.length
505
+ ? new Set(['tableauBlock'])
506
+ : STANDARD_BLOCK_NODE_TYPES,
507
+ true
508
+ )
509
+ }
510
+ return issue('unknown-node', path, node.type)
511
+ }
512
+
513
+ function escapeHtml(value = '') {
514
+ return String(value)
515
+ .replace(/&/g, '&amp;')
516
+ .replace(/</g, '&lt;')
517
+ .replace(/>/g, '&gt;')
518
+ .replace(/"/g, '&quot;')
519
+ .replace(/'/g, '&#39;')
520
+ }
521
+
522
+ function attribute(name, value, preserveEmpty = false) {
523
+ return value === undefined ||
524
+ value === null ||
525
+ (!preserveEmpty && value === '')
526
+ ? ''
527
+ : ` ${name}="${escapeHtml(value)}"`
528
+ }
529
+
530
+ function renderMarkedText(node) {
531
+ let html = escapeHtml(node.text)
532
+ const marks = [...(node.marks || [])].sort(
533
+ (left, right) =>
534
+ MARK_RENDER_ORDER[left.type] - MARK_RENDER_ORDER[right.type]
535
+ )
536
+
537
+ for (const mark of marks.reverse()) {
538
+ if (mark.type === 'bold') html = `<strong>${html}</strong>`
539
+ else if (mark.type === 'italic') html = `<em>${html}</em>`
540
+ else if (mark.type === 'strike') html = `<s>${html}</s>`
541
+ else if (mark.type === 'code') html = `<code>${html}</code>`
542
+ else if (mark.type === 'subscript') html = `<sub>${html}</sub>`
543
+ else if (mark.type === 'superscript') html = `<sup>${html}</sup>`
544
+ else if (mark.type === 'link') {
545
+ html = `<a${attribute('href', mark.attrs.href)}${attribute(
546
+ 'target',
547
+ mark.attrs.target
548
+ )}${attribute('rel', mark.attrs.rel)}${attribute(
549
+ 'class',
550
+ mark.attrs.class
551
+ )}${attribute('title', mark.attrs.title)}>${html}</a>`
552
+ }
553
+ }
554
+ return html
555
+ }
556
+
557
+ function renderChildren(node) {
558
+ return (node.content || []).map(renderStandardNode).join('')
559
+ }
560
+
561
+ function renderTableCell(node, tagName) {
562
+ const colspan = node.attrs?.colspan ?? 1
563
+ const rowspan = node.attrs?.rowspan ?? 1
564
+ const colwidth = node.attrs?.colwidth
565
+ const align = node.attrs?.align
566
+ return `<${tagName}${attribute('colspan', colspan)}${attribute(
567
+ 'rowspan',
568
+ rowspan
569
+ )}${
570
+ Array.isArray(colwidth) ? attribute('colwidth', colwidth.join(',')) : ''
571
+ }${align ? attribute('style', `text-align: ${align}`) : ''}>${renderChildren(
572
+ node
573
+ )}</${tagName}>`
574
+ }
575
+
576
+ function renderTable(node) {
577
+ const minimumWidth = 25
578
+ const firstRow = node.content[0]
579
+ const columns = firstRow.content.flatMap(cell => {
580
+ const colspan = cell.attrs?.colspan ?? 1
581
+ const widths = cell.attrs?.colwidth
582
+ return Array.from({ length: colspan }, (_value, index) => widths?.[index])
583
+ })
584
+ const fixedWidth = columns.every(Boolean)
585
+ const totalWidth = columns.reduce(
586
+ (sum, width) => sum + (width || minimumWidth),
587
+ 0
588
+ )
589
+ const tableStyle = fixedWidth
590
+ ? `width: ${totalWidth}px`
591
+ : `min-width: ${totalWidth}px`
592
+ const colgroup = columns
593
+ .map(width =>
594
+ width
595
+ ? `<col style="width: ${width}px;">`
596
+ : `<col style="min-width: ${minimumWidth}px;">`
597
+ )
598
+ .join('')
599
+ return `<table${attribute(
600
+ 'style',
601
+ tableStyle
602
+ )}><colgroup>${colgroup}</colgroup><tbody>${renderChildren(
603
+ node
604
+ )}</tbody></table>`
605
+ }
606
+
607
+ function renderStandardNode(node) {
608
+ if (node.type === 'text') return renderMarkedText(node)
609
+ if (node.type === 'hardBreak') return '<br>'
610
+ if (node.type === 'paragraph') return `<p>${renderChildren(node)}</p>`
611
+ if (node.type === 'heading') {
612
+ return `<h${node.attrs.level}>${renderChildren(node)}</h${
613
+ node.attrs.level
614
+ }>`
615
+ }
616
+ if (node.type === 'blockquote') {
617
+ return `<blockquote>${renderChildren(node)}</blockquote>`
618
+ }
619
+ if (node.type === 'codeBlock') {
620
+ const languageClass = node.attrs?.language
621
+ ? attribute('class', `language-${node.attrs.language}`)
622
+ : ''
623
+ return `<pre><code${languageClass}>${renderChildren(node)}</code></pre>`
624
+ }
625
+ if (node.type === 'horizontalRule') return '<hr>'
626
+ if (node.type === 'bulletList') return `<ul>${renderChildren(node)}</ul>`
627
+ if (node.type === 'orderedList') {
628
+ const start = node.attrs?.start
629
+ const type = node.attrs?.type
630
+ return `<ol${start && start !== 1 ? attribute('start', start) : ''}${
631
+ type && type !== '1' ? attribute('type', type) : ''
632
+ }>${renderChildren(node)}</ol>`
633
+ }
634
+ if (node.type === 'listItem') return `<li>${renderChildren(node)}</li>`
635
+ if (node.type === 'image') {
636
+ return `<img${attribute('src', node.attrs.src)}${attribute(
637
+ 'alt',
638
+ node.attrs.alt,
639
+ true
640
+ )}${attribute('title', node.attrs.title)}${attribute(
641
+ 'width',
642
+ node.attrs.width
643
+ )}${attribute('height', node.attrs.height)}>`
644
+ }
645
+ if (node.type === 'videoEmbed') {
646
+ return `<div data-cc-node="video"${attribute(
647
+ 'data-src',
648
+ node.attrs.src
649
+ )}${attribute('data-title', node.attrs.title)}${attribute(
650
+ 'data-descriptive-text',
651
+ node.attrs.descriptiveText
652
+ )}><iframe${attribute('src', node.attrs.src)}${attribute(
653
+ 'title',
654
+ node.attrs.title || 'Embedded video'
655
+ )}></iframe></div>`
656
+ }
657
+ if (node.type === 'table') return renderTable(node)
658
+ if (node.type === 'tableRow') return `<tr>${renderChildren(node)}</tr>`
659
+ if (node.type === 'tableCell') return renderTableCell(node, 'td')
660
+ if (node.type === 'tableHeader') return renderTableCell(node, 'th')
661
+ return ''
662
+ }
663
+
664
+ function buildLegacyRenderNodes(bodyHtml, blocks) {
665
+ const blockMap = new Map(
666
+ blocks.filter(isRecord).map(block => [block.id, block])
667
+ )
668
+ const nodes = []
669
+ const pattern = new RegExp(LEGACY_PLACEHOLDER_PATTERN)
670
+ let lastIndex = 0
671
+ let match
672
+
673
+ while ((match = pattern.exec(bodyHtml))) {
674
+ if (match.index > lastIndex) {
675
+ nodes.push({ type: 'html', html: bodyHtml.slice(lastIndex, match.index) })
676
+ }
677
+ const block = blockMap.get(match[4])
678
+ nodes.push(
679
+ block && block.type === match[2]
680
+ ? { type: block.type, block }
681
+ : { type: 'html', html: match[0] }
682
+ )
683
+ lastIndex = match.index + match[0].length
684
+ }
685
+ if (lastIndex < bodyHtml.length) {
686
+ nodes.push({ type: 'html', html: bodyHtml.slice(lastIndex) })
687
+ }
688
+ return nodes
689
+ }
690
+
691
+ function accordionToRenderBlock(node) {
692
+ return {
693
+ id: node.attrs?.blockId || '',
694
+ type: 'accordion',
695
+ items: node.content.map(item => {
696
+ const isTableau = item.content[0]?.type === 'tableauBlock'
697
+ return isTableau
698
+ ? {
699
+ id: item.attrs?.itemId || '',
700
+ heading: item.attrs?.heading || '',
701
+ opened: Boolean(item.attrs?.opened),
702
+ contentType: 'tableau',
703
+ tableau: { ...item.content[0].attrs },
704
+ }
705
+ : {
706
+ id: item.attrs?.itemId || '',
707
+ heading: item.attrs?.heading || '',
708
+ opened: Boolean(item.attrs?.opened),
709
+ contentType: 'text',
710
+ bodyHtml: item.content.map(renderStandardNode).join(''),
711
+ }
712
+ }),
713
+ }
714
+ }
715
+
716
+ function buildTiptapRenderNodes(messageDocument) {
717
+ const nodes = []
718
+ let standardContent = []
719
+ const flushStandardContent = () => {
720
+ if (!standardContent.length) return
721
+ nodes.push({
722
+ type: 'html',
723
+ html: standardContent.map(renderStandardNode).join(''),
724
+ })
725
+ standardContent = []
726
+ }
727
+
728
+ messageDocument.content.forEach(node => {
729
+ if (node.type === 'accordionBlock') {
730
+ flushStandardContent()
731
+ nodes.push({ type: 'accordion', block: accordionToRenderBlock(node) })
732
+ } else if (node.type === 'tableauBlock') {
733
+ flushStandardContent()
734
+ nodes.push({
735
+ type: 'tableau',
736
+ block: {
737
+ id: node.attrs?.blockId || '',
738
+ type: 'tableau',
739
+ ...node.attrs,
740
+ },
741
+ })
742
+ } else {
743
+ standardContent.push(node)
744
+ }
745
+ })
746
+ flushStandardContent()
747
+ return nodes
748
+ }
749
+
750
+ function unsupportedModel(detected, bodyContent, validationIssue) {
751
+ return {
752
+ format: detected.format,
753
+ status: MESSAGE_DOCUMENT_STATUS.UNSUPPORTED,
754
+ reason:
755
+ validationIssue ||
756
+ issue('unsupported-body-json', 'bodyJson', detected.reason),
757
+ blocks: [],
758
+ nodes: bodyContent ? [{ type: 'html', html: bodyContent }] : [],
759
+ }
760
+ }
761
+
762
+ export function containsBodyBlockPlaceholder(html) {
763
+ if (!html) return false
764
+ return new RegExp(LEGACY_PLACEHOLDER_PATTERN).test(html)
765
+ }
766
+
767
+ export function buildRichBodyRenderModel({
768
+ bodyContent = '',
769
+ bodyJson = '',
770
+ } = {}) {
771
+ const detected = detectBodyJsonFormat(bodyJson)
772
+
773
+ if (detected.format === BODY_JSON_FORMAT.EMPTY) {
774
+ return {
775
+ format: BODY_JSON_FORMAT.EMPTY,
776
+ status: MESSAGE_DOCUMENT_STATUS.LEGACY,
777
+ reason: null,
778
+ blocks: [],
779
+ nodes: buildLegacyRenderNodes(bodyContent, []),
780
+ }
781
+ }
782
+ if (detected.format === BODY_JSON_FORMAT.LEGACY_BLOCKS) {
783
+ if (detected.reason) return unsupportedModel(detected, bodyContent)
784
+ const blocks = detected.value.blocks.filter(isRecord)
785
+ return {
786
+ format: BODY_JSON_FORMAT.LEGACY_BLOCKS,
787
+ status: MESSAGE_DOCUMENT_STATUS.LEGACY,
788
+ reason: null,
789
+ blocks,
790
+ nodes: buildLegacyRenderNodes(bodyContent, blocks),
791
+ }
792
+ }
793
+ if (detected.format !== BODY_JSON_FORMAT.TIPTAP) {
794
+ return unsupportedModel(detected, bodyContent)
795
+ }
796
+
797
+ const validationIssue = validateNode(detected.value, 'bodyJson')
798
+ if (validationIssue) {
799
+ return unsupportedModel(detected, bodyContent, validationIssue)
800
+ }
801
+ return {
802
+ format: BODY_JSON_FORMAT.TIPTAP,
803
+ status: MESSAGE_DOCUMENT_STATUS.SUPPORTED,
804
+ reason: null,
805
+ blocks: [],
806
+ nodes: buildTiptapRenderNodes(detected.value),
807
+ }
808
+ }