@bendyline/squisq-react 1.3.1 → 1.4.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 (215) hide show
  1. package/dist/index.d.ts +766 -35
  2. package/dist/index.js +4793 -33
  3. package/dist/index.js.map +1 -1
  4. package/dist/squisq-player.css +1 -1
  5. package/dist/squisq-player.css.map +1 -1
  6. package/dist/squisq-player.global.js +17 -13
  7. package/dist/squisq-player.global.js.map +1 -1
  8. package/dist/standalone-source.js +1 -1
  9. package/package.json +3 -2
  10. package/src/BlockRenderer.tsx +15 -7
  11. package/src/DocPlayer.tsx +65 -12
  12. package/src/DocProgressBar.tsx +21 -3
  13. package/src/LinearDocView.tsx +11 -197
  14. package/src/MarkdownRenderer.tsx +165 -41
  15. package/src/MediaClipLayer.tsx +135 -0
  16. package/src/__tests__/DocPlayer.test.tsx +51 -0
  17. package/src/__tests__/DocProgressBar.test.tsx +76 -0
  18. package/src/__tests__/MarkdownRenderer.test.tsx +95 -1
  19. package/src/__tests__/PathLayer.test.tsx +73 -0
  20. package/src/__tests__/fillStyle.test.tsx +112 -0
  21. package/src/__tests__/transitionStyles.test.ts +125 -0
  22. package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
  23. package/src/hooks/useAudioSync.ts +14 -1
  24. package/src/hooks/useDocPlayback.ts +81 -100
  25. package/src/hooks/useMediaSchedule.ts +39 -0
  26. package/src/index.ts +7 -0
  27. package/src/layers/ImageLayer.tsx +11 -1
  28. package/src/layers/PathLayer.tsx +146 -0
  29. package/src/layers/ShapeLayer.tsx +27 -5
  30. package/src/layers/TextLayer.tsx +395 -22
  31. package/src/layers/VideoLayer.tsx +16 -9
  32. package/src/layers/index.ts +1 -0
  33. package/src/styles/doc-animations.css +1857 -2
  34. package/src/utils/fillStyle.tsx +148 -0
  35. package/dist/BlockRenderer.d.ts +0 -37
  36. package/dist/BlockRenderer.d.ts.map +0 -1
  37. package/dist/BlockRenderer.js +0 -54
  38. package/dist/BlockRenderer.js.map +0 -1
  39. package/dist/CaptionOverlay.d.ts +0 -33
  40. package/dist/CaptionOverlay.d.ts.map +0 -1
  41. package/dist/CaptionOverlay.js +0 -44
  42. package/dist/CaptionOverlay.js.map +0 -1
  43. package/dist/DocControlsBottom.d.ts +0 -28
  44. package/dist/DocControlsBottom.d.ts.map +0 -1
  45. package/dist/DocControlsBottom.js +0 -27
  46. package/dist/DocControlsBottom.js.map +0 -1
  47. package/dist/DocControlsOverlay.d.ts +0 -28
  48. package/dist/DocControlsOverlay.d.ts.map +0 -1
  49. package/dist/DocControlsOverlay.js +0 -80
  50. package/dist/DocControlsOverlay.js.map +0 -1
  51. package/dist/DocControlsSidebar.d.ts +0 -27
  52. package/dist/DocControlsSidebar.d.ts.map +0 -1
  53. package/dist/DocControlsSidebar.js +0 -10
  54. package/dist/DocControlsSidebar.js.map +0 -1
  55. package/dist/DocControlsSlideshow.d.ts +0 -20
  56. package/dist/DocControlsSlideshow.d.ts.map +0 -1
  57. package/dist/DocControlsSlideshow.js +0 -69
  58. package/dist/DocControlsSlideshow.js.map +0 -1
  59. package/dist/DocPlayer.d.ts +0 -97
  60. package/dist/DocPlayer.d.ts.map +0 -1
  61. package/dist/DocPlayer.js +0 -657
  62. package/dist/DocPlayer.js.map +0 -1
  63. package/dist/DocPlayerWithSidebar.d.ts +0 -41
  64. package/dist/DocPlayerWithSidebar.d.ts.map +0 -1
  65. package/dist/DocPlayerWithSidebar.js +0 -69
  66. package/dist/DocPlayerWithSidebar.js.map +0 -1
  67. package/dist/DocProgressBar.d.ts +0 -31
  68. package/dist/DocProgressBar.d.ts.map +0 -1
  69. package/dist/DocProgressBar.js +0 -122
  70. package/dist/DocProgressBar.js.map +0 -1
  71. package/dist/InlineAudioPlayer.d.ts +0 -15
  72. package/dist/InlineAudioPlayer.d.ts.map +0 -1
  73. package/dist/InlineAudioPlayer.js +0 -22
  74. package/dist/InlineAudioPlayer.js.map +0 -1
  75. package/dist/InlineVideoPlayer.d.ts +0 -21
  76. package/dist/InlineVideoPlayer.d.ts.map +0 -1
  77. package/dist/InlineVideoPlayer.js +0 -29
  78. package/dist/InlineVideoPlayer.js.map +0 -1
  79. package/dist/LinearDocView.d.ts +0 -73
  80. package/dist/LinearDocView.d.ts.map +0 -1
  81. package/dist/LinearDocView.js +0 -451
  82. package/dist/LinearDocView.js.map +0 -1
  83. package/dist/MarkdownRenderer.d.ts +0 -32
  84. package/dist/MarkdownRenderer.d.ts.map +0 -1
  85. package/dist/MarkdownRenderer.js +0 -259
  86. package/dist/MarkdownRenderer.js.map +0 -1
  87. package/dist/SocialCaptionOverlay.d.ts +0 -27
  88. package/dist/SocialCaptionOverlay.d.ts.map +0 -1
  89. package/dist/SocialCaptionOverlay.js +0 -185
  90. package/dist/SocialCaptionOverlay.js.map +0 -1
  91. package/dist/__tests__/BlockRenderer.test.d.ts +0 -2
  92. package/dist/__tests__/BlockRenderer.test.d.ts.map +0 -1
  93. package/dist/__tests__/BlockRenderer.test.js +0 -82
  94. package/dist/__tests__/BlockRenderer.test.js.map +0 -1
  95. package/dist/__tests__/DocControlsSlideshow.test.d.ts +0 -2
  96. package/dist/__tests__/DocControlsSlideshow.test.d.ts.map +0 -1
  97. package/dist/__tests__/DocControlsSlideshow.test.js +0 -89
  98. package/dist/__tests__/DocControlsSlideshow.test.js.map +0 -1
  99. package/dist/__tests__/JsonView.test.d.ts +0 -2
  100. package/dist/__tests__/JsonView.test.d.ts.map +0 -1
  101. package/dist/__tests__/JsonView.test.js +0 -93
  102. package/dist/__tests__/JsonView.test.js.map +0 -1
  103. package/dist/__tests__/LinearDocView.test.d.ts +0 -2
  104. package/dist/__tests__/LinearDocView.test.d.ts.map +0 -1
  105. package/dist/__tests__/LinearDocView.test.js +0 -250
  106. package/dist/__tests__/LinearDocView.test.js.map +0 -1
  107. package/dist/__tests__/MarkdownRenderer.test.d.ts +0 -2
  108. package/dist/__tests__/MarkdownRenderer.test.d.ts.map +0 -1
  109. package/dist/__tests__/MarkdownRenderer.test.js +0 -208
  110. package/dist/__tests__/MarkdownRenderer.test.js.map +0 -1
  111. package/dist/__tests__/TableLayer.test.d.ts +0 -2
  112. package/dist/__tests__/TableLayer.test.d.ts.map +0 -1
  113. package/dist/__tests__/TableLayer.test.js +0 -121
  114. package/dist/__tests__/TableLayer.test.js.map +0 -1
  115. package/dist/__tests__/exports.test.d.ts +0 -2
  116. package/dist/__tests__/exports.test.d.ts.map +0 -1
  117. package/dist/__tests__/exports.test.js +0 -48
  118. package/dist/__tests__/exports.test.js.map +0 -1
  119. package/dist/hooks/AudioProvider.d.ts +0 -71
  120. package/dist/hooks/AudioProvider.d.ts.map +0 -1
  121. package/dist/hooks/AudioProvider.js +0 -52
  122. package/dist/hooks/AudioProvider.js.map +0 -1
  123. package/dist/hooks/MediaContext.d.ts +0 -35
  124. package/dist/hooks/MediaContext.d.ts.map +0 -1
  125. package/dist/hooks/MediaContext.js +0 -72
  126. package/dist/hooks/MediaContext.js.map +0 -1
  127. package/dist/hooks/index.d.ts +0 -6
  128. package/dist/hooks/index.d.ts.map +0 -1
  129. package/dist/hooks/index.js +0 -5
  130. package/dist/hooks/index.js.map +0 -1
  131. package/dist/hooks/useAudioSync.d.ts +0 -18
  132. package/dist/hooks/useAudioSync.d.ts.map +0 -1
  133. package/dist/hooks/useAudioSync.js +0 -340
  134. package/dist/hooks/useAudioSync.js.map +0 -1
  135. package/dist/hooks/useAutoSurface.d.ts +0 -11
  136. package/dist/hooks/useAutoSurface.d.ts.map +0 -1
  137. package/dist/hooks/useAutoSurface.js +0 -24
  138. package/dist/hooks/useAutoSurface.js.map +0 -1
  139. package/dist/hooks/useDocPlayback.d.ts +0 -47
  140. package/dist/hooks/useDocPlayback.d.ts.map +0 -1
  141. package/dist/hooks/useDocPlayback.js +0 -179
  142. package/dist/hooks/useDocPlayback.js.map +0 -1
  143. package/dist/hooks/useViewportOrientation.d.ts +0 -31
  144. package/dist/hooks/useViewportOrientation.d.ts.map +0 -1
  145. package/dist/hooks/useViewportOrientation.js +0 -92
  146. package/dist/hooks/useViewportOrientation.js.map +0 -1
  147. package/dist/index.d.ts.map +0 -1
  148. package/dist/jsonView/JsonView.d.ts +0 -26
  149. package/dist/jsonView/JsonView.d.ts.map +0 -1
  150. package/dist/jsonView/JsonView.js +0 -12
  151. package/dist/jsonView/JsonView.js.map +0 -1
  152. package/dist/jsonView/RenderNode.d.ts +0 -22
  153. package/dist/jsonView/RenderNode.d.ts.map +0 -1
  154. package/dist/jsonView/RenderNode.js +0 -30
  155. package/dist/jsonView/RenderNode.js.map +0 -1
  156. package/dist/jsonView/index.d.ts +0 -3
  157. package/dist/jsonView/index.d.ts.map +0 -1
  158. package/dist/jsonView/index.js +0 -2
  159. package/dist/jsonView/index.js.map +0 -1
  160. package/dist/jsonView/useJsonViewTokens.d.ts +0 -14
  161. package/dist/jsonView/useJsonViewTokens.d.ts.map +0 -1
  162. package/dist/jsonView/useJsonViewTokens.js +0 -34
  163. package/dist/jsonView/useJsonViewTokens.js.map +0 -1
  164. package/dist/jsonView/viewers.d.ts +0 -30
  165. package/dist/jsonView/viewers.d.ts.map +0 -1
  166. package/dist/jsonView/viewers.js +0 -226
  167. package/dist/jsonView/viewers.js.map +0 -1
  168. package/dist/layers/ImageLayer.d.ts +0 -23
  169. package/dist/layers/ImageLayer.d.ts.map +0 -1
  170. package/dist/layers/ImageLayer.js +0 -104
  171. package/dist/layers/ImageLayer.js.map +0 -1
  172. package/dist/layers/MapLayer.d.ts +0 -29
  173. package/dist/layers/MapLayer.d.ts.map +0 -1
  174. package/dist/layers/MapLayer.js +0 -97
  175. package/dist/layers/MapLayer.js.map +0 -1
  176. package/dist/layers/ShapeLayer.d.ts +0 -20
  177. package/dist/layers/ShapeLayer.d.ts.map +0 -1
  178. package/dist/layers/ShapeLayer.js +0 -39
  179. package/dist/layers/ShapeLayer.js.map +0 -1
  180. package/dist/layers/TableLayer.d.ts +0 -23
  181. package/dist/layers/TableLayer.d.ts.map +0 -1
  182. package/dist/layers/TableLayer.js +0 -58
  183. package/dist/layers/TableLayer.js.map +0 -1
  184. package/dist/layers/TextLayer.d.ts +0 -23
  185. package/dist/layers/TextLayer.d.ts.map +0 -1
  186. package/dist/layers/TextLayer.js +0 -126
  187. package/dist/layers/TextLayer.js.map +0 -1
  188. package/dist/layers/VideoLayer.d.ts +0 -40
  189. package/dist/layers/VideoLayer.d.ts.map +0 -1
  190. package/dist/layers/VideoLayer.js +0 -105
  191. package/dist/layers/VideoLayer.js.map +0 -1
  192. package/dist/layers/index.d.ts +0 -6
  193. package/dist/layers/index.d.ts.map +0 -1
  194. package/dist/layers/index.js +0 -6
  195. package/dist/layers/index.js.map +0 -1
  196. package/dist/standalone-entry.d.ts +0 -69
  197. package/dist/standalone-entry.d.ts.map +0 -1
  198. package/dist/standalone-entry.js +0 -185
  199. package/dist/standalone-entry.js.map +0 -1
  200. package/dist/types.d.ts +0 -154
  201. package/dist/types.d.ts.map +0 -1
  202. package/dist/types.js +0 -21
  203. package/dist/types.js.map +0 -1
  204. package/dist/utils/animationUtils.d.ts +0 -8
  205. package/dist/utils/animationUtils.d.ts.map +0 -1
  206. package/dist/utils/animationUtils.js +0 -8
  207. package/dist/utils/animationUtils.js.map +0 -1
  208. package/dist/utils/layerUtils.d.ts +0 -17
  209. package/dist/utils/layerUtils.d.ts.map +0 -1
  210. package/dist/utils/layerUtils.js +0 -37
  211. package/dist/utils/layerUtils.js.map +0 -1
  212. package/dist/utils/mapTileUtils.d.ts +0 -105
  213. package/dist/utils/mapTileUtils.d.ts.map +0 -1
  214. package/dist/utils/mapTileUtils.js +0 -268
  215. package/dist/utils/mapTileUtils.js.map +0 -1
@@ -15,14 +15,17 @@
15
15
  */
16
16
 
17
17
  import { Fragment } from 'react';
18
- import type {
18
+ import {
19
+ sanitizeHtmlNodes,
20
+ sanitizeUrl,
21
+ type HtmlPolicy,
22
+ type HtmlElement,
23
+ type HtmlNode,
19
24
  MarkdownBlockNode,
20
25
  MarkdownInlineNode,
21
26
  MarkdownListItem,
22
27
  MarkdownTableRow,
23
28
  MarkdownTableCell,
24
- HtmlNode,
25
- HtmlElement,
26
29
  } from '@bendyline/squisq/markdown';
27
30
  import { useMediaUrl } from './hooks/MediaContext';
28
31
  import { InlineVideoPlayer } from './InlineVideoPlayer.js';
@@ -35,12 +38,21 @@ export interface MarkdownRendererProps {
35
38
  nodes: MarkdownBlockNode[];
36
39
  /** Optional CSS class for the wrapper element */
37
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;
38
46
  }
39
47
 
40
48
  // ── Inline Renderer ────────────────────────────────────────────────
41
49
 
42
50
  /** Render an array of inline nodes into React elements. */
43
- function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactNode[] {
51
+ function renderInline(
52
+ nodes: MarkdownInlineNode[],
53
+ keyPrefix = '',
54
+ htmlPolicy: HtmlPolicy = 'sanitize',
55
+ ): React.ReactNode[] {
44
56
  return nodes.map((node, i) => {
45
57
  const key = `${keyPrefix}i${i}`;
46
58
  switch (node.type) {
@@ -67,21 +79,21 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
67
79
  case 'emphasis':
68
80
  return (
69
81
  <em key={key} className="squisq-md-em">
70
- {renderInline(node.children, key)}
82
+ {renderInline(node.children, key, htmlPolicy)}
71
83
  </em>
72
84
  );
73
85
 
74
86
  case 'strong':
75
87
  return (
76
88
  <strong key={key} className="squisq-md-strong">
77
- {renderInline(node.children, key)}
89
+ {renderInline(node.children, key, htmlPolicy)}
78
90
  </strong>
79
91
  );
80
92
 
81
93
  case 'delete':
82
94
  return (
83
95
  <del key={key} className="squisq-md-del">
84
- {renderInline(node.children, key)}
96
+ {renderInline(node.children, key, htmlPolicy)}
85
97
  </del>
86
98
  );
87
99
 
@@ -92,19 +104,28 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
92
104
  </code>
93
105
  );
94
106
 
95
- case 'link':
107
+ case 'link': {
108
+ const href = sanitizeUrl(node.url, 'link');
109
+ if (!href) {
110
+ return (
111
+ <span key={key} className="squisq-md-link squisq-md-link--blocked">
112
+ {renderInline(node.children, key, htmlPolicy)}
113
+ </span>
114
+ );
115
+ }
96
116
  return (
97
117
  <a
98
118
  key={key}
99
119
  className="squisq-md-link"
100
- href={node.url}
120
+ href={href}
101
121
  title={node.title ?? undefined}
102
122
  target="_blank"
103
123
  rel="noopener noreferrer"
104
124
  >
105
- {renderInline(node.children, key)}
125
+ {renderInline(node.children, key, htmlPolicy)}
106
126
  </a>
107
127
  );
128
+ }
108
129
 
109
130
  case 'image':
110
131
  return (
@@ -122,9 +143,15 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
122
143
  );
123
144
 
124
145
  case 'htmlInline':
146
+ if (htmlPolicy === 'strip') return null;
125
147
  // Fast path: no <video>/<audio> in the subtree → use the original
126
148
  // rawHtml passthrough (preserves arbitrary HTML for custom embeds).
127
- if (!containsMediaTag(node.htmlChildren)) {
149
+ if (
150
+ htmlPolicy === 'trusted' &&
151
+ !containsMediaTag(node.htmlChildren) &&
152
+ !containsDangerousTag(node.htmlChildren) &&
153
+ !hasDangerousRawHtml(node.rawHtml)
154
+ ) {
128
155
  return (
129
156
  <span
130
157
  key={key}
@@ -137,7 +164,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
137
164
  // go through MediaContext-aware player components.
138
165
  return (
139
166
  <span key={key} className="squisq-md-html-inline">
140
- {renderHtmlNodes(node.htmlChildren, `${key}h`)}
167
+ {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, htmlPolicy), `${key}h`)}
141
168
  </span>
142
169
  );
143
170
 
@@ -152,7 +179,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
152
179
  // Render as plain text (definition targets not available at render time)
153
180
  return (
154
181
  <span key={key} className="squisq-md-link-ref">
155
- {renderInline(node.children, key)}
182
+ {renderInline(node.children, key, htmlPolicy)}
156
183
  </span>
157
184
  );
158
185
 
@@ -166,7 +193,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
166
193
  case 'textDirective':
167
194
  return (
168
195
  <span key={key} className="squisq-md-text-directive" data-directive={node.name}>
169
- {renderInline(node.children, key)}
196
+ {renderInline(node.children, key, htmlPolicy)}
170
197
  </span>
171
198
  );
172
199
 
@@ -193,12 +220,16 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
193
220
  // ── Block Renderer ─────────────────────────────────────────────────
194
221
 
195
222
  /** Render a single block-level node into a React element. */
196
- function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
223
+ function renderBlock(
224
+ node: MarkdownBlockNode,
225
+ key: string,
226
+ htmlPolicy: HtmlPolicy = 'sanitize',
227
+ ): React.ReactNode {
197
228
  switch (node.type) {
198
229
  case 'paragraph':
199
230
  return (
200
231
  <p key={key} className="squisq-md-p">
201
- {renderInline(node.children, key)}
232
+ {renderInline(node.children, key, htmlPolicy)}
202
233
  </p>
203
234
  );
204
235
 
@@ -206,7 +237,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
206
237
  const Tag = `h${node.depth}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
207
238
  return (
208
239
  <Tag key={key} className={`squisq-md-heading squisq-md-h${node.depth}`}>
209
- {renderInline(node.children, key)}
240
+ {renderInline(node.children, key, htmlPolicy)}
210
241
  </Tag>
211
242
  );
212
243
  }
@@ -214,7 +245,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
214
245
  case 'blockquote':
215
246
  return (
216
247
  <blockquote key={key} className="squisq-md-blockquote">
217
- {renderBlocks(node.children, key)}
248
+ {renderBlocks(node.children, key, htmlPolicy)}
218
249
  </blockquote>
219
250
  );
220
251
 
@@ -222,13 +253,13 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
222
253
  if (node.ordered) {
223
254
  return (
224
255
  <ol key={key} className="squisq-md-list squisq-md-ol" start={node.start ?? undefined}>
225
- {node.children.map((item, i) => renderListItem(item, `${key}li${i}`))}
256
+ {node.children.map((item, i) => renderListItem(item, `${key}li${i}`, htmlPolicy))}
226
257
  </ol>
227
258
  );
228
259
  }
229
260
  return (
230
261
  <ul key={key} className="squisq-md-list squisq-md-ul">
231
- {node.children.map((item, i) => renderListItem(item, `${key}li${i}`))}
262
+ {node.children.map((item, i) => renderListItem(item, `${key}li${i}`, htmlPolicy))}
232
263
  </ul>
233
264
  );
234
265
 
@@ -243,12 +274,18 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
243
274
  return <hr key={key} className="squisq-md-hr" />;
244
275
 
245
276
  case 'table':
246
- return renderTable(node.children, node.align, key);
277
+ return renderTable(node.children, node.align, key, htmlPolicy);
247
278
 
248
279
  case 'htmlBlock':
280
+ if (htmlPolicy === 'strip') return null;
249
281
  // Fast path: no <video>/<audio> → preserve the existing rawHtml
250
282
  // passthrough so arbitrary HTML embeds still survive verbatim.
251
- if (!containsMediaTag(node.htmlChildren)) {
283
+ if (
284
+ htmlPolicy === 'trusted' &&
285
+ !containsMediaTag(node.htmlChildren) &&
286
+ !containsDangerousTag(node.htmlChildren) &&
287
+ !hasDangerousRawHtml(node.rawHtml)
288
+ ) {
252
289
  return (
253
290
  <div
254
291
  key={key}
@@ -261,7 +298,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
261
298
  // route through the player components and resolve via MediaContext.
262
299
  return (
263
300
  <div key={key} className="squisq-md-html-block">
264
- {renderHtmlNodes(node.htmlChildren, `${key}h`)}
301
+ {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, htmlPolicy), `${key}h`)}
265
302
  </div>
266
303
  );
267
304
 
@@ -280,7 +317,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
280
317
  return (
281
318
  <div key={key} className="squisq-md-footnote-def" id={`fn-${node.identifier}`}>
282
319
  <sup>{node.label ?? node.identifier}</sup>
283
- {renderBlocks(node.children, key)}
320
+ {renderBlocks(node.children, key, htmlPolicy)}
284
321
  </div>
285
322
  );
286
323
 
@@ -292,7 +329,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
292
329
  data-directive={node.name}
293
330
  >
294
331
  {node.label && <div className="squisq-md-directive-label">{node.label}</div>}
295
- {renderBlocks(node.children, key)}
332
+ {renderBlocks(node.children, key, htmlPolicy)}
296
333
  </div>
297
334
  );
298
335
 
@@ -303,7 +340,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
303
340
  className={`squisq-md-directive squisq-md-directive-${node.name}`}
304
341
  data-directive={node.name}
305
342
  >
306
- {renderInline(node.children, key)}
343
+ {renderInline(node.children, key, htmlPolicy)}
307
344
  </div>
308
345
  );
309
346
 
@@ -314,13 +351,13 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
314
351
  if (child.type === 'definitionTerm') {
315
352
  return (
316
353
  <dt key={`${key}dt${i}`} className="squisq-md-dt">
317
- {renderInline(child.children, `${key}dt${i}`)}
354
+ {renderInline(child.children, `${key}dt${i}`, htmlPolicy)}
318
355
  </dt>
319
356
  );
320
357
  }
321
358
  return (
322
359
  <dd key={`${key}dd${i}`} className="squisq-md-dd">
323
- {renderBlocks(child.children, `${key}dd${i}`)}
360
+ {renderBlocks(child.children, `${key}dd${i}`, htmlPolicy)}
324
361
  </dd>
325
362
  );
326
363
  })}
@@ -333,14 +370,18 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
333
370
  }
334
371
 
335
372
  /** Render a list item, including task-list checkbox support. */
336
- function renderListItem(item: MarkdownListItem, key: string): React.ReactNode {
373
+ function renderListItem(
374
+ item: MarkdownListItem,
375
+ key: string,
376
+ htmlPolicy: HtmlPolicy = 'sanitize',
377
+ ): React.ReactNode {
337
378
  const isTask = item.checked !== null && item.checked !== undefined;
338
379
  return (
339
380
  <li key={key} className={`squisq-md-li${isTask ? ' squisq-md-task' : ''}`}>
340
381
  {isTask && (
341
382
  <input type="checkbox" checked={!!item.checked} readOnly className="squisq-md-checkbox" />
342
383
  )}
343
- {renderBlocks(item.children, key)}
384
+ {renderBlocks(item.children, key, htmlPolicy)}
344
385
  </li>
345
386
  );
346
387
  }
@@ -350,6 +391,7 @@ function renderTable(
350
391
  rows: MarkdownTableRow[],
351
392
  align: (('left' | 'right' | 'center') | null)[] | undefined,
352
393
  key: string,
394
+ htmlPolicy: HtmlPolicy = 'sanitize',
353
395
  ): React.ReactNode {
354
396
  const [headerRow, ...bodyRows] = rows;
355
397
  return (
@@ -363,7 +405,7 @@ function renderTable(
363
405
  className="squisq-md-th"
364
406
  style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
365
407
  >
366
- {renderInline(cell.children, `${key}th${ci}`)}
408
+ {renderInline(cell.children, `${key}th${ci}`, htmlPolicy)}
367
409
  </th>
368
410
  ))}
369
411
  </tr>
@@ -379,7 +421,7 @@ function renderTable(
379
421
  className="squisq-md-td"
380
422
  style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
381
423
  >
382
- {renderInline(cell.children, `${key}td${ri}-${ci}`)}
424
+ {renderInline(cell.children, `${key}td${ri}-${ci}`, htmlPolicy)}
383
425
  </td>
384
426
  ))}
385
427
  </tr>
@@ -391,15 +433,21 @@ function renderTable(
391
433
  }
392
434
 
393
435
  /** Render an array of block-level nodes. */
394
- function renderBlocks(nodes: MarkdownBlockNode[], keyPrefix = ''): React.ReactNode[] {
395
- return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`));
436
+ function renderBlocks(
437
+ nodes: MarkdownBlockNode[],
438
+ keyPrefix = '',
439
+ htmlPolicy: HtmlPolicy = 'sanitize',
440
+ ): React.ReactNode[] {
441
+ return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`, htmlPolicy));
396
442
  }
397
443
 
398
444
  // ── Image with MediaProvider resolution ───────────────────────────
399
445
 
400
446
  /** Renders an <img> that resolves its src through the MediaProvider when available. */
401
447
  function MdImage({ src, alt, title }: { src: string; alt: string; title?: string }) {
402
- const resolved = useMediaUrl(src, '.');
448
+ const safeSrc = sanitizeUrl(src, 'media');
449
+ const resolved = useMediaUrl(safeSrc ?? '', '.');
450
+ if (!safeSrc) return null;
403
451
  return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
404
452
  }
405
453
 
@@ -408,15 +456,75 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
408
456
  /** True when the htmlElement subtree contains a tag we want to swap
409
457
  * for a React component. Cheap recursive scan — lets us keep the
410
458
  * `dangerouslySetInnerHTML` fast path for everything else. */
459
+ function resolveHtmlNodes(nodes: HtmlNode[], htmlPolicy: HtmlPolicy): HtmlNode[] {
460
+ if (htmlPolicy === 'strip') return [];
461
+ if (htmlPolicy === 'trusted') return nodes;
462
+ return sanitizeHtmlNodes(nodes);
463
+ }
464
+
411
465
  function containsMediaTag(nodes: HtmlNode[]): boolean {
412
466
  for (const node of nodes) {
413
467
  if (node.type !== 'htmlElement') continue;
414
- if (node.tagName === 'video' || node.tagName === 'audio') return true;
468
+ const tagName = node.tagName.toLowerCase();
469
+ if (tagName === 'video' || tagName === 'audio') return true;
415
470
  if (containsMediaTag(node.children)) return true;
416
471
  }
417
472
  return false;
418
473
  }
419
474
 
475
+ /**
476
+ * Tags that can escape their container and affect the whole host
477
+ * document — global styling, script execution, external/resource loads,
478
+ * or framing. Markdown content is frequently untrusted (LLM output,
479
+ * pasted snippets), and these tags have no business mutating the page
480
+ * they're embedded in, so they are *always* dropped — even under the
481
+ * `'trusted'` policy. "Trusted" means "render this HTML's structure
482
+ * verbatim," not "let it restyle or script the host." A stray `<style>`
483
+ * applies document-wide (CSS has no per-element scoping outside shadow
484
+ * DOM / iframes), which is exactly how an embedded game's `<style>`
485
+ * leaked onto the surrounding app chrome.
486
+ */
487
+ const DANGEROUS_HTML_TAGS = new Set([
488
+ 'base',
489
+ 'embed',
490
+ 'iframe',
491
+ 'link',
492
+ 'meta',
493
+ 'object',
494
+ 'script',
495
+ 'style',
496
+ 'title',
497
+ ]);
498
+
499
+ /** True when the subtree contains any host-affecting tag (see
500
+ * {@link DANGEROUS_HTML_TAGS}). Mirrors {@link containsMediaTag}: keeps
501
+ * such content off the verbatim `dangerouslySetInnerHTML` fast path so
502
+ * it routes through the React reconstruction, which drops the tag. */
503
+ function containsDangerousTag(nodes: HtmlNode[]): boolean {
504
+ for (const node of nodes) {
505
+ if (node.type !== 'htmlElement') continue;
506
+ if (DANGEROUS_HTML_TAGS.has(node.tagName.toLowerCase())) return true;
507
+ if (containsDangerousTag(node.children)) return true;
508
+ }
509
+ return false;
510
+ }
511
+
512
+ /**
513
+ * Raw-string backstop for {@link DANGEROUS_HTML_TAGS}. The structural
514
+ * {@link containsDangerousTag} check covers the normal case, but a block
515
+ * parsed with `parseHtml: false` carries an empty `htmlChildren` while
516
+ * `rawHtml` still holds the markup — so the verbatim fast path scans the
517
+ * raw string too, guaranteeing a `<style>`/`<script>` can never be
518
+ * injected into the host document by that path no matter how the node
519
+ * was produced. The `\b` keeps `<styled-thing>` from matching `<style>`.
520
+ */
521
+ const DANGEROUS_RAW_HTML_RE =
522
+ /<\s*\/?\s*(?:base|embed|iframe|link|meta|object|script|style|title)\b/i;
523
+
524
+ function hasDangerousRawHtml(rawHtml: string): boolean {
525
+ return DANGEROUS_RAW_HTML_RE.test(rawHtml);
526
+ }
527
+
420
528
  /** A pragmatic shortlist of HTML attributes the raw-HTML walker
421
529
  * passes through to React when reconstructing a non-media element.
422
530
  * Anything outside this list is silently dropped — the media-tag
@@ -432,6 +540,10 @@ const PASSTHROUGH_ATTRS: Record<string, string> = {
432
540
  // media-adjacent (used when video/audio appear inside other wrappers)
433
541
  width: 'width',
434
542
  height: 'height',
543
+ src: 'src',
544
+ alt: 'alt',
545
+ loading: 'loading',
546
+ decoding: 'decoding',
435
547
  // anchor
436
548
  href: 'href',
437
549
  target: 'target',
@@ -456,7 +568,13 @@ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unkn
456
568
  }
457
569
 
458
570
  function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
459
- if (el.tagName === 'video') {
571
+ const tagName = el.tagName.toLowerCase();
572
+ // Final safety net: never reconstruct a host-affecting element (e.g. a
573
+ // <style> that would leak globally), whatever the policy. The fast path
574
+ // is gated by containsDangerousTag, so trusted content carrying these
575
+ // tags lands here — drop the tag and keep the rest of the subtree.
576
+ if (DANGEROUS_HTML_TAGS.has(tagName)) return null;
577
+ if (tagName === 'video') {
460
578
  return (
461
579
  <InlineVideoPlayer
462
580
  key={key}
@@ -477,7 +595,7 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
477
595
  />
478
596
  );
479
597
  }
480
- if (el.tagName === 'audio') {
598
+ if (tagName === 'audio') {
481
599
  return (
482
600
  <InlineAudioPlayer
483
601
  key={key}
@@ -494,7 +612,7 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
494
612
  );
495
613
  }
496
614
 
497
- const Tag = el.tagName as keyof JSX.IntrinsicElements;
615
+ const Tag = tagName as keyof JSX.IntrinsicElements;
498
616
  const props = reactPropsFromAttrs(el.attributes);
499
617
  if (el.selfClosing) {
500
618
  return <Tag key={key} {...props} />;
@@ -533,8 +651,14 @@ function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[
533
651
  * <MarkdownRenderer nodes={block.contents} />
534
652
  * ```
535
653
  */
536
- export function MarkdownRenderer({ nodes, className }: MarkdownRendererProps) {
654
+ export function MarkdownRenderer({
655
+ nodes,
656
+ className,
657
+ htmlPolicy = 'sanitize',
658
+ }: MarkdownRendererProps) {
537
659
  if (!nodes || nodes.length === 0) return null;
538
660
 
539
- return <div className={`squisq-md ${className || ''}`}>{renderBlocks(nodes)}</div>;
661
+ return (
662
+ <div className={`squisq-md ${className || ''}`}>{renderBlocks(nodes, '', htmlPolicy)}</div>
663
+ );
540
664
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * MediaClipLayer
3
+ *
4
+ * Player-level audio/video elements for the media-clip schedule. Renders one
5
+ * element per scheduled clip (audio, or document-spanning video) and keeps each
6
+ * mounted so re-entry doesn't reload; the drive effect seeks/plays only the
7
+ * clips active at the current time. Multiple concurrent clips (e.g. a
8
+ * document-spanning narration plus a block clip) are independent elements and
9
+ * the browser mixes their output.
10
+ *
11
+ * Audio clips play unmuted during live playback (silent in render mode, where
12
+ * frames are captured without sound and audio is muxed offline). Video clips
13
+ * play muted — their audio, if any, is reproduced by the export mux.
14
+ */
15
+
16
+ import { useEffect, useRef } from 'react';
17
+ import type { ScheduledClip } from '@bendyline/squisq/schemas';
18
+ import { useMediaUrl } from './hooks/MediaContext';
19
+ import { useMediaSchedule } from './hooks/useMediaSchedule';
20
+
21
+ /** Re-seek an element only when it drifts this far from its target (seconds). */
22
+ const DRIFT = 0.25;
23
+
24
+ export interface MediaClipLayerProps {
25
+ schedule: ScheduledClip[];
26
+ currentTime: number;
27
+ isPlaying: boolean;
28
+ basePath: string;
29
+ renderMode?: boolean;
30
+ }
31
+
32
+ export function MediaClipLayer({
33
+ schedule,
34
+ currentTime,
35
+ isPlaying,
36
+ basePath,
37
+ renderMode = false,
38
+ }: MediaClipLayerProps) {
39
+ const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
40
+ if (renderClips.length === 0) return null;
41
+ return (
42
+ <div className="doc-player__media-clips" aria-hidden>
43
+ {renderClips.map((clip) => (
44
+ <MediaClipElement
45
+ key={clip.id}
46
+ clip={clip}
47
+ active={activeIds.has(clip.id)}
48
+ currentTime={currentTime}
49
+ isPlaying={isPlaying}
50
+ basePath={basePath}
51
+ renderMode={renderMode}
52
+ />
53
+ ))}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ interface MediaClipElementProps {
59
+ clip: ScheduledClip;
60
+ active: boolean;
61
+ currentTime: number;
62
+ isPlaying: boolean;
63
+ basePath: string;
64
+ renderMode: boolean;
65
+ }
66
+
67
+ function MediaClipElement({
68
+ clip,
69
+ active,
70
+ currentTime,
71
+ isPlaying,
72
+ basePath,
73
+ renderMode,
74
+ }: MediaClipElementProps) {
75
+ const ref = useRef<HTMLMediaElement | null>(null);
76
+ const src = useMediaUrl(clip.src, basePath);
77
+
78
+ useEffect(() => {
79
+ const el = ref.current;
80
+ if (!el) return;
81
+ if (!active) {
82
+ if (!el.paused) el.pause();
83
+ return;
84
+ }
85
+ const target = Math.max(0, clip.sourceIn + (currentTime - clip.absoluteStart));
86
+ if (renderMode || Math.abs(el.currentTime - target) > DRIFT) {
87
+ try {
88
+ el.currentTime = target;
89
+ } catch {
90
+ // Seeking before metadata loads throws; the next tick retries.
91
+ }
92
+ }
93
+ if (isPlaying && !renderMode) {
94
+ const p = el.play();
95
+ if (p) p.catch(() => {});
96
+ } else {
97
+ el.pause();
98
+ }
99
+ }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart]);
100
+
101
+ const isVideo = clip.kind === 'video';
102
+ const common = {
103
+ ref: ref as React.RefObject<never>,
104
+ src,
105
+ preload: 'auto' as const,
106
+ 'data-clip-id': clip.id,
107
+ 'data-abs-start': clip.absoluteStart,
108
+ 'data-abs-end': clip.absoluteEnd,
109
+ 'data-source-in': clip.sourceIn,
110
+ };
111
+
112
+ if (isVideo) {
113
+ // Document-spanning video renders full-bleed behind the blocks.
114
+ return (
115
+ <video
116
+ {...common}
117
+ muted
118
+ playsInline
119
+ style={{
120
+ position: 'absolute',
121
+ inset: 0,
122
+ width: '100%',
123
+ height: '100%',
124
+ objectFit: 'cover',
125
+ zIndex: 0,
126
+ pointerEvents: 'none',
127
+ }}
128
+ />
129
+ );
130
+ }
131
+
132
+ return (
133
+ <audio {...common} muted={renderMode} style={{ position: 'absolute', width: 0, height: 0 }} />
134
+ );
135
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { DocPlayer } from '../DocPlayer';
4
+ import type { Doc } from '@bendyline/squisq/schemas';
5
+
6
+ function minimalDoc(): Doc {
7
+ return {
8
+ articleId: 'smoke',
9
+ duration: 5,
10
+ blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
11
+ audio: { segments: [] },
12
+ };
13
+ }
14
+
15
+ describe('DocPlayer smoke test', () => {
16
+ it('renders without crashing in video mode (default)', () => {
17
+ const { container } = render(<DocPlayer script={minimalDoc()} basePath="/test" />);
18
+ expect(container.firstChild).toBeTruthy();
19
+ });
20
+
21
+ it('renders without crashing in slideshow mode', () => {
22
+ const { container } = render(
23
+ <DocPlayer script={minimalDoc()} basePath="/test" displayMode="slideshow" />,
24
+ );
25
+ expect(container.firstChild).toBeTruthy();
26
+ });
27
+
28
+ it('renders without crashing in linear mode', () => {
29
+ const { container } = render(
30
+ <DocPlayer script={minimalDoc()} basePath="/test" displayMode="linear" />,
31
+ );
32
+ expect(container.firstChild).toBeTruthy();
33
+ });
34
+
35
+ it('exposes playback controls via onControlsReady', () => {
36
+ let controls: { play: () => void; pause: () => void } | null = null;
37
+ render(
38
+ <DocPlayer
39
+ script={minimalDoc()}
40
+ basePath="/test"
41
+ showControls={false}
42
+ onControlsReady={(c) => {
43
+ controls = c;
44
+ }}
45
+ />,
46
+ );
47
+ expect(controls).not.toBeNull();
48
+ expect(typeof controls!.play).toBe('function');
49
+ expect(typeof controls!.pause).toBe('function');
50
+ });
51
+ });