@bendyline/squisq-react 1.4.0 → 1.4.1
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/README.md +57 -23
- package/dist/index.d.ts +70 -21
- package/dist/index.js +379 -164
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +49 -17
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +2263 -0
- package/package.json +8 -5
- package/src/DocPlayer.tsx +160 -46
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/LinearDocView.tsx +59 -10
- package/src/MarkdownRenderer.tsx +52 -35
- package/src/__tests__/DocPlayer.test.tsx +34 -4
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
- package/src/__tests__/LinearDocView.test.tsx +53 -1
- package/src/__tests__/MarkdownRenderer.test.tsx +18 -0
- package/src/__tests__/useJsonViewTokens.test.ts +41 -0
- package/src/__tests__/useSlideSwipe.test.ts +81 -0
- package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
- package/src/hooks/index.ts +7 -2
- package/src/hooks/useAudioSync.ts +5 -4
- package/src/hooks/useSlideSwipe.ts +265 -0
- package/src/index.ts +1 -1
- package/src/jsonView/useJsonViewTokens.ts +6 -31
- package/src/standalone-entry.tsx +1 -1
- package/src/styles/doc-animations.css +46 -0
package/src/LinearDocView.tsx
CHANGED
|
@@ -28,17 +28,32 @@ import {
|
|
|
28
28
|
type Theme,
|
|
29
29
|
} from '@bendyline/squisq/schemas';
|
|
30
30
|
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
getLayers,
|
|
33
|
+
hasTemplate,
|
|
34
|
+
markdownToDoc,
|
|
35
|
+
DEFAULT_THEME,
|
|
36
|
+
deriveTemplateInputs,
|
|
37
|
+
} from '@bendyline/squisq/doc';
|
|
32
38
|
import type { RenderContext } from '@bendyline/squisq/doc';
|
|
33
|
-
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
39
|
+
import { extractPlainText, parseMarkdown } from '@bendyline/squisq/markdown';
|
|
34
40
|
import { BlockRenderer } from './BlockRenderer';
|
|
35
41
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
|
36
42
|
|
|
37
43
|
// ── Props ──────────────────────────────────────────────────────────
|
|
38
44
|
|
|
39
45
|
export interface LinearDocViewProps {
|
|
40
|
-
/**
|
|
41
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The Doc to render. Wins over `markdown` when both are provided.
|
|
48
|
+
* When neither `doc` nor `markdown` is given, an empty container renders.
|
|
49
|
+
*/
|
|
50
|
+
doc?: Doc;
|
|
51
|
+
/**
|
|
52
|
+
* Markdown source to render. When `doc` is absent, the markdown is parsed
|
|
53
|
+
* and converted to a Doc via `markdownToDoc(parseMarkdown(markdown))`.
|
|
54
|
+
* Ignored when `doc` is provided.
|
|
55
|
+
*/
|
|
56
|
+
markdown?: string;
|
|
42
57
|
/** Base path for resolving media URLs (images, etc.) */
|
|
43
58
|
basePath?: string;
|
|
44
59
|
/** Viewport config for SVG card rendering (default: landscape) */
|
|
@@ -78,16 +93,33 @@ export type ImageDisplayMode = 'inline' | 'thumbnail';
|
|
|
78
93
|
|
|
79
94
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
80
95
|
|
|
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
|
+
|
|
81
100
|
/**
|
|
82
101
|
* Determine whether a block has a template annotation that should be
|
|
83
102
|
* rendered as a visual SVG card. A block is "annotated" when:
|
|
84
103
|
* 1. Its sourceHeading has a templateAnnotation, AND
|
|
85
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.
|
|
86
109
|
*/
|
|
87
110
|
function isAnnotatedBlock(block: Block): boolean {
|
|
88
111
|
const annotation = block.sourceHeading?.templateAnnotation;
|
|
89
|
-
if (!annotation) return false;
|
|
90
|
-
|
|
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;
|
|
91
123
|
}
|
|
92
124
|
|
|
93
125
|
/**
|
|
@@ -221,8 +253,6 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
221
253
|
);
|
|
222
254
|
}
|
|
223
255
|
|
|
224
|
-
// ── Template Defaults (mirrored from PreviewPanel) ─────────────────
|
|
225
|
-
|
|
226
256
|
// ── Main Component ─────────────────────────────────────────────────
|
|
227
257
|
|
|
228
258
|
/**
|
|
@@ -239,6 +269,7 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
239
269
|
*/
|
|
240
270
|
export function LinearDocView({
|
|
241
271
|
doc,
|
|
272
|
+
markdown,
|
|
242
273
|
basePath = '/',
|
|
243
274
|
viewport,
|
|
244
275
|
className,
|
|
@@ -248,7 +279,18 @@ export function LinearDocView({
|
|
|
248
279
|
imageDisplayMode = 'inline',
|
|
249
280
|
}: LinearDocViewProps) {
|
|
250
281
|
const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
|
|
251
|
-
|
|
282
|
+
|
|
283
|
+
// Parse markdown into a Doc only when no explicit doc is supplied.
|
|
284
|
+
const markdownDoc = useMemo(
|
|
285
|
+
() => (!doc && markdown !== undefined ? markdownToDoc(parseMarkdown(markdown)) : undefined),
|
|
286
|
+
[doc, markdown],
|
|
287
|
+
);
|
|
288
|
+
const resolvedDoc = doc ?? markdownDoc;
|
|
289
|
+
|
|
290
|
+
const totalBlocks = useMemo(
|
|
291
|
+
() => (resolvedDoc ? countAll(resolvedDoc.blocks) : 0),
|
|
292
|
+
[resolvedDoc],
|
|
293
|
+
);
|
|
252
294
|
const autoSurface = useAutoSurface(surface === 'auto');
|
|
253
295
|
const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
|
|
254
296
|
|
|
@@ -266,6 +308,13 @@ export function LinearDocView({
|
|
|
266
308
|
}, [activeViewport, totalBlocks, theme, resolvedSurface]);
|
|
267
309
|
|
|
268
310
|
const activeTheme = renderContext.theme!;
|
|
311
|
+
|
|
312
|
+
// Nothing to render — keep an empty (but classed) container so hosts can
|
|
313
|
+
// still target/measure the view.
|
|
314
|
+
if (!resolvedDoc) {
|
|
315
|
+
return <div className={`squisq-linear squisq-linear--empty ${className || ''}`} />;
|
|
316
|
+
}
|
|
317
|
+
|
|
269
318
|
const bgColor = activeTheme.colors.background;
|
|
270
319
|
const textColor = activeTheme.colors.text;
|
|
271
320
|
const mutedColor = activeTheme.colors.textMuted;
|
|
@@ -428,7 +477,7 @@ export function LinearDocView({
|
|
|
428
477
|
background: color-mix(in srgb, var(--squisq-linear-primary) 8%, transparent);
|
|
429
478
|
}
|
|
430
479
|
`}</style>
|
|
431
|
-
{
|
|
480
|
+
{resolvedDoc.blocks.map((block, i) => (
|
|
432
481
|
<BlockSection
|
|
433
482
|
key={block.id}
|
|
434
483
|
block={block}
|
package/src/MarkdownRenderer.tsx
CHANGED
|
@@ -43,15 +43,29 @@ export interface MarkdownRendererProps {
|
|
|
43
43
|
* event handlers, and executable URL schemes before rendering.
|
|
44
44
|
*/
|
|
45
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[];
|
|
46
52
|
}
|
|
47
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
|
+
|
|
48
62
|
// ── Inline Renderer ────────────────────────────────────────────────
|
|
49
63
|
|
|
50
64
|
/** Render an array of inline nodes into React elements. */
|
|
51
65
|
function renderInline(
|
|
52
66
|
nodes: MarkdownInlineNode[],
|
|
53
67
|
keyPrefix = '',
|
|
54
|
-
|
|
68
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
55
69
|
): React.ReactNode[] {
|
|
56
70
|
return nodes.map((node, i) => {
|
|
57
71
|
const key = `${keyPrefix}i${i}`;
|
|
@@ -79,21 +93,21 @@ function renderInline(
|
|
|
79
93
|
case 'emphasis':
|
|
80
94
|
return (
|
|
81
95
|
<em key={key} className="squisq-md-em">
|
|
82
|
-
{renderInline(node.children, key,
|
|
96
|
+
{renderInline(node.children, key, ctx)}
|
|
83
97
|
</em>
|
|
84
98
|
);
|
|
85
99
|
|
|
86
100
|
case 'strong':
|
|
87
101
|
return (
|
|
88
102
|
<strong key={key} className="squisq-md-strong">
|
|
89
|
-
{renderInline(node.children, key,
|
|
103
|
+
{renderInline(node.children, key, ctx)}
|
|
90
104
|
</strong>
|
|
91
105
|
);
|
|
92
106
|
|
|
93
107
|
case 'delete':
|
|
94
108
|
return (
|
|
95
109
|
<del key={key} className="squisq-md-del">
|
|
96
|
-
{renderInline(node.children, key,
|
|
110
|
+
{renderInline(node.children, key, ctx)}
|
|
97
111
|
</del>
|
|
98
112
|
);
|
|
99
113
|
|
|
@@ -105,11 +119,11 @@ function renderInline(
|
|
|
105
119
|
);
|
|
106
120
|
|
|
107
121
|
case 'link': {
|
|
108
|
-
const href = sanitizeUrl(node.url, 'link');
|
|
122
|
+
const href = sanitizeUrl(node.url, 'link', { extraLinkSchemes: ctx.linkSchemes });
|
|
109
123
|
if (!href) {
|
|
110
124
|
return (
|
|
111
125
|
<span key={key} className="squisq-md-link squisq-md-link--blocked">
|
|
112
|
-
{renderInline(node.children, key,
|
|
126
|
+
{renderInline(node.children, key, ctx)}
|
|
113
127
|
</span>
|
|
114
128
|
);
|
|
115
129
|
}
|
|
@@ -122,7 +136,7 @@ function renderInline(
|
|
|
122
136
|
target="_blank"
|
|
123
137
|
rel="noopener noreferrer"
|
|
124
138
|
>
|
|
125
|
-
{renderInline(node.children, key,
|
|
139
|
+
{renderInline(node.children, key, ctx)}
|
|
126
140
|
</a>
|
|
127
141
|
);
|
|
128
142
|
}
|
|
@@ -143,11 +157,11 @@ function renderInline(
|
|
|
143
157
|
);
|
|
144
158
|
|
|
145
159
|
case 'htmlInline':
|
|
146
|
-
if (htmlPolicy === 'strip') return null;
|
|
160
|
+
if (ctx.htmlPolicy === 'strip') return null;
|
|
147
161
|
// Fast path: no <video>/<audio> in the subtree → use the original
|
|
148
162
|
// rawHtml passthrough (preserves arbitrary HTML for custom embeds).
|
|
149
163
|
if (
|
|
150
|
-
htmlPolicy === 'trusted' &&
|
|
164
|
+
ctx.htmlPolicy === 'trusted' &&
|
|
151
165
|
!containsMediaTag(node.htmlChildren) &&
|
|
152
166
|
!containsDangerousTag(node.htmlChildren) &&
|
|
153
167
|
!hasDangerousRawHtml(node.rawHtml)
|
|
@@ -164,7 +178,7 @@ function renderInline(
|
|
|
164
178
|
// go through MediaContext-aware player components.
|
|
165
179
|
return (
|
|
166
180
|
<span key={key} className="squisq-md-html-inline">
|
|
167
|
-
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, htmlPolicy), `${key}h`)}
|
|
181
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
|
|
168
182
|
</span>
|
|
169
183
|
);
|
|
170
184
|
|
|
@@ -179,7 +193,7 @@ function renderInline(
|
|
|
179
193
|
// Render as plain text (definition targets not available at render time)
|
|
180
194
|
return (
|
|
181
195
|
<span key={key} className="squisq-md-link-ref">
|
|
182
|
-
{renderInline(node.children, key,
|
|
196
|
+
{renderInline(node.children, key, ctx)}
|
|
183
197
|
</span>
|
|
184
198
|
);
|
|
185
199
|
|
|
@@ -193,7 +207,7 @@ function renderInline(
|
|
|
193
207
|
case 'textDirective':
|
|
194
208
|
return (
|
|
195
209
|
<span key={key} className="squisq-md-text-directive" data-directive={node.name}>
|
|
196
|
-
{renderInline(node.children, key,
|
|
210
|
+
{renderInline(node.children, key, ctx)}
|
|
197
211
|
</span>
|
|
198
212
|
);
|
|
199
213
|
|
|
@@ -223,13 +237,13 @@ function renderInline(
|
|
|
223
237
|
function renderBlock(
|
|
224
238
|
node: MarkdownBlockNode,
|
|
225
239
|
key: string,
|
|
226
|
-
|
|
240
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
227
241
|
): React.ReactNode {
|
|
228
242
|
switch (node.type) {
|
|
229
243
|
case 'paragraph':
|
|
230
244
|
return (
|
|
231
245
|
<p key={key} className="squisq-md-p">
|
|
232
|
-
{renderInline(node.children, key,
|
|
246
|
+
{renderInline(node.children, key, ctx)}
|
|
233
247
|
</p>
|
|
234
248
|
);
|
|
235
249
|
|
|
@@ -237,7 +251,7 @@ function renderBlock(
|
|
|
237
251
|
const Tag = `h${node.depth}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
238
252
|
return (
|
|
239
253
|
<Tag key={key} className={`squisq-md-heading squisq-md-h${node.depth}`}>
|
|
240
|
-
{renderInline(node.children, key,
|
|
254
|
+
{renderInline(node.children, key, ctx)}
|
|
241
255
|
</Tag>
|
|
242
256
|
);
|
|
243
257
|
}
|
|
@@ -245,7 +259,7 @@ function renderBlock(
|
|
|
245
259
|
case 'blockquote':
|
|
246
260
|
return (
|
|
247
261
|
<blockquote key={key} className="squisq-md-blockquote">
|
|
248
|
-
{renderBlocks(node.children, key,
|
|
262
|
+
{renderBlocks(node.children, key, ctx)}
|
|
249
263
|
</blockquote>
|
|
250
264
|
);
|
|
251
265
|
|
|
@@ -253,13 +267,13 @@ function renderBlock(
|
|
|
253
267
|
if (node.ordered) {
|
|
254
268
|
return (
|
|
255
269
|
<ol key={key} className="squisq-md-list squisq-md-ol" start={node.start ?? undefined}>
|
|
256
|
-
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`,
|
|
270
|
+
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`, ctx))}
|
|
257
271
|
</ol>
|
|
258
272
|
);
|
|
259
273
|
}
|
|
260
274
|
return (
|
|
261
275
|
<ul key={key} className="squisq-md-list squisq-md-ul">
|
|
262
|
-
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`,
|
|
276
|
+
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`, ctx))}
|
|
263
277
|
</ul>
|
|
264
278
|
);
|
|
265
279
|
|
|
@@ -274,14 +288,14 @@ function renderBlock(
|
|
|
274
288
|
return <hr key={key} className="squisq-md-hr" />;
|
|
275
289
|
|
|
276
290
|
case 'table':
|
|
277
|
-
return renderTable(node.children, node.align, key,
|
|
291
|
+
return renderTable(node.children, node.align, key, ctx);
|
|
278
292
|
|
|
279
293
|
case 'htmlBlock':
|
|
280
|
-
if (htmlPolicy === 'strip') return null;
|
|
294
|
+
if (ctx.htmlPolicy === 'strip') return null;
|
|
281
295
|
// Fast path: no <video>/<audio> → preserve the existing rawHtml
|
|
282
296
|
// passthrough so arbitrary HTML embeds still survive verbatim.
|
|
283
297
|
if (
|
|
284
|
-
htmlPolicy === 'trusted' &&
|
|
298
|
+
ctx.htmlPolicy === 'trusted' &&
|
|
285
299
|
!containsMediaTag(node.htmlChildren) &&
|
|
286
300
|
!containsDangerousTag(node.htmlChildren) &&
|
|
287
301
|
!hasDangerousRawHtml(node.rawHtml)
|
|
@@ -298,7 +312,7 @@ function renderBlock(
|
|
|
298
312
|
// route through the player components and resolve via MediaContext.
|
|
299
313
|
return (
|
|
300
314
|
<div key={key} className="squisq-md-html-block">
|
|
301
|
-
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, htmlPolicy), `${key}h`)}
|
|
315
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
|
|
302
316
|
</div>
|
|
303
317
|
);
|
|
304
318
|
|
|
@@ -317,7 +331,7 @@ function renderBlock(
|
|
|
317
331
|
return (
|
|
318
332
|
<div key={key} className="squisq-md-footnote-def" id={`fn-${node.identifier}`}>
|
|
319
333
|
<sup>{node.label ?? node.identifier}</sup>
|
|
320
|
-
{renderBlocks(node.children, key,
|
|
334
|
+
{renderBlocks(node.children, key, ctx)}
|
|
321
335
|
</div>
|
|
322
336
|
);
|
|
323
337
|
|
|
@@ -329,7 +343,7 @@ function renderBlock(
|
|
|
329
343
|
data-directive={node.name}
|
|
330
344
|
>
|
|
331
345
|
{node.label && <div className="squisq-md-directive-label">{node.label}</div>}
|
|
332
|
-
{renderBlocks(node.children, key,
|
|
346
|
+
{renderBlocks(node.children, key, ctx)}
|
|
333
347
|
</div>
|
|
334
348
|
);
|
|
335
349
|
|
|
@@ -340,7 +354,7 @@ function renderBlock(
|
|
|
340
354
|
className={`squisq-md-directive squisq-md-directive-${node.name}`}
|
|
341
355
|
data-directive={node.name}
|
|
342
356
|
>
|
|
343
|
-
{renderInline(node.children, key,
|
|
357
|
+
{renderInline(node.children, key, ctx)}
|
|
344
358
|
</div>
|
|
345
359
|
);
|
|
346
360
|
|
|
@@ -351,13 +365,13 @@ function renderBlock(
|
|
|
351
365
|
if (child.type === 'definitionTerm') {
|
|
352
366
|
return (
|
|
353
367
|
<dt key={`${key}dt${i}`} className="squisq-md-dt">
|
|
354
|
-
{renderInline(child.children, `${key}dt${i}`,
|
|
368
|
+
{renderInline(child.children, `${key}dt${i}`, ctx)}
|
|
355
369
|
</dt>
|
|
356
370
|
);
|
|
357
371
|
}
|
|
358
372
|
return (
|
|
359
373
|
<dd key={`${key}dd${i}`} className="squisq-md-dd">
|
|
360
|
-
{renderBlocks(child.children, `${key}dd${i}`,
|
|
374
|
+
{renderBlocks(child.children, `${key}dd${i}`, ctx)}
|
|
361
375
|
</dd>
|
|
362
376
|
);
|
|
363
377
|
})}
|
|
@@ -373,7 +387,7 @@ function renderBlock(
|
|
|
373
387
|
function renderListItem(
|
|
374
388
|
item: MarkdownListItem,
|
|
375
389
|
key: string,
|
|
376
|
-
|
|
390
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
377
391
|
): React.ReactNode {
|
|
378
392
|
const isTask = item.checked !== null && item.checked !== undefined;
|
|
379
393
|
return (
|
|
@@ -381,7 +395,7 @@ function renderListItem(
|
|
|
381
395
|
{isTask && (
|
|
382
396
|
<input type="checkbox" checked={!!item.checked} readOnly className="squisq-md-checkbox" />
|
|
383
397
|
)}
|
|
384
|
-
{renderBlocks(item.children, key,
|
|
398
|
+
{renderBlocks(item.children, key, ctx)}
|
|
385
399
|
</li>
|
|
386
400
|
);
|
|
387
401
|
}
|
|
@@ -391,7 +405,7 @@ function renderTable(
|
|
|
391
405
|
rows: MarkdownTableRow[],
|
|
392
406
|
align: (('left' | 'right' | 'center') | null)[] | undefined,
|
|
393
407
|
key: string,
|
|
394
|
-
|
|
408
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
395
409
|
): React.ReactNode {
|
|
396
410
|
const [headerRow, ...bodyRows] = rows;
|
|
397
411
|
return (
|
|
@@ -405,7 +419,7 @@ function renderTable(
|
|
|
405
419
|
className="squisq-md-th"
|
|
406
420
|
style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
|
|
407
421
|
>
|
|
408
|
-
{renderInline(cell.children, `${key}th${ci}`,
|
|
422
|
+
{renderInline(cell.children, `${key}th${ci}`, ctx)}
|
|
409
423
|
</th>
|
|
410
424
|
))}
|
|
411
425
|
</tr>
|
|
@@ -421,7 +435,7 @@ function renderTable(
|
|
|
421
435
|
className="squisq-md-td"
|
|
422
436
|
style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
|
|
423
437
|
>
|
|
424
|
-
{renderInline(cell.children, `${key}td${ri}-${ci}`,
|
|
438
|
+
{renderInline(cell.children, `${key}td${ri}-${ci}`, ctx)}
|
|
425
439
|
</td>
|
|
426
440
|
))}
|
|
427
441
|
</tr>
|
|
@@ -436,9 +450,9 @@ function renderTable(
|
|
|
436
450
|
function renderBlocks(
|
|
437
451
|
nodes: MarkdownBlockNode[],
|
|
438
452
|
keyPrefix = '',
|
|
439
|
-
|
|
453
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
440
454
|
): React.ReactNode[] {
|
|
441
|
-
return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`,
|
|
455
|
+
return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`, ctx));
|
|
442
456
|
}
|
|
443
457
|
|
|
444
458
|
// ── Image with MediaProvider resolution ───────────────────────────
|
|
@@ -655,10 +669,13 @@ export function MarkdownRenderer({
|
|
|
655
669
|
nodes,
|
|
656
670
|
className,
|
|
657
671
|
htmlPolicy = 'sanitize',
|
|
672
|
+
linkSchemes,
|
|
658
673
|
}: MarkdownRendererProps) {
|
|
659
674
|
if (!nodes || nodes.length === 0) return null;
|
|
660
675
|
|
|
661
676
|
return (
|
|
662
|
-
<div className={`squisq-md ${className || ''}`}>
|
|
677
|
+
<div className={`squisq-md ${className || ''}`}>
|
|
678
|
+
{renderBlocks(nodes, '', { htmlPolicy, linkSchemes })}
|
|
679
|
+
</div>
|
|
663
680
|
);
|
|
664
681
|
}
|
|
@@ -14,20 +14,20 @@ function minimalDoc(): Doc {
|
|
|
14
14
|
|
|
15
15
|
describe('DocPlayer smoke test', () => {
|
|
16
16
|
it('renders without crashing in video mode (default)', () => {
|
|
17
|
-
const { container } = render(<DocPlayer
|
|
17
|
+
const { container } = render(<DocPlayer doc={minimalDoc()} basePath="/test" />);
|
|
18
18
|
expect(container.firstChild).toBeTruthy();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
it('renders without crashing in slideshow mode', () => {
|
|
22
22
|
const { container } = render(
|
|
23
|
-
<DocPlayer
|
|
23
|
+
<DocPlayer doc={minimalDoc()} basePath="/test" displayMode="slideshow" />,
|
|
24
24
|
);
|
|
25
25
|
expect(container.firstChild).toBeTruthy();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
it('renders without crashing in linear mode', () => {
|
|
29
29
|
const { container } = render(
|
|
30
|
-
<DocPlayer
|
|
30
|
+
<DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
|
|
31
31
|
);
|
|
32
32
|
expect(container.firstChild).toBeTruthy();
|
|
33
33
|
});
|
|
@@ -36,7 +36,7 @@ describe('DocPlayer smoke test', () => {
|
|
|
36
36
|
let controls: { play: () => void; pause: () => void } | null = null;
|
|
37
37
|
render(
|
|
38
38
|
<DocPlayer
|
|
39
|
-
|
|
39
|
+
doc={minimalDoc()}
|
|
40
40
|
basePath="/test"
|
|
41
41
|
showControls={false}
|
|
42
42
|
onControlsReady={(c) => {
|
|
@@ -49,3 +49,33 @@ describe('DocPlayer smoke test', () => {
|
|
|
49
49
|
expect(typeof controls!.pause).toBe('function');
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
describe('DocPlayer front door (doc / markdown resolution)', () => {
|
|
54
|
+
it('renders a doc built from the markdown prop', () => {
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<DocPlayer markdown={'# Hello From Markdown\n\nBody text here.'} displayMode="linear" />,
|
|
57
|
+
);
|
|
58
|
+
expect(container.querySelector('.doc-player')).toBeTruthy();
|
|
59
|
+
expect(container.textContent).toContain('Hello From Markdown');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('doc wins over markdown when both are provided', () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<DocPlayer doc={minimalDoc()} markdown="# Markdown Loses" displayMode="linear" />,
|
|
65
|
+
);
|
|
66
|
+
expect(container.querySelector('.doc-player')).toBeTruthy();
|
|
67
|
+
expect(container.textContent).not.toContain('Markdown Loses');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders an empty state without throwing when neither doc nor markdown is given', () => {
|
|
71
|
+
const { container } = render(<DocPlayer />);
|
|
72
|
+
const empty = container.querySelector('.doc-player--empty');
|
|
73
|
+
expect(empty).toBeTruthy();
|
|
74
|
+
expect(empty!.classList.contains('doc-player')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('defaults basePath when omitted', () => {
|
|
78
|
+
const { container } = render(<DocPlayer doc={minimalDoc()} />);
|
|
79
|
+
expect(container.querySelector('.doc-player')).toBeTruthy();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Missing-stylesheet sentinel test.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own file so it owns a fresh module instance of DocPlayer
|
|
5
|
+
* (vitest isolates module registries per test file) — the sentinel warning
|
|
6
|
+
* is one-shot at module level, so this file's first mount deterministically
|
|
7
|
+
* observes it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
11
|
+
import { render } from '@testing-library/react';
|
|
12
|
+
import { DocPlayer } from '../DocPlayer';
|
|
13
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
14
|
+
|
|
15
|
+
function minimalDoc(): Doc {
|
|
16
|
+
return {
|
|
17
|
+
articleId: 'sentinel',
|
|
18
|
+
duration: 5,
|
|
19
|
+
blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
|
|
20
|
+
audio: { segments: [] },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('DocPlayer missing-CSS sentinel', () => {
|
|
29
|
+
it('warns once (and only once) in dev when the stylesheet is not loaded', () => {
|
|
30
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
31
|
+
|
|
32
|
+
render(<DocPlayer doc={minimalDoc()} />);
|
|
33
|
+
const sentinelCalls = () =>
|
|
34
|
+
warnSpy.mock.calls.filter((c) => String(c[0]).includes('squisq-react/styles'));
|
|
35
|
+
expect(sentinelCalls().length).toBe(1);
|
|
36
|
+
|
|
37
|
+
// Second mount must not warn again — module-level one-shot.
|
|
38
|
+
render(<DocPlayer doc={minimalDoc()} />);
|
|
39
|
+
expect(sentinelCalls().length).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { render } from '@testing-library/react';
|
|
3
3
|
import { LinearDocView } from '../LinearDocView';
|
|
4
4
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
@@ -279,3 +279,55 @@ describe('LinearDocView', () => {
|
|
|
279
279
|
expect(el.style.background).toBe(probe.style.background);
|
|
280
280
|
});
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
describe('LinearDocView markdown prop', () => {
|
|
284
|
+
it('renders from the markdown prop when no doc is given', () => {
|
|
285
|
+
const { container } = render(<LinearDocView markdown={'# From Markdown\n\nParagraph body.'} />);
|
|
286
|
+
expect(container.textContent).toContain('From Markdown');
|
|
287
|
+
expect(container.textContent).toContain('Paragraph body.');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('doc wins over markdown when both are provided', () => {
|
|
291
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('Doc body wins'))] })]);
|
|
292
|
+
const { container } = render(<LinearDocView doc={doc} markdown="# Markdown Loses" />);
|
|
293
|
+
expect(container.textContent).toContain('Doc body wins');
|
|
294
|
+
expect(container.textContent).not.toContain('Markdown Loses');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('renders an empty container when neither doc nor markdown is given', () => {
|
|
298
|
+
const { container } = render(<LinearDocView />);
|
|
299
|
+
expect(container.querySelector('.squisq-linear--empty')).toBeTruthy();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('LinearDocView unknown template annotations', () => {
|
|
304
|
+
it('warns once per unknown template name and falls back to plain markdown', () => {
|
|
305
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
306
|
+
const doc = mkDoc([
|
|
307
|
+
mkBlock({
|
|
308
|
+
id: 'unknown-1',
|
|
309
|
+
sourceHeading: {
|
|
310
|
+
type: 'heading',
|
|
311
|
+
depth: 2,
|
|
312
|
+
children: [text('Mystery Section')],
|
|
313
|
+
templateAnnotation: { template: 'no-such-template-xyz' },
|
|
314
|
+
},
|
|
315
|
+
contents: [paragraph(text('Fallback body content'))],
|
|
316
|
+
}),
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
320
|
+
// Renders as plain markdown: heading + body, no SVG card.
|
|
321
|
+
expect(container.textContent).toContain('Mystery Section');
|
|
322
|
+
expect(container.textContent).toContain('Fallback body content');
|
|
323
|
+
expect(container.querySelector('.squisq-linear-card')).toBeNull();
|
|
324
|
+
|
|
325
|
+
// Re-render: the warning stays one-shot per unknown template name.
|
|
326
|
+
render(<LinearDocView doc={doc} />);
|
|
327
|
+
const sentinelCalls = warnSpy.mock.calls.filter((c) =>
|
|
328
|
+
String(c[0]).includes('no-such-template-xyz'),
|
|
329
|
+
);
|
|
330
|
+
expect(sentinelCalls.length).toBe(1);
|
|
331
|
+
warnSpy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -102,6 +102,24 @@ describe('MarkdownRenderer', () => {
|
|
|
102
102
|
expect(container.innerHTML).not.toContain('javascript:');
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
it('linkSchemes allows a host scheme as a real anchor, never executable ones', () => {
|
|
106
|
+
const nodes = parseNodes('[a](gezel-nav:src%2Fa.ts) [b](javascript:alert(1))');
|
|
107
|
+
const blocked = render(<MarkdownRenderer nodes={nodes} />);
|
|
108
|
+
expect(blocked.container.querySelector('a.squisq-md-link')).toBeNull();
|
|
109
|
+
|
|
110
|
+
const allowed = render(<MarkdownRenderer nodes={nodes} linkSchemes={['gezel-nav']} />);
|
|
111
|
+
const a = allowed.container.querySelector('a.squisq-md-link') as HTMLAnchorElement;
|
|
112
|
+
expect(a?.getAttribute('href')).toBe('gezel-nav:src%2Fa.ts');
|
|
113
|
+
// javascript: stays blocked even when a host lists it
|
|
114
|
+
const evil = render(
|
|
115
|
+
<MarkdownRenderer
|
|
116
|
+
nodes={parseNodes('[b](javascript:alert(1))')}
|
|
117
|
+
linkSchemes={['javascript']}
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
expect(evil.container.querySelector('a.squisq-md-link')).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
105
123
|
it('renders an image', () => {
|
|
106
124
|
const nodes: MarkdownBlockNode[] = [
|
|
107
125
|
paragraph({
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import { DARK_SURFACE, LIGHT_SURFACE, DEFAULT_THEME } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { useJsonViewTokens } from '../jsonView/useJsonViewTokens';
|
|
5
|
+
|
|
6
|
+
type StyleBag = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
function mockPrefersDark(dark: boolean): void {
|
|
9
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
10
|
+
configurable: true,
|
|
11
|
+
value: (query: string) => ({
|
|
12
|
+
matches: dark && query.includes('dark'),
|
|
13
|
+
media: query,
|
|
14
|
+
onchange: null,
|
|
15
|
+
addListener: vi.fn(),
|
|
16
|
+
removeListener: vi.fn(),
|
|
17
|
+
addEventListener: vi.fn(),
|
|
18
|
+
removeEventListener: vi.fn(),
|
|
19
|
+
dispatchEvent: vi.fn(() => false),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('useJsonViewTokens', () => {
|
|
25
|
+
afterEach(() => vi.restoreAllMocks());
|
|
26
|
+
|
|
27
|
+
it('emits json-prefixed tokens for a static surface', () => {
|
|
28
|
+
const { result } = renderHook(() => useJsonViewTokens(DEFAULT_THEME, LIGHT_SURFACE));
|
|
29
|
+
const style = result.current.style as StyleBag;
|
|
30
|
+
expect(style['--squisq-json-bg']).toBe(LIGHT_SURFACE.background);
|
|
31
|
+
expect(style['--squisq-json-text']).toBe(LIGHT_SURFACE.text);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("tracks a dark OS preference under surface='auto'", () => {
|
|
35
|
+
mockPrefersDark(true);
|
|
36
|
+
const { result } = renderHook(() => useJsonViewTokens(DEFAULT_THEME, 'auto'));
|
|
37
|
+
const style = result.current.style as StyleBag;
|
|
38
|
+
expect(style['--squisq-json-bg']).toBe(DARK_SURFACE.background);
|
|
39
|
+
expect(result.current.theme.colors.background).toBe(DARK_SURFACE.background);
|
|
40
|
+
});
|
|
41
|
+
});
|