@bendyline/squisq-react 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. package/src/utils/mapTileUtils.ts +0 -375
@@ -1,624 +0,0 @@
1
- /**
2
- * MarkdownRenderer Component
3
- *
4
- * Converts a MarkdownBlockNode[] AST into React elements for rendering
5
- * markdown content as readable HTML. Used by LinearDocView to display
6
- * non-annotated document sections as flowing text.
7
- *
8
- * Supports all block and inline node types from the markdown DOM:
9
- * - Block: paragraph, heading, blockquote, list, code, table,
10
- * thematicBreak, math, htmlBlock, definitionList, directives
11
- * - Inline: text, emphasis, strong, delete, inlineCode, link,
12
- * image, break, inlineMath, htmlInline, footnoteReference
13
- *
14
- * All elements use the `squisq-md-*` CSS class prefix for styling.
15
- */
16
-
17
- import { Fragment } from 'react';
18
- import {
19
- sanitizeHtmlNodes,
20
- sanitizeUrl,
21
- type HtmlPolicy,
22
- type HtmlElement,
23
- type HtmlNode,
24
- MarkdownBlockNode,
25
- MarkdownInlineNode,
26
- MarkdownListItem,
27
- MarkdownTableRow,
28
- MarkdownTableCell,
29
- } from '@bendyline/squisq/markdown';
30
- import { useMediaUrl } from './hooks/MediaContext';
31
- import { InlineVideoPlayer } from './InlineVideoPlayer.js';
32
- import { InlineAudioPlayer } from './InlineAudioPlayer.js';
33
-
34
- // ── Props ──────────────────────────────────────────────────────────
35
-
36
- export interface MarkdownRendererProps {
37
- /** Block-level AST nodes to render */
38
- nodes: MarkdownBlockNode[];
39
- /** Optional CSS class for the wrapper element */
40
- className?: string;
41
- /**
42
- * Raw HTML policy. Defaults to `sanitize`, which removes unsafe tags,
43
- * event handlers, and executable URL schemes before rendering.
44
- */
45
- htmlPolicy?: HtmlPolicy;
46
- /**
47
- * Extra URL schemes to allow on links (e.g. a host app's internal
48
- * navigation scheme it intercepts on click). Executable schemes are
49
- * never allowed regardless. See {@link SanitizeUrlOptions}.
50
- */
51
- linkSchemes?: readonly string[];
52
- }
53
-
54
- /** Options threaded through the recursive renderers. */
55
- interface RenderCtx {
56
- htmlPolicy: HtmlPolicy;
57
- linkSchemes?: readonly string[];
58
- }
59
-
60
- const DEFAULT_CTX: RenderCtx = { htmlPolicy: 'sanitize' };
61
-
62
- // ── Inline Renderer ────────────────────────────────────────────────
63
-
64
- /** Render an array of inline nodes into React elements. */
65
- function renderInline(
66
- nodes: MarkdownInlineNode[],
67
- keyPrefix = '',
68
- ctx: RenderCtx = DEFAULT_CTX,
69
- ): React.ReactNode[] {
70
- return nodes.map((node, i) => {
71
- const key = `${keyPrefix}i${i}`;
72
- switch (node.type) {
73
- case 'text': {
74
- // Soft breaks (newlines without two trailing spaces) become \n in
75
- // text nodes. HTML collapses \n to whitespace, which loses the visual
76
- // line break the author wanted, so render <br> for each newline.
77
- if (!node.value.includes('\n')) {
78
- return <Fragment key={key}>{node.value}</Fragment>;
79
- }
80
- const parts = node.value.split('\n');
81
- return (
82
- <Fragment key={key}>
83
- {parts.map((part, j) => (
84
- <Fragment key={j}>
85
- {j > 0 && <br />}
86
- {part}
87
- </Fragment>
88
- ))}
89
- </Fragment>
90
- );
91
- }
92
-
93
- case 'emphasis':
94
- return (
95
- <em key={key} className="squisq-md-em">
96
- {renderInline(node.children, key, ctx)}
97
- </em>
98
- );
99
-
100
- case 'strong':
101
- return (
102
- <strong key={key} className="squisq-md-strong">
103
- {renderInline(node.children, key, ctx)}
104
- </strong>
105
- );
106
-
107
- case 'delete':
108
- return (
109
- <del key={key} className="squisq-md-del">
110
- {renderInline(node.children, key, ctx)}
111
- </del>
112
- );
113
-
114
- case 'inlineCode':
115
- return (
116
- <code key={key} className="squisq-md-inline-code">
117
- {node.value}
118
- </code>
119
- );
120
-
121
- case 'link': {
122
- const href = sanitizeUrl(node.url, 'link', { extraLinkSchemes: ctx.linkSchemes });
123
- if (!href) {
124
- return (
125
- <span key={key} className="squisq-md-link squisq-md-link--blocked">
126
- {renderInline(node.children, key, ctx)}
127
- </span>
128
- );
129
- }
130
- return (
131
- <a
132
- key={key}
133
- className="squisq-md-link"
134
- href={href}
135
- title={node.title ?? undefined}
136
- target="_blank"
137
- rel="noopener noreferrer"
138
- >
139
- {renderInline(node.children, key, ctx)}
140
- </a>
141
- );
142
- }
143
-
144
- case 'image':
145
- return (
146
- <MdImage key={key} src={node.url} alt={node.alt ?? ''} title={node.title ?? undefined} />
147
- );
148
-
149
- case 'break':
150
- return <br key={key} />;
151
-
152
- case 'inlineMath':
153
- return (
154
- <code key={key} className="squisq-md-inline-math">
155
- {node.value}
156
- </code>
157
- );
158
-
159
- case 'htmlInline':
160
- if (ctx.htmlPolicy === 'strip') return null;
161
- // Reconstruct every subtree as React. Besides routing media through
162
- // MediaContext, this keeps event-handler attributes and unsafe URLs
163
- // out of the DOM even when the caller selected `trusted` structure.
164
- return (
165
- <span key={key} className="squisq-md-html-inline">
166
- {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
167
- </span>
168
- );
169
-
170
- case 'footnoteReference':
171
- return (
172
- <sup key={key} className="squisq-md-footnote-ref">
173
- <a href={`#fn-${node.identifier}`}>[{node.label ?? node.identifier}]</a>
174
- </sup>
175
- );
176
-
177
- case 'linkReference':
178
- // Render as plain text (definition targets not available at render time)
179
- return (
180
- <span key={key} className="squisq-md-link-ref">
181
- {renderInline(node.children, key, ctx)}
182
- </span>
183
- );
184
-
185
- case 'imageReference':
186
- return (
187
- <span key={key} className="squisq-md-image-ref">
188
- [{node.alt ?? node.identifier}]
189
- </span>
190
- );
191
-
192
- case 'textDirective':
193
- return (
194
- <span key={key} className="squisq-md-text-directive" data-directive={node.name}>
195
- {renderInline(node.children, key, ctx)}
196
- </span>
197
- );
198
-
199
- case 'mention':
200
- return (
201
- <span
202
- key={key}
203
- className="squisq-md-mention mention"
204
- data-mention="true"
205
- data-kind={node.targetKind}
206
- data-id={node.targetId}
207
- data-label={node.displayName}
208
- >
209
- @{node.displayName}
210
- </span>
211
- );
212
-
213
- default:
214
- return null;
215
- }
216
- });
217
- }
218
-
219
- // ── Block Renderer ─────────────────────────────────────────────────
220
-
221
- /** Render a single block-level node into a React element. */
222
- function renderBlock(
223
- node: MarkdownBlockNode,
224
- key: string,
225
- ctx: RenderCtx = DEFAULT_CTX,
226
- ): React.ReactNode {
227
- switch (node.type) {
228
- case 'paragraph':
229
- return (
230
- <p key={key} className="squisq-md-p">
231
- {renderInline(node.children, key, ctx)}
232
- </p>
233
- );
234
-
235
- case 'heading': {
236
- const Tag = `h${node.depth}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
237
- return (
238
- <Tag key={key} className={`squisq-md-heading squisq-md-h${node.depth}`}>
239
- {renderInline(node.children, key, ctx)}
240
- </Tag>
241
- );
242
- }
243
-
244
- case 'blockquote':
245
- return (
246
- <blockquote key={key} className="squisq-md-blockquote">
247
- {renderBlocks(node.children, key, ctx)}
248
- </blockquote>
249
- );
250
-
251
- case 'list':
252
- if (node.ordered) {
253
- return (
254
- <ol key={key} className="squisq-md-list squisq-md-ol" start={node.start ?? undefined}>
255
- {node.children.map((item, i) => renderListItem(item, `${key}li${i}`, ctx))}
256
- </ol>
257
- );
258
- }
259
- return (
260
- <ul key={key} className="squisq-md-list squisq-md-ul">
261
- {node.children.map((item, i) => renderListItem(item, `${key}li${i}`, ctx))}
262
- </ul>
263
- );
264
-
265
- case 'code':
266
- return (
267
- <pre key={key} className="squisq-md-code-block">
268
- <code className={node.lang ? `language-${node.lang}` : undefined}>{node.value}</code>
269
- </pre>
270
- );
271
-
272
- case 'thematicBreak':
273
- return <hr key={key} className="squisq-md-hr" />;
274
-
275
- case 'table':
276
- return renderTable(node.children, node.align, key, ctx);
277
-
278
- case 'htmlBlock':
279
- if (ctx.htmlPolicy === 'strip') return null;
280
- // The structural walker is deliberately the only rendering path: raw
281
- // HTML strings never bypass the tag, attribute, and URL policy below.
282
- return (
283
- <div key={key} className="squisq-md-html-block">
284
- {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
285
- </div>
286
- );
287
-
288
- case 'math':
289
- return (
290
- <pre key={key} className="squisq-md-math-block">
291
- <code>{node.value}</code>
292
- </pre>
293
- );
294
-
295
- case 'definition':
296
- // Link definitions aren't rendered visually
297
- return null;
298
-
299
- case 'footnoteDefinition':
300
- return (
301
- <div key={key} className="squisq-md-footnote-def" id={`fn-${node.identifier}`}>
302
- <sup>{node.label ?? node.identifier}</sup>
303
- {renderBlocks(node.children, key, ctx)}
304
- </div>
305
- );
306
-
307
- case 'containerDirective':
308
- return (
309
- <div
310
- key={key}
311
- className={`squisq-md-directive squisq-md-directive-${node.name}`}
312
- data-directive={node.name}
313
- >
314
- {node.label && <div className="squisq-md-directive-label">{node.label}</div>}
315
- {renderBlocks(node.children, key, ctx)}
316
- </div>
317
- );
318
-
319
- case 'leafDirective':
320
- return (
321
- <div
322
- key={key}
323
- className={`squisq-md-directive squisq-md-directive-${node.name}`}
324
- data-directive={node.name}
325
- >
326
- {renderInline(node.children, key, ctx)}
327
- </div>
328
- );
329
-
330
- case 'definitionList':
331
- return (
332
- <dl key={key} className="squisq-md-dl">
333
- {node.children.map((child, i) => {
334
- if (child.type === 'definitionTerm') {
335
- return (
336
- <dt key={`${key}dt${i}`} className="squisq-md-dt">
337
- {renderInline(child.children, `${key}dt${i}`, ctx)}
338
- </dt>
339
- );
340
- }
341
- return (
342
- <dd key={`${key}dd${i}`} className="squisq-md-dd">
343
- {renderBlocks(child.children, `${key}dd${i}`, ctx)}
344
- </dd>
345
- );
346
- })}
347
- </dl>
348
- );
349
-
350
- default:
351
- return null;
352
- }
353
- }
354
-
355
- /** Render a list item, including task-list checkbox support. */
356
- function renderListItem(
357
- item: MarkdownListItem,
358
- key: string,
359
- ctx: RenderCtx = DEFAULT_CTX,
360
- ): React.ReactNode {
361
- const isTask = item.checked !== null && item.checked !== undefined;
362
- return (
363
- <li key={key} className={`squisq-md-li${isTask ? ' squisq-md-task' : ''}`}>
364
- {isTask && (
365
- <input type="checkbox" checked={!!item.checked} readOnly className="squisq-md-checkbox" />
366
- )}
367
- {renderBlocks(item.children, key, ctx)}
368
- </li>
369
- );
370
- }
371
-
372
- /** Render a table from rows and alignment data. */
373
- function renderTable(
374
- rows: MarkdownTableRow[],
375
- align: (('left' | 'right' | 'center') | null)[] | undefined,
376
- key: string,
377
- ctx: RenderCtx = DEFAULT_CTX,
378
- ): React.ReactNode {
379
- const [headerRow, ...bodyRows] = rows;
380
- return (
381
- <table key={key} className="squisq-md-table">
382
- {headerRow && (
383
- <thead>
384
- <tr>
385
- {headerRow.children.map((cell: MarkdownTableCell, ci: number) => (
386
- <th
387
- key={`${key}th${ci}`}
388
- className="squisq-md-th"
389
- style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
390
- >
391
- {renderInline(cell.children, `${key}th${ci}`, ctx)}
392
- </th>
393
- ))}
394
- </tr>
395
- </thead>
396
- )}
397
- {bodyRows.length > 0 && (
398
- <tbody>
399
- {bodyRows.map((row, ri) => (
400
- <tr key={`${key}tr${ri}`}>
401
- {row.children.map((cell: MarkdownTableCell, ci: number) => (
402
- <td
403
- key={`${key}td${ri}-${ci}`}
404
- className="squisq-md-td"
405
- style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
406
- >
407
- {renderInline(cell.children, `${key}td${ri}-${ci}`, ctx)}
408
- </td>
409
- ))}
410
- </tr>
411
- ))}
412
- </tbody>
413
- )}
414
- </table>
415
- );
416
- }
417
-
418
- /** Render an array of block-level nodes. */
419
- function renderBlocks(
420
- nodes: MarkdownBlockNode[],
421
- keyPrefix = '',
422
- ctx: RenderCtx = DEFAULT_CTX,
423
- ): React.ReactNode[] {
424
- return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`, ctx));
425
- }
426
-
427
- // ── Image with MediaProvider resolution ───────────────────────────
428
-
429
- /** Renders an <img> that resolves its src through the MediaProvider when available. */
430
- function MdImage({ src, alt, title }: { src: string; alt: string; title?: string }) {
431
- const safeSrc = sanitizeUrl(src, 'media');
432
- const resolved = useMediaUrl(safeSrc ?? '', '.');
433
- if (!safeSrc) return null;
434
- return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
435
- }
436
-
437
- // ── Raw-HTML walker (intercepts <video>/<audio>) ─────────────────
438
-
439
- /** Apply the caller's structural HTML policy before React reconstruction. */
440
- function resolveHtmlNodes(nodes: HtmlNode[], htmlPolicy: HtmlPolicy): HtmlNode[] {
441
- if (htmlPolicy === 'strip') return [];
442
- if (htmlPolicy === 'trusted') return nodes;
443
- return sanitizeHtmlNodes(nodes);
444
- }
445
-
446
- /**
447
- * Tags that can escape their container and affect the whole host
448
- * document — global styling, script execution, external/resource loads,
449
- * or framing. Markdown content is frequently untrusted (LLM output,
450
- * pasted snippets), and these tags have no business mutating the page
451
- * they're embedded in, so they are *always* dropped — even under the
452
- * `'trusted'` policy. "Trusted" means "render this HTML's structure
453
- * verbatim," not "let it restyle or script the host." A stray `<style>`
454
- * applies document-wide (CSS has no per-element scoping outside shadow
455
- * DOM / iframes), which is exactly how an embedded game's `<style>`
456
- * leaked onto the surrounding app chrome.
457
- */
458
- const DANGEROUS_HTML_TAGS = new Set([
459
- 'base',
460
- 'embed',
461
- 'iframe',
462
- 'link',
463
- 'meta',
464
- 'object',
465
- 'script',
466
- 'style',
467
- 'title',
468
- ]);
469
-
470
- /** A pragmatic shortlist of HTML attributes the raw-HTML walker
471
- * passes through to React when reconstructing a non-media element.
472
- * Anything outside this list is silently dropped. */
473
- const PASSTHROUGH_ATTRS: Record<string, string> = {
474
- // common
475
- class: 'className',
476
- id: 'id',
477
- title: 'title',
478
- style: 'style',
479
- // media-adjacent (used when video/audio appear inside other wrappers)
480
- width: 'width',
481
- height: 'height',
482
- src: 'src',
483
- alt: 'alt',
484
- loading: 'loading',
485
- decoding: 'decoding',
486
- // anchor
487
- href: 'href',
488
- target: 'target',
489
- rel: 'rel',
490
- };
491
-
492
- function reactPropsFromAttrs(
493
- attrs: Record<string, string>,
494
- ctx: RenderCtx,
495
- ): Record<string, unknown> {
496
- const out: Record<string, unknown> = {};
497
- for (const [name, value] of Object.entries(attrs)) {
498
- const propName = PASSTHROUGH_ATTRS[name];
499
- if (!propName) continue;
500
- if (propName === 'style') {
501
- // We can't safely parse arbitrary `style="..."` strings without
502
- // an HTML parser; preserve as a data attribute so authors can
503
- // see it survived round-trip without crashing React.
504
- out['data-style'] = value;
505
- continue;
506
- }
507
- if (propName === 'href') {
508
- const href = sanitizeUrl(value, 'link', { extraLinkSchemes: ctx.linkSchemes });
509
- if (href) out.href = href;
510
- continue;
511
- }
512
- if (propName === 'src') {
513
- const src = sanitizeUrl(value, 'media');
514
- if (src) out.src = src;
515
- continue;
516
- }
517
- out[propName] = value;
518
- }
519
- return out;
520
- }
521
-
522
- function renderHtmlElement(el: HtmlElement, key: string, ctx: RenderCtx): React.ReactNode {
523
- const tagName = el.tagName.toLowerCase();
524
- // Never reconstruct a host-affecting element (e.g. a <style> that would
525
- // leak globally), whatever the policy.
526
- if (DANGEROUS_HTML_TAGS.has(tagName)) return null;
527
- if (tagName === 'video') {
528
- const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
529
- const poster = sanitizeUrl(el.attributes.poster ?? '', 'media') ?? undefined;
530
- return (
531
- <InlineVideoPlayer
532
- key={key}
533
- src={src}
534
- width={el.attributes.width}
535
- height={el.attributes.height}
536
- poster={poster}
537
- // The `controls` attribute is a boolean — present means true,
538
- // even if its value is an empty string.
539
- controls={'controls' in el.attributes}
540
- preload={
541
- el.attributes.preload === 'none' ||
542
- el.attributes.preload === 'metadata' ||
543
- el.attributes.preload === 'auto'
544
- ? el.attributes.preload
545
- : undefined
546
- }
547
- />
548
- );
549
- }
550
- if (tagName === 'audio') {
551
- const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
552
- return (
553
- <InlineAudioPlayer
554
- key={key}
555
- src={src}
556
- controls={'controls' in el.attributes}
557
- preload={
558
- el.attributes.preload === 'none' ||
559
- el.attributes.preload === 'metadata' ||
560
- el.attributes.preload === 'auto'
561
- ? el.attributes.preload
562
- : undefined
563
- }
564
- />
565
- );
566
- }
567
-
568
- const Tag = tagName as keyof JSX.IntrinsicElements;
569
- const props = reactPropsFromAttrs(el.attributes, ctx);
570
- if (el.selfClosing) {
571
- return <Tag key={key} {...props} />;
572
- }
573
- return (
574
- <Tag key={key} {...props}>
575
- {renderHtmlNodes(el.children, `${key}c`, ctx)}
576
- </Tag>
577
- );
578
- }
579
-
580
- function renderHtmlNodes(
581
- nodes: HtmlNode[],
582
- keyPrefix: string,
583
- ctx: RenderCtx = DEFAULT_CTX,
584
- ): React.ReactNode[] {
585
- return nodes.map((node, i) => {
586
- const key = `${keyPrefix}${i}`;
587
- switch (node.type) {
588
- case 'htmlElement':
589
- return renderHtmlElement(node, key, ctx);
590
- case 'htmlText':
591
- return <Fragment key={key}>{node.value}</Fragment>;
592
- case 'htmlComment':
593
- // Comments don't render in React; ignore.
594
- return null;
595
- default:
596
- return null;
597
- }
598
- });
599
- }
600
-
601
- // ── Main Component ─────────────────────────────────────────────────
602
-
603
- /**
604
- * Renders MarkdownBlockNode[] AST as React HTML elements.
605
- *
606
- * @example
607
- * ```tsx
608
- * <MarkdownRenderer nodes={block.contents} />
609
- * ```
610
- */
611
- export function MarkdownRenderer({
612
- nodes,
613
- className,
614
- htmlPolicy = 'sanitize',
615
- linkSchemes,
616
- }: MarkdownRendererProps) {
617
- if (!nodes || nodes.length === 0) return null;
618
-
619
- return (
620
- <div className={`squisq-md ${className || ''}`}>
621
- {renderBlocks(nodes, '', { htmlPolicy, linkSchemes })}
622
- </div>
623
- );
624
- }