@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.
- package/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- package/src/utils/mapTileUtils.ts +0 -375
package/src/MarkdownRenderer.tsx
DELETED
|
@@ -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
|
-
}
|