@asd20/ui-next 2.16.0 → 2.18.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,19 @@
1
1
  # Changelog
2
2
 
3
+ # [2.18.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.17.0...ui-next-v2.18.0) (2026-08-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * add tiptap gallery support ([7371f56](https://github.com/academydistrict20/asd20-ui-next/commit/7371f561c6c0a3e6a2275a9537beba72c86e6837))
9
+
10
+ # [2.17.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.16.0...ui-next-v2.17.0) (2026-08-05)
11
+
12
+
13
+ ### Features
14
+
15
+ * add canonical video rendering ([295fbfd](https://github.com/academydistrict20/asd20-ui-next/commit/295fbfd38d3d66c2bc1e0070fb98a15ba6e51d02))
16
+
3
17
  # [2.16.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.15.1...ui-next-v2.16.0) (2026-08-04)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -157,5 +157,61 @@ export default {
157
157
  :deep(.asd20-message-table td > :last-child) {
158
158
  margin-bottom: 0;
159
159
  }
160
+
161
+ :deep(.asd20-message-image-gallery) {
162
+ box-sizing: border-box;
163
+ max-width: 100%;
164
+ margin: 1rem 0;
165
+ overflow: hidden;
166
+ }
167
+
168
+ :deep(.asd20-message-image-gallery__images) {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ gap: 0.75rem;
173
+ width: 100%;
174
+ height: auto !important;
175
+ max-height: min(32rem, 70vh) !important;
176
+ overflow: hidden;
177
+ }
178
+
179
+ :deep(.asd20-message-image-gallery__images > img) {
180
+ box-sizing: border-box;
181
+ display: block;
182
+ flex: 0 1 auto;
183
+ width: auto !important;
184
+ min-width: 0;
185
+ max-width: none !important;
186
+ height: auto !important;
187
+ max-height: min(32rem, 70vh) !important;
188
+ margin: 0 !important;
189
+ object-fit: contain;
190
+ }
191
+
192
+ :deep(.asd20-message-video) {
193
+ max-width: 100%;
194
+ margin: 0.75rem 0;
195
+ }
196
+
197
+ :deep(.asd20-message-video__description) {
198
+ margin: 0 0 0.5rem;
199
+ }
200
+
201
+ :deep(.asd20-message-video__frame) {
202
+ position: relative;
203
+ width: 100%;
204
+ overflow: hidden;
205
+ aspect-ratio: 16 / 9;
206
+ background: #000;
207
+ }
208
+
209
+ :deep(.asd20-message-video__frame iframe) {
210
+ position: absolute;
211
+ inset: 0;
212
+ width: 100%;
213
+ height: 100%;
214
+ border: 0;
215
+ }
160
216
  }
161
217
  </style>
@@ -27,6 +27,7 @@ const STANDARD_BLOCK_NODE_TYPES = new Set([
27
27
  'bulletList',
28
28
  'orderedList',
29
29
  'image',
30
+ 'imageGallery',
30
31
  'videoEmbed',
31
32
  'table',
32
33
  ])
@@ -56,6 +57,22 @@ const MARK_TYPES = new Set([
56
57
  'superscript',
57
58
  ])
58
59
  const TABLE_HEADER_SCOPES = new Set(['col', 'row', 'colgroup', 'rowgroup'])
60
+ const VIDEO_TITLE_MAX_LENGTH = 200
61
+ const VIDEO_DESCRIPTION_MAX_LENGTH = 1000
62
+ const VIDEO_PROVIDER_RULES = Object.freeze([
63
+ {
64
+ host: 'www.youtube.com',
65
+ pathPattern: /^\/embed\/([A-Za-z0-9_-]{11})$/,
66
+ },
67
+ {
68
+ host: 'www.youtube-nocookie.com',
69
+ pathPattern: /^\/embed\/([A-Za-z0-9_-]{11})$/,
70
+ },
71
+ {
72
+ host: 'player.vimeo.com',
73
+ pathPattern: /^\/video\/([0-9]+)$/,
74
+ },
75
+ ])
59
76
  const MARK_RENDER_ORDER = Object.freeze({
60
77
  link: 0,
61
78
  bold: 1,
@@ -78,6 +95,7 @@ const NODE_ATTRIBUTE_NAMES = Object.freeze({
78
95
  orderedList: ['start', 'type'],
79
96
  listItem: [],
80
97
  image: ['src', 'alt', 'title', 'width', 'height'],
98
+ imageGallery: ['images'],
81
99
  videoEmbed: ['src', 'title', 'descriptiveText'],
82
100
  table: ['caption'],
83
101
  tableRow: [],
@@ -196,6 +214,57 @@ function isSafeResource(value, allowHash = false) {
196
214
  }
197
215
  }
198
216
 
217
+ function isSafeImageResource(value) {
218
+ if (typeof value !== 'string' || !value.trim()) return false
219
+ if (value.trim().startsWith('#')) return false
220
+
221
+ try {
222
+ const url = new URL(value.trim(), 'https://ui-next.invalid')
223
+ return url.protocol === 'http:' || url.protocol === 'https:'
224
+ } catch (_error) {
225
+ return false
226
+ }
227
+ }
228
+
229
+ function isCanonicalVideoEmbedSource(value) {
230
+ if (typeof value !== 'string' || !value) return false
231
+
232
+ try {
233
+ const url = new URL(value)
234
+ if (
235
+ url.protocol !== 'https:' ||
236
+ url.username ||
237
+ url.password ||
238
+ url.port ||
239
+ url.search ||
240
+ url.hash
241
+ ) {
242
+ return false
243
+ }
244
+
245
+ const rule = VIDEO_PROVIDER_RULES.find(
246
+ candidate => candidate.host === url.hostname.toLowerCase()
247
+ )
248
+ return Boolean(
249
+ rule &&
250
+ rule.pathPattern.test(url.pathname) &&
251
+ value === `https://${rule.host}${url.pathname}`
252
+ )
253
+ } catch (_error) {
254
+ return false
255
+ }
256
+ }
257
+
258
+ function validateRequiredCanonicalText(node, name, path, maximumLength) {
259
+ const attrIssue = validateStringAttribute(node, name, path, true)
260
+ if (attrIssue) return attrIssue
261
+
262
+ const value = node.attrs[name]
263
+ return !value || value !== value.trim() || value.length > maximumLength
264
+ ? issue('malformed-attribute', path, name)
265
+ : null
266
+ }
267
+
199
268
  function validateMarks(marks, path) {
200
269
  if (marks === undefined) return null
201
270
  if (!Array.isArray(marks)) return issue('malformed-marks', path)
@@ -273,6 +342,33 @@ function validateAtom(node, path) {
273
342
  return null
274
343
  }
275
344
 
345
+ function validateImageAttributes(node, path, requiredSource = false) {
346
+ const srcIssue = validateStringAttribute(
347
+ node,
348
+ 'src',
349
+ path,
350
+ requiredSource
351
+ )
352
+ if (srcIssue || (node.attrs?.src && !isSafeImageResource(node.attrs.src))) {
353
+ return srcIssue || issue('unsafe-resource', path, 'src')
354
+ }
355
+ for (const name of ['alt', 'title']) {
356
+ const attrIssue = validateStringAttribute(node, name, path)
357
+ if (attrIssue) return attrIssue
358
+ }
359
+ for (const name of ['width', 'height']) {
360
+ const value = node.attrs?.[name]
361
+ if (
362
+ value !== undefined &&
363
+ value !== null &&
364
+ (!Number.isFinite(value) || value <= 0)
365
+ ) {
366
+ return issue('malformed-attribute', path, name)
367
+ }
368
+ }
369
+ return null
370
+ }
371
+
276
372
  function validateNode(node, path) {
277
373
  if (!isRecord(node) || typeof node.type !== 'string') {
278
374
  return issue('malformed-node', path)
@@ -375,35 +471,47 @@ function validateNode(node, path) {
375
471
  : issue('invalid-child-node', `${path}.content[0]`, node.content[0].type)
376
472
  }
377
473
  if (node.type === 'image') {
378
- const srcIssue = validateStringAttribute(node, 'src', path)
379
- if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
380
- return srcIssue || issue('unsafe-resource', path, 'src')
381
- }
382
- for (const name of ['alt', 'title']) {
383
- const attrIssue = validateStringAttribute(node, name, path)
384
- if (attrIssue) return attrIssue
385
- }
386
- for (const name of ['width', 'height']) {
387
- const value = node.attrs?.[name]
388
- if (
389
- value !== undefined &&
390
- value !== null &&
391
- (!Number.isFinite(value) || value <= 0)
392
- ) {
393
- return issue('malformed-attribute', path, name)
474
+ return validateImageAttributes(node, path) || validateAtom(node, path)
475
+ }
476
+ if (node.type === 'imageGallery') {
477
+ const atomIssue = validateAtom(node, path)
478
+ if (atomIssue) return atomIssue
479
+ const images = node.attrs?.images
480
+ if (!Array.isArray(images) || images.length < 2 || images.length > 3) {
481
+ return issue('malformed-attribute', path, 'images')
482
+ }
483
+ for (let index = 0; index < images.length; index += 1) {
484
+ const imagePath = `${path}.attrs.images[${index}]`
485
+ if (!isRecord(images[index])) {
486
+ return issue('malformed-attribute', imagePath, 'image')
394
487
  }
488
+ const imageNode = { type: 'image', attrs: images[index] }
489
+ const attributeNameIssue = validateAttributeNames(imageNode, imagePath)
490
+ if (attributeNameIssue) return attributeNameIssue
491
+ const imageIssue = validateImageAttributes(imageNode, imagePath, true)
492
+ if (imageIssue) return imageIssue
395
493
  }
396
- return validateAtom(node, path)
494
+ return null
397
495
  }
398
496
  if (node.type === 'videoEmbed') {
399
- const srcIssue = validateStringAttribute(node, 'src', path)
400
- if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
497
+ const srcIssue = validateStringAttribute(node, 'src', path, true)
498
+ if (srcIssue || !isCanonicalVideoEmbedSource(node.attrs?.src)) {
401
499
  return srcIssue || issue('unsafe-resource', path, 'src')
402
500
  }
403
- for (const name of ['title', 'descriptiveText']) {
404
- const attrIssue = validateStringAttribute(node, name, path)
405
- if (attrIssue) return attrIssue
406
- }
501
+ const titleIssue = validateRequiredCanonicalText(
502
+ node,
503
+ 'title',
504
+ path,
505
+ VIDEO_TITLE_MAX_LENGTH
506
+ )
507
+ if (titleIssue) return titleIssue
508
+ const descriptionIssue = validateRequiredCanonicalText(
509
+ node,
510
+ 'descriptiveText',
511
+ path,
512
+ VIDEO_DESCRIPTION_MAX_LENGTH
513
+ )
514
+ if (descriptionIssue) return descriptionIssue
407
515
  return validateAtom(node, path)
408
516
  }
409
517
  if (node.type === 'table') {
@@ -627,6 +735,17 @@ function renderTable(node) {
627
735
  )}</tbody></table></div>`
628
736
  }
629
737
 
738
+ function renderImage(attrs) {
739
+ return `<img${attribute('src', attrs.src)}${attribute(
740
+ 'alt',
741
+ attrs.alt,
742
+ true
743
+ )}${attribute('title', attrs.title)}${attribute(
744
+ 'width',
745
+ attrs.width
746
+ )}${attribute('height', attrs.height)}>`
747
+ }
748
+
630
749
  function renderStandardNode(node) {
631
750
  if (node.type === 'text') return renderMarkedText(node)
632
751
  if (node.type === 'hardBreak') return '<br>'
@@ -656,26 +775,33 @@ function renderStandardNode(node) {
656
775
  }
657
776
  if (node.type === 'listItem') return `<li>${renderChildren(node)}</li>`
658
777
  if (node.type === 'image') {
659
- return `<img${attribute('src', node.attrs.src)}${attribute(
660
- 'alt',
661
- node.attrs.alt,
662
- true
663
- )}${attribute('title', node.attrs.title)}${attribute(
664
- 'width',
665
- node.attrs.width
666
- )}${attribute('height', node.attrs.height)}>`
778
+ return renderImage(node.attrs)
779
+ }
780
+ if (node.type === 'imageGallery') {
781
+ return `<figure class="asd20-message-image-gallery" data-cc-node="image-gallery"><div class="asd20-message-image-gallery__images" data-cc-gallery-images="">${node.attrs.images
782
+ .map(renderImage)
783
+ .join('')}</div></figure>`
667
784
  }
668
785
  if (node.type === 'videoEmbed') {
669
- return `<div data-cc-node="video"${attribute(
786
+ return `<div class="asd20-message-video" data-cc-node="video"${attribute(
670
787
  'data-src',
671
788
  node.attrs.src
672
789
  )}${attribute('data-title', node.attrs.title)}${attribute(
673
790
  'data-descriptive-text',
674
791
  node.attrs.descriptiveText
675
- )}><iframe${attribute('src', node.attrs.src)}${attribute(
792
+ )}><p class="asd20-message-video__description">${escapeHtml(
793
+ node.attrs.descriptiveText
794
+ )}</p><div class="asd20-message-video__frame"><iframe${attribute(
795
+ 'src',
796
+ node.attrs.src
797
+ )}${attribute(
676
798
  'title',
677
- node.attrs.title || 'Embedded video'
678
- )}></iframe></div>`
799
+ node.attrs.title
800
+ )} loading="lazy" referrerpolicy="strict-origin-when-cross-origin"${attribute(
801
+ 'allowfullscreen',
802
+ '',
803
+ true
804
+ )}></iframe></div></div>`
679
805
  }
680
806
  if (node.type === 'table') return renderTable(node)
681
807
  if (node.type === 'tableRow') return `<tr>${renderChildren(node)}</tr>`