@asd20/ui-next 2.16.0 → 2.17.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,12 @@
1
1
  # Changelog
2
2
 
3
+ # [2.17.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.16.0...ui-next-v2.17.0) (2026-08-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * add canonical video rendering ([295fbfd](https://github.com/academydistrict20/asd20-ui-next/commit/295fbfd38d3d66c2bc1e0070fb98a15ba6e51d02))
9
+
3
10
  # [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
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -157,5 +157,30 @@ export default {
157
157
  :deep(.asd20-message-table td > :last-child) {
158
158
  margin-bottom: 0;
159
159
  }
160
+
161
+ :deep(.asd20-message-video) {
162
+ max-width: 100%;
163
+ margin: 0.75rem 0;
164
+ }
165
+
166
+ :deep(.asd20-message-video__description) {
167
+ margin: 0 0 0.5rem;
168
+ }
169
+
170
+ :deep(.asd20-message-video__frame) {
171
+ position: relative;
172
+ width: 100%;
173
+ overflow: hidden;
174
+ aspect-ratio: 16 / 9;
175
+ background: #000;
176
+ }
177
+
178
+ :deep(.asd20-message-video__frame iframe) {
179
+ position: absolute;
180
+ inset: 0;
181
+ width: 100%;
182
+ height: 100%;
183
+ border: 0;
184
+ }
160
185
  }
161
186
  </style>
@@ -56,6 +56,22 @@ const MARK_TYPES = new Set([
56
56
  'superscript',
57
57
  ])
58
58
  const TABLE_HEADER_SCOPES = new Set(['col', 'row', 'colgroup', 'rowgroup'])
59
+ const VIDEO_TITLE_MAX_LENGTH = 200
60
+ const VIDEO_DESCRIPTION_MAX_LENGTH = 1000
61
+ const VIDEO_PROVIDER_RULES = Object.freeze([
62
+ {
63
+ host: 'www.youtube.com',
64
+ pathPattern: /^\/embed\/([A-Za-z0-9_-]{11})$/,
65
+ },
66
+ {
67
+ host: 'www.youtube-nocookie.com',
68
+ pathPattern: /^\/embed\/([A-Za-z0-9_-]{11})$/,
69
+ },
70
+ {
71
+ host: 'player.vimeo.com',
72
+ pathPattern: /^\/video\/([0-9]+)$/,
73
+ },
74
+ ])
59
75
  const MARK_RENDER_ORDER = Object.freeze({
60
76
  link: 0,
61
77
  bold: 1,
@@ -196,6 +212,45 @@ function isSafeResource(value, allowHash = false) {
196
212
  }
197
213
  }
198
214
 
215
+ function isCanonicalVideoEmbedSource(value) {
216
+ if (typeof value !== 'string' || !value) return false
217
+
218
+ try {
219
+ const url = new URL(value)
220
+ if (
221
+ url.protocol !== 'https:' ||
222
+ url.username ||
223
+ url.password ||
224
+ url.port ||
225
+ url.search ||
226
+ url.hash
227
+ ) {
228
+ return false
229
+ }
230
+
231
+ const rule = VIDEO_PROVIDER_RULES.find(
232
+ candidate => candidate.host === url.hostname.toLowerCase()
233
+ )
234
+ return Boolean(
235
+ rule &&
236
+ rule.pathPattern.test(url.pathname) &&
237
+ value === `https://${rule.host}${url.pathname}`
238
+ )
239
+ } catch (_error) {
240
+ return false
241
+ }
242
+ }
243
+
244
+ function validateRequiredCanonicalText(node, name, path, maximumLength) {
245
+ const attrIssue = validateStringAttribute(node, name, path, true)
246
+ if (attrIssue) return attrIssue
247
+
248
+ const value = node.attrs[name]
249
+ return !value || value !== value.trim() || value.length > maximumLength
250
+ ? issue('malformed-attribute', path, name)
251
+ : null
252
+ }
253
+
199
254
  function validateMarks(marks, path) {
200
255
  if (marks === undefined) return null
201
256
  if (!Array.isArray(marks)) return issue('malformed-marks', path)
@@ -396,14 +451,24 @@ function validateNode(node, path) {
396
451
  return validateAtom(node, path)
397
452
  }
398
453
  if (node.type === 'videoEmbed') {
399
- const srcIssue = validateStringAttribute(node, 'src', path)
400
- if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
454
+ const srcIssue = validateStringAttribute(node, 'src', path, true)
455
+ if (srcIssue || !isCanonicalVideoEmbedSource(node.attrs?.src)) {
401
456
  return srcIssue || issue('unsafe-resource', path, 'src')
402
457
  }
403
- for (const name of ['title', 'descriptiveText']) {
404
- const attrIssue = validateStringAttribute(node, name, path)
405
- if (attrIssue) return attrIssue
406
- }
458
+ const titleIssue = validateRequiredCanonicalText(
459
+ node,
460
+ 'title',
461
+ path,
462
+ VIDEO_TITLE_MAX_LENGTH
463
+ )
464
+ if (titleIssue) return titleIssue
465
+ const descriptionIssue = validateRequiredCanonicalText(
466
+ node,
467
+ 'descriptiveText',
468
+ path,
469
+ VIDEO_DESCRIPTION_MAX_LENGTH
470
+ )
471
+ if (descriptionIssue) return descriptionIssue
407
472
  return validateAtom(node, path)
408
473
  }
409
474
  if (node.type === 'table') {
@@ -666,16 +731,25 @@ function renderStandardNode(node) {
666
731
  )}${attribute('height', node.attrs.height)}>`
667
732
  }
668
733
  if (node.type === 'videoEmbed') {
669
- return `<div data-cc-node="video"${attribute(
734
+ return `<div class="asd20-message-video" data-cc-node="video"${attribute(
670
735
  'data-src',
671
736
  node.attrs.src
672
737
  )}${attribute('data-title', node.attrs.title)}${attribute(
673
738
  'data-descriptive-text',
674
739
  node.attrs.descriptiveText
675
- )}><iframe${attribute('src', node.attrs.src)}${attribute(
740
+ )}><p class="asd20-message-video__description">${escapeHtml(
741
+ node.attrs.descriptiveText
742
+ )}</p><div class="asd20-message-video__frame"><iframe${attribute(
743
+ 'src',
744
+ node.attrs.src
745
+ )}${attribute(
676
746
  'title',
677
- node.attrs.title || 'Embedded video'
678
- )}></iframe></div>`
747
+ node.attrs.title
748
+ )} loading="lazy" referrerpolicy="strict-origin-when-cross-origin"${attribute(
749
+ 'allowfullscreen',
750
+ '',
751
+ true
752
+ )}></iframe></div></div>`
679
753
  }
680
754
  if (node.type === 'table') return renderTable(node)
681
755
  if (node.type === 'tableRow') return `<tr>${renderChildren(node)}</tr>`