@bendyline/squisq-react 1.4.0 → 1.4.2

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.
@@ -20,19 +20,22 @@
20
20
 
21
21
  import { useRef, useState, useCallback, useEffect } from 'react';
22
22
  import type { Doc } from '@bendyline/squisq/schemas';
23
- import type { ViewportConfig } from '@bendyline/squisq/schemas';
24
- import type { AudioProvider } from './hooks/AudioProvider';
23
+ import type { ViewportConfig, Theme } from '@bendyline/squisq/schemas';
24
+ import type { AudioController } from './hooks/AudioController';
25
25
  import { DocPlayer } from './DocPlayer';
26
26
  import { DocControlsSidebar } from './DocControlsSidebar';
27
27
  import type { PlaybackState, PlaybackActions } from './types';
28
28
 
29
29
  interface DocPlayerWithSidebarProps {
30
- script: Doc;
31
- basePath: string;
30
+ /** The Doc to play */
31
+ doc: Doc;
32
+ /** Base path for resolving media URLs (default: `'.'`) */
33
+ basePath?: string;
32
34
  autoPlay?: boolean;
33
35
  onEnded?: () => void;
34
36
  onTimeUpdate?: (time: number) => void;
35
- audioProvider?: AudioProvider;
37
+ /** Optional audio controller (if not provided, uses default HTML5 audio) */
38
+ audioController?: AudioController;
36
39
  muted?: boolean;
37
40
  captionsEnabled?: boolean;
38
41
  isFullscreen?: boolean;
@@ -41,6 +44,13 @@ interface DocPlayerWithSidebarProps {
41
44
  forceViewport?: ViewportConfig;
42
45
  /** Called when playing state changes */
43
46
  onPlayingChange?: (isPlaying: boolean) => void;
47
+ /**
48
+ * Theme for rendering. Forwarded to the inner DocPlayer so the sidebar
49
+ * (portrait) layout matches the default (landscape) layout — without it the
50
+ * inner player falls back to DEFAULT_THEME, whose dark text is unreadable
51
+ * over a hero cover image.
52
+ */
53
+ theme?: Theme;
44
54
  }
45
55
 
46
56
  const DEFAULT_STATE: PlaybackState = {
@@ -59,18 +69,19 @@ const DEFAULT_STATE: PlaybackState = {
59
69
  };
60
70
 
61
71
  export function DocPlayerWithSidebar({
62
- script,
72
+ doc,
63
73
  basePath,
64
74
  autoPlay = false,
65
75
  onEnded,
66
76
  onTimeUpdate,
67
- audioProvider,
77
+ audioController,
68
78
  muted,
69
79
  captionsEnabled,
70
80
  isFullscreen,
71
81
  onFullscreenToggle,
72
82
  forceViewport,
73
83
  onPlayingChange,
84
+ theme,
74
85
  }: DocPlayerWithSidebarProps) {
75
86
  // Store playback state in a ref to avoid triggering re-renders from DocPlayer callbacks
76
87
  const stateRef = useRef<PlaybackState>(DEFAULT_STATE);
@@ -114,12 +125,13 @@ export function DocPlayerWithSidebar({
114
125
  <div className="doc-player-sidebar-layout">
115
126
  <div className="doc-player-sidebar-layout__video">
116
127
  <DocPlayer
117
- script={script}
128
+ doc={doc}
129
+ theme={theme}
118
130
  basePath={basePath}
119
131
  autoPlay={autoPlay}
120
132
  onEnded={onEnded}
121
133
  onTimeUpdate={onTimeUpdate}
122
- audioProvider={audioProvider}
134
+ audioController={audioController}
123
135
  muted={muted}
124
136
  captionsEnabled={captionsEnabled}
125
137
  showControls={isFullscreen}
@@ -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 { getLayers, hasTemplate, DEFAULT_THEME, deriveTemplateInputs } from '@bendyline/squisq/doc';
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
- /** The Doc to render */
41
- doc: Doc;
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
- return !!annotation.template && hasTemplate(annotation.template);
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
- const totalBlocks = useMemo(() => countAll(doc.blocks), [doc.blocks]);
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
- {doc.blocks.map((block, i) => (
480
+ {resolvedDoc.blocks.map((block, i) => (
432
481
  <BlockSection
433
482
  key={block.id}
434
483
  block={block}
@@ -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
- htmlPolicy: HtmlPolicy = 'sanitize',
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, htmlPolicy)}
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, htmlPolicy)}
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, htmlPolicy)}
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, htmlPolicy)}
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, htmlPolicy)}
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, htmlPolicy)}
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, htmlPolicy)}
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
- htmlPolicy: HtmlPolicy = 'sanitize',
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, htmlPolicy)}
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, htmlPolicy)}
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, htmlPolicy)}
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}`, htmlPolicy))}
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}`, htmlPolicy))}
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, htmlPolicy);
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, htmlPolicy)}
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, htmlPolicy)}
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, htmlPolicy)}
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}`, htmlPolicy)}
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}`, htmlPolicy)}
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
- htmlPolicy: HtmlPolicy = 'sanitize',
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, htmlPolicy)}
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
- htmlPolicy: HtmlPolicy = 'sanitize',
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}`, htmlPolicy)}
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}`, htmlPolicy)}
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
- htmlPolicy: HtmlPolicy = 'sanitize',
453
+ ctx: RenderCtx = DEFAULT_CTX,
440
454
  ): React.ReactNode[] {
441
- return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`, htmlPolicy));
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 || ''}`}>{renderBlocks(nodes, '', htmlPolicy)}</div>
677
+ <div className={`squisq-md ${className || ''}`}>
678
+ {renderBlocks(nodes, '', { htmlPolicy, linkSchemes })}
679
+ </div>
663
680
  );
664
681
  }
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { render } from '@testing-library/react';
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import { DocPlayer } from '../DocPlayer';
4
4
  import type { Doc } from '@bendyline/squisq/schemas';
5
5
 
@@ -12,22 +12,71 @@ function minimalDoc(): Doc {
12
12
  };
13
13
  }
14
14
 
15
+ function docWithCover(): Doc {
16
+ return {
17
+ ...minimalDoc(),
18
+ startBlock: {
19
+ title: 'Managed Cover',
20
+ subtitle: 'Generated by Squisq',
21
+ },
22
+ };
23
+ }
24
+
15
25
  describe('DocPlayer smoke test', () => {
26
+ beforeAll(() => {
27
+ vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
28
+ });
29
+
30
+ afterAll(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
16
34
  it('renders without crashing in video mode (default)', () => {
17
- const { container } = render(<DocPlayer script={minimalDoc()} basePath="/test" />);
35
+ const { container } = render(<DocPlayer doc={minimalDoc()} basePath="/test" />);
18
36
  expect(container.firstChild).toBeTruthy();
19
37
  });
20
38
 
21
39
  it('renders without crashing in slideshow mode', () => {
22
40
  const { container } = render(
23
- <DocPlayer script={minimalDoc()} basePath="/test" displayMode="slideshow" />,
41
+ <DocPlayer doc={minimalDoc()} basePath="/test" displayMode="slideshow" />,
24
42
  );
25
43
  expect(container.firstChild).toBeTruthy();
26
44
  });
27
45
 
46
+ it('shows the managed cover as the first slideshow entry by default', async () => {
47
+ const { container } = render(
48
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
49
+ );
50
+ await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
51
+ expect(screen.getByTestId('slide-counter').textContent).toBe('Cover');
52
+ });
53
+
54
+ it('can suppress the managed cover slide', () => {
55
+ const { container } = render(
56
+ <DocPlayer
57
+ doc={docWithCover()}
58
+ basePath="/test"
59
+ displayMode="slideshow"
60
+ showCoverSlide={false}
61
+ />,
62
+ );
63
+ expect(container.textContent).not.toContain('Managed Cover');
64
+ expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1');
65
+ });
66
+
67
+ it('advances from slideshow cover to slide 1', async () => {
68
+ const { container } = render(
69
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
70
+ );
71
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('Cover'));
72
+ fireEvent.click(screen.getByTestId('slide-next'));
73
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1'));
74
+ expect(container.textContent).not.toContain('Managed Cover');
75
+ });
76
+
28
77
  it('renders without crashing in linear mode', () => {
29
78
  const { container } = render(
30
- <DocPlayer script={minimalDoc()} basePath="/test" displayMode="linear" />,
79
+ <DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
31
80
  );
32
81
  expect(container.firstChild).toBeTruthy();
33
82
  });
@@ -36,7 +85,7 @@ describe('DocPlayer smoke test', () => {
36
85
  let controls: { play: () => void; pause: () => void } | null = null;
37
86
  render(
38
87
  <DocPlayer
39
- script={minimalDoc()}
88
+ doc={minimalDoc()}
40
89
  basePath="/test"
41
90
  showControls={false}
42
91
  onControlsReady={(c) => {
@@ -49,3 +98,33 @@ describe('DocPlayer smoke test', () => {
49
98
  expect(typeof controls!.pause).toBe('function');
50
99
  });
51
100
  });
101
+
102
+ describe('DocPlayer front door (doc / markdown resolution)', () => {
103
+ it('renders a doc built from the markdown prop', () => {
104
+ const { container } = render(
105
+ <DocPlayer markdown={'# Hello From Markdown\n\nBody text here.'} displayMode="linear" />,
106
+ );
107
+ expect(container.querySelector('.doc-player')).toBeTruthy();
108
+ expect(container.textContent).toContain('Hello From Markdown');
109
+ });
110
+
111
+ it('doc wins over markdown when both are provided', () => {
112
+ const { container } = render(
113
+ <DocPlayer doc={minimalDoc()} markdown="# Markdown Loses" displayMode="linear" />,
114
+ );
115
+ expect(container.querySelector('.doc-player')).toBeTruthy();
116
+ expect(container.textContent).not.toContain('Markdown Loses');
117
+ });
118
+
119
+ it('renders an empty state without throwing when neither doc nor markdown is given', () => {
120
+ const { container } = render(<DocPlayer />);
121
+ const empty = container.querySelector('.doc-player--empty');
122
+ expect(empty).toBeTruthy();
123
+ expect(empty!.classList.contains('doc-player')).toBe(true);
124
+ });
125
+
126
+ it('defaults basePath when omitted', () => {
127
+ const { container } = render(<DocPlayer doc={minimalDoc()} />);
128
+ expect(container.querySelector('.doc-player')).toBeTruthy();
129
+ });
130
+ });
@@ -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
+ });