@bendyline/squisq-react 1.4.2 → 2.0.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 (45) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +174 -27
  3. package/dist/index.js +1244 -603
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.global.js +54 -37
  6. package/dist/squisq-player.global.js.map +1 -1
  7. package/dist/standalone-source.js +1 -1
  8. package/package.json +2 -2
  9. package/src/BlockRenderer.tsx +53 -17
  10. package/src/DocControlsSlideshow.tsx +222 -5
  11. package/src/DocPlayer.tsx +367 -183
  12. package/src/DocPlayerWithSidebar.tsx +4 -0
  13. package/src/DocProgressBar.tsx +40 -1
  14. package/src/LinearDocView.tsx +135 -62
  15. package/src/MarkdownRenderer.tsx +40 -97
  16. package/src/MediaClipLayer.tsx +12 -2
  17. package/src/__tests__/BlockRenderer.test.tsx +79 -8
  18. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  19. package/src/__tests__/DocPlayer.test.tsx +505 -0
  20. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  21. package/src/__tests__/LinearDocView.test.tsx +91 -11
  22. package/src/__tests__/MapLayer.test.tsx +63 -0
  23. package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
  24. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  25. package/src/__tests__/MediaContext.test.tsx +51 -0
  26. package/src/__tests__/PathLayer.test.tsx +12 -1
  27. package/src/__tests__/VideoLayer.test.tsx +94 -0
  28. package/src/__tests__/fillStyle.test.tsx +3 -2
  29. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  30. package/src/__tests__/useAudioSync.test.ts +49 -0
  31. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  32. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  33. package/src/hooks/MediaContext.tsx +12 -3
  34. package/src/hooks/useAudioSync.ts +61 -12
  35. package/src/hooks/useDocPlayback.ts +40 -12
  36. package/src/hooks/useViewportOrientation.ts +2 -4
  37. package/src/index.ts +5 -2
  38. package/src/layers/MapLayer.tsx +7 -6
  39. package/src/layers/PathLayer.tsx +20 -11
  40. package/src/layers/ShapeLayer.tsx +4 -2
  41. package/src/layers/TextLayer.tsx +4 -3
  42. package/src/layers/TreeLayer.tsx +167 -0
  43. package/src/layers/VideoLayer.tsx +20 -6
  44. package/src/standalone-entry.tsx +91 -14
  45. package/src/types.ts +13 -13
@@ -36,6 +36,8 @@ interface DocPlayerWithSidebarProps {
36
36
  onTimeUpdate?: (time: number) => void;
37
37
  /** Optional audio controller (if not provided, uses default HTML5 audio) */
38
38
  audioController?: AudioController;
39
+ /** Whether to render slide transitions and per-layer animations (default: true). */
40
+ animationsEnabled?: boolean;
39
41
  muted?: boolean;
40
42
  captionsEnabled?: boolean;
41
43
  isFullscreen?: boolean;
@@ -75,6 +77,7 @@ export function DocPlayerWithSidebar({
75
77
  onEnded,
76
78
  onTimeUpdate,
77
79
  audioController,
80
+ animationsEnabled = true,
78
81
  muted,
79
82
  captionsEnabled,
80
83
  isFullscreen,
@@ -132,6 +135,7 @@ export function DocPlayerWithSidebar({
132
135
  onEnded={onEnded}
133
136
  onTimeUpdate={onTimeUpdate}
134
137
  audioController={audioController}
138
+ animationsEnabled={animationsEnabled}
135
139
  muted={muted}
136
140
  captionsEnabled={captionsEnabled}
137
141
  showControls={isFullscreen}
@@ -63,6 +63,32 @@ export function DocProgressBar({
63
63
  setHoverPosition(null);
64
64
  }, []);
65
65
 
66
+ const handleProgressKeyDown = useCallback(
67
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
68
+ let next: number | null = null;
69
+ switch (e.key) {
70
+ case 'ArrowLeft':
71
+ case 'ArrowDown':
72
+ next = state.currentTime - 5;
73
+ break;
74
+ case 'ArrowRight':
75
+ case 'ArrowUp':
76
+ next = state.currentTime + 5;
77
+ break;
78
+ case 'Home':
79
+ next = 0;
80
+ break;
81
+ case 'End':
82
+ next = state.totalDuration;
83
+ break;
84
+ }
85
+ if (next == null) return;
86
+ e.preventDefault();
87
+ actions.seekTo(Math.max(0, Math.min(state.totalDuration, next)));
88
+ },
89
+ [actions, state.currentTime, state.totalDuration],
90
+ );
91
+
66
92
  const getBlockAtTimeLocal = useCallback(
67
93
  (time: number): { block: Block; index: number } | null => {
68
94
  for (let i = expandedBlocks.length - 1; i >= 0; i--) {
@@ -79,6 +105,8 @@ export function DocProgressBar({
79
105
  return (
80
106
  <div
81
107
  ref={progressBarRef}
108
+ role="group"
109
+ aria-label="Playback timeline"
82
110
  style={{
83
111
  flex: 1,
84
112
  height: '24px',
@@ -98,6 +126,14 @@ export function DocProgressBar({
98
126
  >
99
127
  {/* Track background */}
100
128
  <div
129
+ role="slider"
130
+ tabIndex={0}
131
+ aria-label="Playback position"
132
+ aria-valuemin={0}
133
+ aria-valuemax={state.totalDuration}
134
+ aria-valuenow={Math.max(0, Math.min(state.totalDuration, state.currentTime))}
135
+ aria-valuetext={`${formatTime(state.currentTime)} of ${formatTime(state.totalDuration)}`}
136
+ onKeyDown={handleProgressKeyDown}
101
137
  style={{
102
138
  position: 'absolute',
103
139
  left: 0,
@@ -132,7 +168,8 @@ export function DocProgressBar({
132
168
 
133
169
  {/* Block markers (dots) */}
134
170
  {blockMarkers.map((marker, i) => (
135
- <div
171
+ <button
172
+ type="button"
136
173
  key={`${marker.block.id}-${i}`}
137
174
  style={{
138
175
  position: 'absolute',
@@ -144,11 +181,13 @@ export function DocProgressBar({
144
181
  background:
145
182
  marker.index === state.currentBlockIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
146
183
  border: '2px solid #5b9bd5',
184
+ padding: 0,
147
185
  cursor: 'pointer',
148
186
  zIndex: 2,
149
187
  transition: 'transform 0.15s, background 0.15s',
150
188
  }}
151
189
  title={marker.title}
190
+ aria-label={`Seek to ${marker.title}`}
152
191
  onClick={(e) => {
153
192
  e.stopPropagation();
154
193
  actions.seekTo(marker.block.startTime);
@@ -13,11 +13,11 @@
13
13
  * - Headings from the block hierarchy rendered as HTML headings
14
14
  * - Body content rendered via MarkdownRenderer
15
15
  * - Template-annotated sections show an SVG card (BlockRenderer)
16
- * using `getLayers()` for on-demand layer computation
16
+ * using `materializeBlockLayers()` for on-demand layer computation
17
17
  * - Blocks are rendered recursively to preserve the heading hierarchy
18
18
  */
19
19
 
20
- import { useMemo } from 'react';
20
+ import { useEffect, useMemo, useRef } from 'react';
21
21
  import { useAutoSurface } from './hooks/useAutoSurface';
22
22
  import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
23
23
  import type { ViewportConfig } from '@bendyline/squisq/schemas';
@@ -29,13 +29,13 @@ import {
29
29
  } from '@bendyline/squisq/schemas';
30
30
  import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
31
31
  import {
32
- getLayers,
33
- hasTemplate,
32
+ materializeBlockLayers,
34
33
  markdownToDoc,
35
34
  DEFAULT_THEME,
36
35
  deriveTemplateInputs,
36
+ isTemplateBlock,
37
37
  } from '@bendyline/squisq/doc';
38
- import type { RenderContext } from '@bendyline/squisq/doc';
38
+ import type { MaterializeBlockLayersOptions } from '@bendyline/squisq/doc';
39
39
  import { extractPlainText, parseMarkdown } from '@bendyline/squisq/markdown';
40
40
  import { BlockRenderer } from './BlockRenderer';
41
41
  import { MarkdownRenderer } from './MarkdownRenderer';
@@ -62,6 +62,8 @@ export interface LinearDocViewProps {
62
62
  className?: string;
63
63
  /** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
64
64
  theme?: Theme;
65
+ /** Whether inline visual cards render their layer animations (default: true). */
66
+ animationsEnabled?: boolean;
65
67
  /**
66
68
  * Optional surface scheme (light / dark paper) overlaid on top of the
67
69
  * theme's colors. Orthogonal to `theme` — a theme picks editorial
@@ -87,43 +89,35 @@ export interface LinearDocViewProps {
87
89
  * full-size images would dominate the layout.
88
90
  */
89
91
  imageDisplayMode?: ImageDisplayMode;
92
+ /**
93
+ * Let unmodified Up/Down arrows scroll this view even when it does not
94
+ * currently hold focus. Intended for a primary document preview.
95
+ */
96
+ globalKeyboardShortcuts?: boolean;
90
97
  }
91
98
 
92
99
  export type ImageDisplayMode = 'inline' | 'thumbnail';
93
100
 
94
101
  // ── Helpers ────────────────────────────────────────────────────────
95
102
 
96
- // Unknown template names we've already warned about (module-level so each
97
- // name warns at most once per page, not once per render).
98
- const warnedUnknownTemplates = new Set<string>();
99
-
100
103
  /**
101
104
  * Determine whether a block has a template annotation that should be
102
- * rendered as a visual SVG card. A block is "annotated" when:
103
- * 1. Its sourceHeading has a templateAnnotation, AND
104
- * 2. The annotated template exists in the registry
105
- *
106
- * Blocks annotated with a template that is NOT in the registry fall back
107
- * to plain markdown rendering, with a one-shot dev-visible warning per
108
- * unknown template name.
105
+ * rendered as a visual SVG card. Unknown templates remain annotated so the
106
+ * materializer can return a visible fallback and structured diagnostic.
109
107
  */
110
108
  function isAnnotatedBlock(block: Block): boolean {
111
- const annotation = block.sourceHeading?.templateAnnotation;
112
- if (!annotation?.template) return false;
113
- if (!hasTemplate(annotation.template)) {
114
- if (!warnedUnknownTemplates.has(annotation.template)) {
115
- warnedUnknownTemplates.add(annotation.template);
116
- console.warn(
117
- `[squisq] Unknown template "${annotation.template}" rendering the block as plain markdown.`,
118
- );
119
- }
120
- return false;
121
- }
122
- return true;
109
+ return (
110
+ !!block.sourceHeading?.templateAnnotation?.template ||
111
+ (!block.sourceHeading && isTemplateBlock(block as DocBlock))
112
+ );
113
+ }
114
+
115
+ function visualTemplateName(block: Block): string | undefined {
116
+ return block.sourceHeading?.templateAnnotation?.template ?? block.template;
123
117
  }
124
118
 
125
119
  /**
126
- * Count total blocks in a hierarchy (for RenderContext.totalBlocks).
120
+ * Count total blocks in a hierarchy for the materialization context.
127
121
  */
128
122
  function countAll(blocks: Block[]): number {
129
123
  let count = 0;
@@ -140,55 +134,75 @@ interface BlockSectionProps {
140
134
  block: Block;
141
135
  basePath: string;
142
136
  viewport: ViewportConfig;
143
- renderContext: RenderContext;
137
+ renderContext: MaterializeBlockLayersOptions;
144
138
  blockIndex: number;
139
+ blockIndices: ReadonlyMap<Block, number>;
140
+ animationsEnabled: boolean;
145
141
  }
146
142
 
147
143
  /**
148
144
  * Render a single block section: heading + body content or SVG card.
149
145
  * Recurses into children to render the full heading tree.
150
146
  */
151
- function BlockSection({ block, basePath, viewport, renderContext, blockIndex }: BlockSectionProps) {
147
+ function BlockSection({
148
+ block,
149
+ basePath,
150
+ viewport,
151
+ renderContext,
152
+ blockIndex,
153
+ blockIndices,
154
+ animationsEnabled,
155
+ }: BlockSectionProps) {
152
156
  const isAnnotated = isAnnotatedBlock(block);
153
157
 
154
158
  // For annotated blocks, compute layers and build a Block with them
155
159
  const visualBlock = useMemo(() => {
156
160
  if (!isAnnotated) return null;
157
161
 
158
- const annotation = block.sourceHeading!.templateAnnotation!;
159
- const headingText = extractPlainText(block.sourceHeading!);
162
+ const annotation = block.sourceHeading?.templateAnnotation;
163
+ const templateName = visualTemplateName(block) ?? 'sectionHeader';
160
164
 
161
- // Build a TemplateBlock-compatible object
162
- const templateBlock: Record<string, unknown> = {
163
- id: block.id,
164
- template: annotation.template,
165
- startTime: 0,
166
- duration: 1,
167
- audioSegment: 0,
168
- title: headingText,
169
- ...(deriveTemplateInputs(
170
- annotation.template ?? 'sectionHeader',
171
- headingText,
172
- block.contents,
173
- {
174
- placeholders: true,
175
- },
176
- ) ?? {}),
177
- ...annotation.params,
178
- ...block.templateOverrides,
179
- };
165
+ // Authored Markdown blocks derive their typed template inputs from the
166
+ // heading/body. Transform-generated blocks already ARE typed template
167
+ // inputs, so materialize them directly instead of looking for authoring
168
+ // nodes they intentionally do not carry.
169
+ const templateBlock: Record<string, unknown> = annotation
170
+ ? (() => {
171
+ const headingText = extractPlainText(block.sourceHeading!);
172
+ return {
173
+ id: block.id,
174
+ template: templateName,
175
+ startTime: 0,
176
+ duration: 1,
177
+ audioSegment: 0,
178
+ title: headingText,
179
+ contents: block.contents,
180
+ children: block.children,
181
+ ...(deriveTemplateInputs(templateName, headingText, block.contents, {
182
+ placeholders: true,
183
+ }) ?? {}),
184
+ ...annotation.params,
185
+ ...block.templateOverrides,
186
+ };
187
+ })()
188
+ : {
189
+ ...block,
190
+ startTime: block.startTime ?? 0,
191
+ duration: block.duration ?? 1,
192
+ audioSegment: block.audioSegment ?? 0,
193
+ template: templateName,
194
+ };
180
195
 
181
- // Compute layers via getLayers
182
- const ctx: RenderContext = {
196
+ const ctx: MaterializeBlockLayersOptions = {
183
197
  ...renderContext,
184
198
  blockIndex,
185
199
  };
186
- const layers = getLayers(templateBlock as unknown as DocBlock, ctx);
200
+ const { layers } = materializeBlockLayers(templateBlock as unknown as DocBlock, ctx);
187
201
 
188
202
  return {
189
203
  ...block,
190
204
  layers,
191
- template: annotation.template,
205
+ template: templateName,
192
206
  } as Block;
193
207
  }, [block, isAnnotated, renderContext, blockIndex]);
194
208
 
@@ -196,7 +210,8 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
196
210
  <div
197
211
  className="squisq-linear-section"
198
212
  data-block-id={block.id}
199
- data-template={isAnnotated ? block.sourceHeading?.templateAnnotation?.template : undefined}
213
+ data-block-index={blockIndex}
214
+ data-template={isAnnotated ? visualTemplateName(block) : undefined}
200
215
  >
201
216
  {/* Render the heading (if present — preamble has no sourceHeading) */}
202
217
  {block.sourceHeading && !isAnnotated && <MarkdownRenderer nodes={[block.sourceHeading]} />}
@@ -224,6 +239,7 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
224
239
  blockTime={0}
225
240
  basePath={basePath}
226
241
  viewport={viewport}
242
+ animationsEnabled={animationsEnabled}
227
243
  />
228
244
  </div>
229
245
  </div>
@@ -244,7 +260,9 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
244
260
  basePath={basePath}
245
261
  viewport={viewport}
246
262
  renderContext={renderContext}
247
- blockIndex={blockIndex + i + 1}
263
+ blockIndex={blockIndices.get(child) ?? blockIndex + i + 1}
264
+ blockIndices={blockIndices}
265
+ animationsEnabled={animationsEnabled}
248
266
  />
249
267
  ))}
250
268
  </div>
@@ -275,9 +293,12 @@ export function LinearDocView({
275
293
  className,
276
294
  theme,
277
295
  surface,
296
+ animationsEnabled = true,
278
297
  thinMargins = false,
279
298
  imageDisplayMode = 'inline',
299
+ globalKeyboardShortcuts = false,
280
300
  }: LinearDocViewProps) {
301
+ const scrollRef = useRef<HTMLDivElement>(null);
281
302
  const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
282
303
 
283
304
  // Parse markdown into a Doc only when no explicit doc is supplied.
@@ -291,10 +312,22 @@ export function LinearDocView({
291
312
  () => (resolvedDoc ? countAll(resolvedDoc.blocks) : 0),
292
313
  [resolvedDoc],
293
314
  );
315
+ const blockIndices = useMemo(() => {
316
+ const indices = new Map<Block, number>();
317
+ let index = 0;
318
+ const visit = (blocks: Block[]) => {
319
+ for (const block of blocks) {
320
+ indices.set(block, index++);
321
+ if (block.children) visit(block.children);
322
+ }
323
+ };
324
+ if (resolvedDoc) visit(resolvedDoc.blocks);
325
+ return indices;
326
+ }, [resolvedDoc]);
294
327
  const autoSurface = useAutoSurface(surface === 'auto');
295
328
  const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
296
329
 
297
- const renderContext: RenderContext = useMemo(() => {
330
+ const renderContext: MaterializeBlockLayersOptions = useMemo(() => {
298
331
  const baseTheme = theme ?? DEFAULT_THEME;
299
332
  const effectiveTheme = resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme;
300
333
  return {
@@ -304,15 +337,52 @@ export function LinearDocView({
304
337
  // Theme atmosphere (vignette/grain/gradient persistent layers) shows
305
338
  // on the inline template cards so they match the player's look.
306
339
  persistentLayers: effectiveTheme.persistentLayers,
340
+ customTemplates: resolvedDoc?.customTemplates,
307
341
  };
308
- }, [activeViewport, totalBlocks, theme, resolvedSurface]);
342
+ }, [activeViewport, resolvedDoc?.customTemplates, totalBlocks, theme, resolvedSurface]);
309
343
 
310
344
  const activeTheme = renderContext.theme!;
311
345
 
346
+ useEffect(() => {
347
+ if (!globalKeyboardShortcuts) return;
348
+ const handleKeyDown = (event: KeyboardEvent) => {
349
+ if (
350
+ event.defaultPrevented ||
351
+ event.altKey ||
352
+ event.ctrlKey ||
353
+ event.metaKey ||
354
+ event.shiftKey ||
355
+ (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
356
+ ) {
357
+ return;
358
+ }
359
+ const target = event.target instanceof Element ? event.target : null;
360
+ if (
361
+ target?.closest(
362
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], .monaco-editor',
363
+ )
364
+ ) {
365
+ return;
366
+ }
367
+ const scroller = scrollRef.current;
368
+ if (!scroller) return;
369
+ event.preventDefault();
370
+ const distance = Math.max(64, Math.round(scroller.clientHeight * 0.12));
371
+ scroller.scrollBy({
372
+ top: event.key === 'ArrowDown' ? distance : -distance,
373
+ behavior: 'smooth',
374
+ });
375
+ };
376
+ document.addEventListener('keydown', handleKeyDown);
377
+ return () => document.removeEventListener('keydown', handleKeyDown);
378
+ }, [globalKeyboardShortcuts]);
379
+
312
380
  // Nothing to render — keep an empty (but classed) container so hosts can
313
381
  // still target/measure the view.
314
382
  if (!resolvedDoc) {
315
- return <div className={`squisq-linear squisq-linear--empty ${className || ''}`} />;
383
+ return (
384
+ <div ref={scrollRef} className={`squisq-linear squisq-linear--empty ${className || ''}`} />
385
+ );
316
386
  }
317
387
 
318
388
  const bgColor = activeTheme.colors.background;
@@ -325,6 +395,7 @@ export function LinearDocView({
325
395
 
326
396
  return (
327
397
  <div
398
+ ref={scrollRef}
328
399
  className={`squisq-linear ${className || ''}`}
329
400
  style={{
330
401
  width: '100%',
@@ -484,7 +555,9 @@ export function LinearDocView({
484
555
  basePath={basePath}
485
556
  viewport={activeViewport}
486
557
  renderContext={renderContext}
487
- blockIndex={i}
558
+ blockIndex={blockIndices.get(block) ?? i}
559
+ blockIndices={blockIndices}
560
+ animationsEnabled={animationsEnabled}
488
561
  />
489
562
  ))}
490
563
  </div>
@@ -158,27 +158,12 @@ function renderInline(
158
158
 
159
159
  case 'htmlInline':
160
160
  if (ctx.htmlPolicy === 'strip') return null;
161
- // Fast path: no <video>/<audio> in the subtree use the original
162
- // rawHtml passthrough (preserves arbitrary HTML for custom embeds).
163
- if (
164
- ctx.htmlPolicy === 'trusted' &&
165
- !containsMediaTag(node.htmlChildren) &&
166
- !containsDangerousTag(node.htmlChildren) &&
167
- !hasDangerousRawHtml(node.rawHtml)
168
- ) {
169
- return (
170
- <span
171
- key={key}
172
- className="squisq-md-html-inline"
173
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
174
- />
175
- );
176
- }
177
- // Otherwise reconstruct the subtree as React so <video>/<audio>
178
- // go through MediaContext-aware player components.
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.
179
164
  return (
180
165
  <span key={key} className="squisq-md-html-inline">
181
- {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
166
+ {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
182
167
  </span>
183
168
  );
184
169
 
@@ -292,27 +277,11 @@ function renderBlock(
292
277
 
293
278
  case 'htmlBlock':
294
279
  if (ctx.htmlPolicy === 'strip') return null;
295
- // Fast path: no <video>/<audio> preserve the existing rawHtml
296
- // passthrough so arbitrary HTML embeds still survive verbatim.
297
- if (
298
- ctx.htmlPolicy === 'trusted' &&
299
- !containsMediaTag(node.htmlChildren) &&
300
- !containsDangerousTag(node.htmlChildren) &&
301
- !hasDangerousRawHtml(node.rawHtml)
302
- ) {
303
- return (
304
- <div
305
- key={key}
306
- className="squisq-md-html-block"
307
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
308
- />
309
- );
310
- }
311
- // Otherwise reconstruct subtree as React so <video>/<audio>
312
- // route through the player components and resolve via MediaContext.
280
+ // The structural walker is deliberately the only rendering path: raw
281
+ // HTML strings never bypass the tag, attribute, and URL policy below.
313
282
  return (
314
283
  <div key={key} className="squisq-md-html-block">
315
- {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
284
+ {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
316
285
  </div>
317
286
  );
318
287
 
@@ -467,25 +436,13 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
467
436
 
468
437
  // ── Raw-HTML walker (intercepts <video>/<audio>) ─────────────────
469
438
 
470
- /** True when the htmlElement subtree contains a tag we want to swap
471
- * for a React component. Cheap recursive scan — lets us keep the
472
- * `dangerouslySetInnerHTML` fast path for everything else. */
439
+ /** Apply the caller's structural HTML policy before React reconstruction. */
473
440
  function resolveHtmlNodes(nodes: HtmlNode[], htmlPolicy: HtmlPolicy): HtmlNode[] {
474
441
  if (htmlPolicy === 'strip') return [];
475
442
  if (htmlPolicy === 'trusted') return nodes;
476
443
  return sanitizeHtmlNodes(nodes);
477
444
  }
478
445
 
479
- function containsMediaTag(nodes: HtmlNode[]): boolean {
480
- for (const node of nodes) {
481
- if (node.type !== 'htmlElement') continue;
482
- const tagName = node.tagName.toLowerCase();
483
- if (tagName === 'video' || tagName === 'audio') return true;
484
- if (containsMediaTag(node.children)) return true;
485
- }
486
- return false;
487
- }
488
-
489
446
  /**
490
447
  * Tags that can escape their container and affect the whole host
491
448
  * document — global styling, script execution, external/resource loads,
@@ -510,41 +467,9 @@ const DANGEROUS_HTML_TAGS = new Set([
510
467
  'title',
511
468
  ]);
512
469
 
513
- /** True when the subtree contains any host-affecting tag (see
514
- * {@link DANGEROUS_HTML_TAGS}). Mirrors {@link containsMediaTag}: keeps
515
- * such content off the verbatim `dangerouslySetInnerHTML` fast path so
516
- * it routes through the React reconstruction, which drops the tag. */
517
- function containsDangerousTag(nodes: HtmlNode[]): boolean {
518
- for (const node of nodes) {
519
- if (node.type !== 'htmlElement') continue;
520
- if (DANGEROUS_HTML_TAGS.has(node.tagName.toLowerCase())) return true;
521
- if (containsDangerousTag(node.children)) return true;
522
- }
523
- return false;
524
- }
525
-
526
- /**
527
- * Raw-string backstop for {@link DANGEROUS_HTML_TAGS}. The structural
528
- * {@link containsDangerousTag} check covers the normal case, but a block
529
- * parsed with `parseHtml: false` carries an empty `htmlChildren` while
530
- * `rawHtml` still holds the markup — so the verbatim fast path scans the
531
- * raw string too, guaranteeing a `<style>`/`<script>` can never be
532
- * injected into the host document by that path no matter how the node
533
- * was produced. The `\b` keeps `<styled-thing>` from matching `<style>`.
534
- */
535
- const DANGEROUS_RAW_HTML_RE =
536
- /<\s*\/?\s*(?:base|embed|iframe|link|meta|object|script|style|title)\b/i;
537
-
538
- function hasDangerousRawHtml(rawHtml: string): boolean {
539
- return DANGEROUS_RAW_HTML_RE.test(rawHtml);
540
- }
541
-
542
470
  /** A pragmatic shortlist of HTML attributes the raw-HTML walker
543
471
  * passes through to React when reconstructing a non-media element.
544
- * Anything outside this list is silently dropped — the media-tag
545
- * fast path means most authors will never hit this code, so we
546
- * keep the surface narrow to avoid React warnings about unknown
547
- * attributes. */
472
+ * Anything outside this list is silently dropped. */
548
473
  const PASSTHROUGH_ATTRS: Record<string, string> = {
549
474
  // common
550
475
  class: 'className',
@@ -564,7 +489,10 @@ const PASSTHROUGH_ATTRS: Record<string, string> = {
564
489
  rel: 'rel',
565
490
  };
566
491
 
567
- function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unknown> {
492
+ function reactPropsFromAttrs(
493
+ attrs: Record<string, string>,
494
+ ctx: RenderCtx,
495
+ ): Record<string, unknown> {
568
496
  const out: Record<string, unknown> = {};
569
497
  for (const [name, value] of Object.entries(attrs)) {
570
498
  const propName = PASSTHROUGH_ATTRS[name];
@@ -576,26 +504,36 @@ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unkn
576
504
  out['data-style'] = value;
577
505
  continue;
578
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
+ }
579
517
  out[propName] = value;
580
518
  }
581
519
  return out;
582
520
  }
583
521
 
584
- function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
522
+ function renderHtmlElement(el: HtmlElement, key: string, ctx: RenderCtx): React.ReactNode {
585
523
  const tagName = el.tagName.toLowerCase();
586
- // Final safety net: never reconstruct a host-affecting element (e.g. a
587
- // <style> that would leak globally), whatever the policy. The fast path
588
- // is gated by containsDangerousTag, so trusted content carrying these
589
- // tags lands here — drop the tag and keep the rest of the subtree.
524
+ // Never reconstruct a host-affecting element (e.g. a <style> that would
525
+ // leak globally), whatever the policy.
590
526
  if (DANGEROUS_HTML_TAGS.has(tagName)) return null;
591
527
  if (tagName === 'video') {
528
+ const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
529
+ const poster = sanitizeUrl(el.attributes.poster ?? '', 'media') ?? undefined;
592
530
  return (
593
531
  <InlineVideoPlayer
594
532
  key={key}
595
- src={el.attributes.src ?? ''}
533
+ src={src}
596
534
  width={el.attributes.width}
597
535
  height={el.attributes.height}
598
- poster={el.attributes.poster}
536
+ poster={poster}
599
537
  // The `controls` attribute is a boolean — present means true,
600
538
  // even if its value is an empty string.
601
539
  controls={'controls' in el.attributes}
@@ -610,10 +548,11 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
610
548
  );
611
549
  }
612
550
  if (tagName === 'audio') {
551
+ const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
613
552
  return (
614
553
  <InlineAudioPlayer
615
554
  key={key}
616
- src={el.attributes.src ?? ''}
555
+ src={src}
617
556
  controls={'controls' in el.attributes}
618
557
  preload={
619
558
  el.attributes.preload === 'none' ||
@@ -627,23 +566,27 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
627
566
  }
628
567
 
629
568
  const Tag = tagName as keyof JSX.IntrinsicElements;
630
- const props = reactPropsFromAttrs(el.attributes);
569
+ const props = reactPropsFromAttrs(el.attributes, ctx);
631
570
  if (el.selfClosing) {
632
571
  return <Tag key={key} {...props} />;
633
572
  }
634
573
  return (
635
574
  <Tag key={key} {...props}>
636
- {renderHtmlNodes(el.children, `${key}c`)}
575
+ {renderHtmlNodes(el.children, `${key}c`, ctx)}
637
576
  </Tag>
638
577
  );
639
578
  }
640
579
 
641
- function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[] {
580
+ function renderHtmlNodes(
581
+ nodes: HtmlNode[],
582
+ keyPrefix: string,
583
+ ctx: RenderCtx = DEFAULT_CTX,
584
+ ): React.ReactNode[] {
642
585
  return nodes.map((node, i) => {
643
586
  const key = `${keyPrefix}${i}`;
644
587
  switch (node.type) {
645
588
  case 'htmlElement':
646
- return renderHtmlElement(node, key);
589
+ return renderHtmlElement(node, key, ctx);
647
590
  case 'htmlText':
648
591
  return <Fragment key={key}>{node.value}</Fragment>;
649
592
  case 'htmlComment':