@bendyline/squisq 0.1.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.
Files changed (102) hide show
  1. package/dist/Types-sh2VRxfo.d.ts +15 -0
  2. package/dist/chunk-7UDSRZKG.js +103 -0
  3. package/dist/chunk-7UDSRZKG.js.map +1 -0
  4. package/dist/chunk-O5HCIUAD.js +577 -0
  5. package/dist/chunk-O5HCIUAD.js.map +1 -0
  6. package/dist/chunk-PJ7AODIM.js +3040 -0
  7. package/dist/chunk-PJ7AODIM.js.map +1 -0
  8. package/dist/chunk-QWVRN6I4.js +81 -0
  9. package/dist/chunk-QWVRN6I4.js.map +1 -0
  10. package/dist/chunk-S4F2RY37.js +92 -0
  11. package/dist/chunk-S4F2RY37.js.map +1 -0
  12. package/dist/chunk-URU6QMLY.js +888 -0
  13. package/dist/chunk-URU6QMLY.js.map +1 -0
  14. package/dist/chunk-VJN7UB2Z.js +145 -0
  15. package/dist/chunk-VJN7UB2Z.js.map +1 -0
  16. package/dist/doc/index.d.ts +3 -0
  17. package/dist/doc/index.js +126 -0
  18. package/dist/doc/index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +217 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/markdown/index.d.ts +237 -0
  23. package/dist/markdown/index.js +33 -0
  24. package/dist/markdown/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +70 -0
  26. package/dist/schemas/index.js +61 -0
  27. package/dist/schemas/index.js.map +1 -0
  28. package/dist/spatial/index.d.ts +111 -0
  29. package/dist/spatial/index.js +25 -0
  30. package/dist/spatial/index.js.map +1 -0
  31. package/dist/storage/index.d.ts +91 -0
  32. package/dist/storage/index.js +11 -0
  33. package/dist/storage/index.js.map +1 -0
  34. package/dist/story/index.d.ts +691 -0
  35. package/dist/story/index.js +126 -0
  36. package/dist/story/index.js.map +1 -0
  37. package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
  38. package/dist/types-DHiv_Pnm.d.ts +496 -0
  39. package/package.json +91 -0
  40. package/src/__tests__/animationUtils.test.ts +77 -0
  41. package/src/__tests__/geohash.test.ts +109 -0
  42. package/src/__tests__/getLayers.test.ts +200 -0
  43. package/src/__tests__/haversine.test.ts +63 -0
  44. package/src/__tests__/localForageAdapter.test.ts +155 -0
  45. package/src/__tests__/markdown.test.ts +806 -0
  46. package/src/__tests__/markdownToDoc.test.ts +374 -0
  47. package/src/__tests__/storage.test.ts +60 -0
  48. package/src/__tests__/templates.test.ts +247 -0
  49. package/src/doc/docToMarkdown.ts +98 -0
  50. package/src/doc/getLayers.ts +152 -0
  51. package/src/doc/index.ts +8 -0
  52. package/src/doc/markdownToDoc.ts +277 -0
  53. package/src/doc/templates/accentImage.ts +338 -0
  54. package/src/doc/templates/captionUtils.ts +33 -0
  55. package/src/doc/templates/comparisonBar.ts +140 -0
  56. package/src/doc/templates/coverBlock.ts +156 -0
  57. package/src/doc/templates/dateEvent.ts +122 -0
  58. package/src/doc/templates/definitionCard.ts +129 -0
  59. package/src/doc/templates/factCard.ts +114 -0
  60. package/src/doc/templates/fullBleedQuote.ts +59 -0
  61. package/src/doc/templates/imageWithCaption.ts +167 -0
  62. package/src/doc/templates/index.ts +571 -0
  63. package/src/doc/templates/listBlock.ts +101 -0
  64. package/src/doc/templates/mapBlock.ts +132 -0
  65. package/src/doc/templates/persistentLayers.ts +522 -0
  66. package/src/doc/templates/photoGrid.ts +199 -0
  67. package/src/doc/templates/pullQuote.ts +119 -0
  68. package/src/doc/templates/quoteBlock.ts +112 -0
  69. package/src/doc/templates/sectionHeader.ts +105 -0
  70. package/src/doc/templates/statHighlight.ts +113 -0
  71. package/src/doc/templates/titleBlock.ts +100 -0
  72. package/src/doc/templates/twoColumn.ts +239 -0
  73. package/src/doc/templates/videoPullQuote.ts +128 -0
  74. package/src/doc/templates/videoWithCaption.ts +126 -0
  75. package/src/doc/utils/animationUtils.ts +135 -0
  76. package/src/doc/utils/themeUtils.ts +139 -0
  77. package/src/index.ts +5 -0
  78. package/src/markdown/convert.ts +897 -0
  79. package/src/markdown/htmlParse.ts +212 -0
  80. package/src/markdown/index.ts +110 -0
  81. package/src/markdown/parse.ts +103 -0
  82. package/src/markdown/stringify.ts +122 -0
  83. package/src/markdown/types.ts +605 -0
  84. package/src/markdown/utils.ts +165 -0
  85. package/src/ngeohash.d.ts +11 -0
  86. package/src/schemas/BlockTemplates.ts +706 -0
  87. package/src/schemas/Doc.ts +565 -0
  88. package/src/schemas/LayoutStrategy.ts +178 -0
  89. package/src/schemas/MediaProvider.ts +70 -0
  90. package/src/schemas/Theme.ts +235 -0
  91. package/src/schemas/Types.ts +15 -0
  92. package/src/schemas/Viewport.ts +91 -0
  93. package/src/schemas/index.ts +14 -0
  94. package/src/schemas/themeLibrary.ts +492 -0
  95. package/src/spatial/Geohash.ts +168 -0
  96. package/src/spatial/Haversine.ts +59 -0
  97. package/src/spatial/index.ts +2 -0
  98. package/src/storage/LocalForageAdapter.ts +103 -0
  99. package/src/storage/LocalStorageAdapter.ts +57 -0
  100. package/src/storage/MemoryStorageAdapter.ts +37 -0
  101. package/src/storage/Storage.ts +28 -0
  102. package/src/storage/index.ts +5 -0
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Block Template Registry
3
+ *
4
+ * Central registry of all block templates. Provides functions to:
5
+ * - Look up templates by name
6
+ * - Expand template blocks into full Layer arrays
7
+ * - Convert a template-based doc script into a renderable format
8
+ *
9
+ * Supports multiple viewport configurations for different aspect ratios.
10
+ *
11
+ * This is shared code used by both site and efb-app doc renderers.
12
+ */
13
+
14
+ import type { Block, Layer } from '../../schemas/Doc.js';
15
+ import type {
16
+ TemplateBlock,
17
+ TemplateContext,
18
+ TemplateRegistry,
19
+ DocBlock,
20
+ PersistentLayerConfig,
21
+ } from '../../schemas/BlockTemplates.js';
22
+ import type { Theme } from '../../schemas/Theme.js';
23
+ import { isTemplateBlock, createTemplateContext } from '../../schemas/BlockTemplates.js';
24
+ import { DEFAULT_THEME as defaultTheme } from '../../schemas/themeLibrary.js';
25
+ import { expandPersistentLayers } from './persistentLayers.js';
26
+ import type { ViewportConfig } from '../../schemas/Viewport.js';
27
+ import { VIEWPORT_PRESETS } from '../../schemas/Viewport.js';
28
+
29
+ // Import all template functions
30
+ import { titleBlock } from './titleBlock.js';
31
+ import { sectionHeader } from './sectionHeader.js';
32
+ import { statHighlight } from './statHighlight.js';
33
+ import { quoteBlock } from './quoteBlock.js';
34
+ import { factCard } from './factCard.js';
35
+ import { twoColumn } from './twoColumn.js';
36
+ import { dateEvent } from './dateEvent.js';
37
+ import { imageWithCaption } from './imageWithCaption.js';
38
+ import { mapBlock } from './mapBlock.js';
39
+ import { fullBleedQuote } from './fullBleedQuote.js';
40
+ import { listBlock } from './listBlock.js';
41
+ import { photoGrid } from './photoGrid.js';
42
+ import { definitionCard } from './definitionCard.js';
43
+ import { comparisonBar } from './comparisonBar.js';
44
+ import { pullQuote } from './pullQuote.js';
45
+ import { videoWithCaption } from './videoWithCaption.js';
46
+ import { videoPullQuote } from './videoPullQuote.js';
47
+
48
+ /**
49
+ * Registry mapping template names to their implementation functions.
50
+ * Note: coverBlock is not in the registry as it's used directly for start blocks,
51
+ * not as a regular template in the block sequence.
52
+ */
53
+ export const templateRegistry: TemplateRegistry = {
54
+ titleBlock,
55
+ sectionHeader,
56
+ statHighlight,
57
+ quoteBlock,
58
+ factCard,
59
+ twoColumn,
60
+ dateEvent,
61
+ imageWithCaption,
62
+ mapBlock,
63
+ fullBleedQuote,
64
+ listBlock,
65
+ photoGrid,
66
+ definitionCard,
67
+ comparisonBar,
68
+ pullQuote,
69
+ videoWithCaption,
70
+ videoPullQuote,
71
+ };
72
+
73
+ /**
74
+ * Expand a template block into a full Block with layers.
75
+ */
76
+ export function expandTemplateBlock(templateBlock: TemplateBlock, context: TemplateContext): Block {
77
+ const templateFn = templateRegistry[templateBlock.template];
78
+
79
+ if (!templateFn) {
80
+ console.warn(`Unknown template: ${templateBlock.template}`);
81
+ // Return block with no layers
82
+ return {
83
+ id: templateBlock.id,
84
+ startTime: 0,
85
+ duration: templateBlock.duration,
86
+ audioSegment: templateBlock.audioSegment,
87
+ transition: templateBlock.transition,
88
+ template: templateBlock.template, // Preserve for debugging
89
+ };
90
+ }
91
+
92
+ // Generate layers from template with error handling
93
+ let layers: Layer[];
94
+ try {
95
+ // Each registry entry accepts its specific TemplateBlock variant; the
96
+ // discriminated union guarantees the shapes match at runtime.
97
+ layers = (templateFn as (input: TemplateBlock, ctx: TemplateContext) => Layer[])(
98
+ templateBlock,
99
+ context,
100
+ );
101
+ if (!Array.isArray(layers)) {
102
+ console.error(
103
+ `Template ${templateBlock.template} did not return an array, got:`,
104
+ typeof layers,
105
+ );
106
+ layers = [];
107
+ }
108
+ } catch (err: unknown) {
109
+ console.error(`Error expanding template ${templateBlock.template}:`, err);
110
+ layers = [];
111
+ }
112
+
113
+ return {
114
+ id: templateBlock.id,
115
+ startTime: 0, // Will be calculated later
116
+ duration: templateBlock.duration,
117
+ audioSegment: templateBlock.audioSegment,
118
+ ...(layers.length > 0 ? { layers } : {}),
119
+ transition: templateBlock.transition,
120
+ template: templateBlock.template, // Preserve for debugging
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Audio segment timing info for aligning blocks with audio.
126
+ */
127
+ export interface AudioSegmentTiming {
128
+ /** Start time of this segment in the overall timeline */
129
+ startTime: number;
130
+ /** Duration of this segment */
131
+ duration: number;
132
+ }
133
+
134
+ /**
135
+ * Options for expanding doc blocks.
136
+ */
137
+ export interface ExpandDocBlocksOptions {
138
+ /** Theme for template rendering (defaults to DEFAULT_THEME) */
139
+ theme?: Theme;
140
+ /** Viewport configuration (defaults to 16:9 landscape) */
141
+ viewport?: ViewportConfig;
142
+ /** Persistent layers for visual consistency across blocks */
143
+ persistentLayers?: PersistentLayerConfig;
144
+ /**
145
+ * Audio segment timing information.
146
+ * When provided, blocks are timed relative to their audio segment's start time,
147
+ * ensuring proper synchronization with audio playback.
148
+ */
149
+ audioSegments?: AudioSegmentTiming[];
150
+ }
151
+
152
+ /**
153
+ * Expand all template blocks in a doc, calculating start times.
154
+ * Injects persistent layers (bottom/top) based on per-block flags.
155
+ *
156
+ * When audioSegments is provided, blocks are timed relative to their audio segment's
157
+ * start time, ensuring blocks appear when their audio is playing. Blocks within each
158
+ * segment are distributed proportionally across the segment duration.
159
+ *
160
+ * @param blocks - Array of template or raw blocks
161
+ * @param options - Expansion options including theme, viewport, and persistent layers
162
+ */
163
+ export function expandDocBlocks(blocks: DocBlock[], options: ExpandDocBlocksOptions = {}): Block[] {
164
+ const opts: ExpandDocBlocksOptions = options;
165
+
166
+ const theme = opts.theme ?? defaultTheme;
167
+ const viewport = opts.viewport ?? VIEWPORT_PRESETS.landscape;
168
+ const { persistentLayers, audioSegments } = opts;
169
+ const totalBlocks = blocks.length;
170
+
171
+ // Pre-expand persistent layers once
172
+ const bottomLayers = expandPersistentLayers(persistentLayers?.bottomLayers);
173
+ const topLayers = expandPersistentLayers(persistentLayers?.topLayers);
174
+
175
+ // If no audio segments provided, use simple cumulative timing
176
+ if (!audioSegments || audioSegments.length === 0) {
177
+ let currentTime = 0;
178
+ return blocks.map((block, index) => {
179
+ const context = createTemplateContext(theme, index, totalBlocks, viewport);
180
+ const expandedBlock = isTemplateBlock(block)
181
+ ? expandTemplateBlock(block, context)
182
+ : (block as Block);
183
+
184
+ // Inject persistent layers
185
+ const templateBlock = block as TemplateBlock;
186
+ const useBottom = templateBlock.useBottomLayer !== false;
187
+ const useTop = templateBlock.useTopLayer !== false;
188
+ if (bottomLayers.length > 0 || topLayers.length > 0) {
189
+ expandedBlock.layers = [
190
+ ...(useBottom ? bottomLayers : []),
191
+ ...(expandedBlock.layers ?? []),
192
+ ...(useTop ? topLayers : []),
193
+ ];
194
+ }
195
+
196
+ expandedBlock.startTime = currentTime;
197
+ currentTime += expandedBlock.duration;
198
+ return expandedBlock;
199
+ });
200
+ }
201
+
202
+ // Group blocks by their audioSegment index
203
+ const blocksBySegment = new Map<number, { block: DocBlock; originalIndex: number }[]>();
204
+ blocks.forEach((block, index) => {
205
+ const segmentIndex = (block as TemplateBlock).audioSegment ?? 0;
206
+ if (!blocksBySegment.has(segmentIndex)) {
207
+ blocksBySegment.set(segmentIndex, []);
208
+ }
209
+ blocksBySegment.get(segmentIndex)!.push({ block, originalIndex: index });
210
+ });
211
+
212
+ // Expand blocks, timing them relative to their audio segment
213
+ const expandedBlocks: Block[] = new Array(blocks.length);
214
+
215
+ for (const [segmentIndex, segmentBlocks] of blocksBySegment) {
216
+ const audioSegment = audioSegments[segmentIndex];
217
+ if (!audioSegment) {
218
+ // No audio segment info - use simple sequential timing within the segment
219
+ let offsetTime = 0;
220
+ for (const { block, originalIndex } of segmentBlocks) {
221
+ const context = createTemplateContext(theme, originalIndex, totalBlocks, viewport);
222
+ const expandedBlock = isTemplateBlock(block)
223
+ ? expandTemplateBlock(block, context)
224
+ : (block as Block);
225
+
226
+ const templateBlock = block as TemplateBlock;
227
+ const useBottom = templateBlock.useBottomLayer !== false;
228
+ const useTop = templateBlock.useTopLayer !== false;
229
+ if (bottomLayers.length > 0 || topLayers.length > 0) {
230
+ expandedBlock.layers = [
231
+ ...(useBottom ? bottomLayers : []),
232
+ ...(expandedBlock.layers ?? []),
233
+ ...(useTop ? topLayers : []),
234
+ ];
235
+ }
236
+
237
+ expandedBlock.startTime = offsetTime;
238
+ offsetTime += expandedBlock.duration;
239
+ expandedBlocks[originalIndex] = expandedBlock;
240
+ }
241
+ continue;
242
+ }
243
+
244
+ // Section headers (sectionHeader template) get their original duration since
245
+ // the title is spoken at the start of the segment. Other blocks are scaled
246
+ // to fill the remaining time.
247
+ const sectionHeaderBlocks: typeof segmentBlocks = [];
248
+ const contentBlocks: typeof segmentBlocks = [];
249
+
250
+ for (const item of segmentBlocks) {
251
+ const templateBlock = item.block as TemplateBlock;
252
+ if (templateBlock.template === 'sectionHeader') {
253
+ sectionHeaderBlocks.push(item);
254
+ } else {
255
+ contentBlocks.push(item);
256
+ }
257
+ }
258
+
259
+ // Calculate fixed duration (section headers) and scalable duration (content)
260
+ const fixedDuration = sectionHeaderBlocks.reduce((sum, { block }) => {
261
+ return sum + ((block as TemplateBlock).duration ?? 0);
262
+ }, 0);
263
+
264
+ const contentBlockDuration = contentBlocks.reduce((sum, { block }) => {
265
+ return sum + ((block as TemplateBlock).duration ?? 0);
266
+ }, 0);
267
+
268
+ // Remaining time after section header(s) for content blocks
269
+ const remainingDuration = audioSegment.duration - fixedDuration;
270
+
271
+ // Scale factor only applies to content blocks
272
+ const scaleFactor =
273
+ contentBlockDuration > 0 && remainingDuration > 0
274
+ ? remainingDuration / contentBlockDuration
275
+ : 1;
276
+
277
+ // First pass: expand all blocks and track which have source timing
278
+ interface ExpandedSlideInfo {
279
+ block: Block;
280
+ originalIndex: number;
281
+ templateBlock: TemplateBlock;
282
+ hasSourceTiming: boolean;
283
+ }
284
+ const expandedInfos: ExpandedSlideInfo[] = [];
285
+
286
+ for (const { block, originalIndex } of segmentBlocks) {
287
+ const context = createTemplateContext(theme, originalIndex, totalBlocks, viewport);
288
+ const expandedBlock = isTemplateBlock(block)
289
+ ? expandTemplateBlock(block, context)
290
+ : (block as Block);
291
+
292
+ const templateBlock = block as TemplateBlock;
293
+ const useBottom = templateBlock.useBottomLayer !== false;
294
+ const useTop = templateBlock.useTopLayer !== false;
295
+ if (bottomLayers.length > 0 || topLayers.length > 0) {
296
+ expandedBlock.layers = [
297
+ ...(useBottom ? bottomLayers : []),
298
+ ...(expandedBlock.layers ?? []),
299
+ ...(useTop ? topLayers : []),
300
+ ];
301
+ }
302
+
303
+ expandedInfos.push({
304
+ block: expandedBlock,
305
+ originalIndex,
306
+ templateBlock,
307
+ hasSourceTiming: typeof templateBlock.sourceStartTime === 'number',
308
+ });
309
+ }
310
+
311
+ // Sort blocks within this segment.
312
+ // When ANY block has source timing (from audio analysis), section headers
313
+ // are placed first since they are intro cards for the segment. When there
314
+ // is no source timing (e.g., preview/synthetic mode), keep original
315
+ // document order so the slideshow matches what the author wrote.
316
+ const hasAnySourceTiming = expandedInfos.some((info) => info.hasSourceTiming);
317
+
318
+ expandedInfos.sort((a, b) => {
319
+ if (hasAnySourceTiming) {
320
+ const aIsHeader = a.templateBlock.template === 'sectionHeader';
321
+ const bIsHeader = b.templateBlock.template === 'sectionHeader';
322
+
323
+ // Section headers come first when we have audio-based timing
324
+ if (aIsHeader && !bIsHeader) return -1;
325
+ if (!aIsHeader && bIsHeader) return 1;
326
+
327
+ // Both have source timing - sort by time
328
+ if (a.hasSourceTiming && b.hasSourceTiming) {
329
+ return a.templateBlock.sourceStartTime! - b.templateBlock.sourceStartTime!;
330
+ }
331
+
332
+ // One has timing, one doesn't - timing comes first
333
+ if (a.hasSourceTiming && !b.hasSourceTiming) return -1;
334
+ if (!a.hasSourceTiming && b.hasSourceTiming) return 1;
335
+ }
336
+
337
+ // Keep original document order
338
+ return a.originalIndex - b.originalIndex;
339
+ });
340
+
341
+ // Second pass: assign start times
342
+ // Slides with sourceStartTime use that; others fill gaps
343
+ let offsetWithinSegment = 0;
344
+ for (let i = 0; i < expandedInfos.length; i++) {
345
+ const info = expandedInfos[i];
346
+ const { block: expandedBlock, templateBlock } = info;
347
+
348
+ // Use source timing if available, otherwise use sequential offset
349
+ if (info.hasSourceTiming) {
350
+ // Use the source timing - this is when the content is spoken
351
+ const sourceStart = templateBlock.sourceStartTime!;
352
+ expandedBlock.startTime = audioSegment.startTime + sourceStart;
353
+ // Use source duration if available, otherwise use block duration
354
+ if (typeof templateBlock.sourceDuration === 'number') {
355
+ expandedBlock.duration = Math.max(5, templateBlock.sourceDuration);
356
+ }
357
+ // Update offset for next block
358
+ offsetWithinSegment = sourceStart + expandedBlock.duration;
359
+ } else if (templateBlock.template === 'sectionHeader') {
360
+ // Section headers start at the beginning of the segment
361
+ expandedBlock.startTime = audioSegment.startTime + offsetWithinSegment;
362
+ offsetWithinSegment += expandedBlock.duration;
363
+ } else {
364
+ // No source timing - place after previous block
365
+ expandedBlock.startTime = audioSegment.startTime + offsetWithinSegment;
366
+ // Scale content block duration to fit
367
+ const scaledDuration = expandedBlock.duration * scaleFactor;
368
+ expandedBlock.duration = scaledDuration;
369
+ offsetWithinSegment += scaledDuration;
370
+ }
371
+
372
+ expandedBlocks[info.originalIndex] = expandedBlock;
373
+ }
374
+
375
+ // Third pass: fix overlaps and gaps by adjusting durations
376
+ // Sort by startTime for overlap detection
377
+ const segmentExpandedBlocks = expandedInfos
378
+ .map((info) => expandedBlocks[info.originalIndex])
379
+ .sort((a, b) => a.startTime - b.startTime);
380
+
381
+ for (let i = 0; i < segmentExpandedBlocks.length - 1; i++) {
382
+ const current = segmentExpandedBlocks[i];
383
+ const next = segmentExpandedBlocks[i + 1];
384
+ const currentEnd = current.startTime + current.duration;
385
+
386
+ if (currentEnd > next.startTime) {
387
+ // Overlap - shorten current block to end when next begins
388
+ current.duration = next.startTime - current.startTime;
389
+ } else if (currentEnd < next.startTime - 0.5) {
390
+ // Gap > 0.5s - extend current block to fill (visual continuity)
391
+ // Same formula as overlap: snap to next block start
392
+ current.duration = Math.max(0.1, next.startTime - current.startTime);
393
+ }
394
+ }
395
+
396
+ // Minimum gap between any two transitions (including section boundaries).
397
+ // No block should be visible for less than this before a transition occurs.
398
+ const MIN_TRANSITION_GAP = 5;
399
+ const segmentEnd = audioSegment.startTime + audioSegment.duration;
400
+
401
+ // Fourth pass: enforce minimum transition gap at section boundary.
402
+ // Eliminate blocks from the end that would start within MIN_TRANSITION_GAP
403
+ // of the section end (creating a jarring transition right before the next
404
+ // section header). Loop to handle cascading eliminations.
405
+ while (segmentExpandedBlocks.length > 1) {
406
+ const lastBlock = segmentExpandedBlocks[segmentExpandedBlocks.length - 1];
407
+ const timeFromLastToEnd = segmentEnd - lastBlock.startTime;
408
+
409
+ if (timeFromLastToEnd < MIN_TRANSITION_GAP && lastBlock.template !== 'sectionHeader') {
410
+ const prevBlock = segmentExpandedBlocks[segmentExpandedBlocks.length - 2];
411
+ prevBlock.duration = segmentEnd - prevBlock.startTime;
412
+ lastBlock.duration = 0;
413
+ lastBlock.startTime = segmentEnd;
414
+ segmentExpandedBlocks.pop();
415
+ } else {
416
+ // Extend last block to fill to segment end
417
+ if (lastBlock.startTime + lastBlock.duration < segmentEnd) {
418
+ lastBlock.duration = segmentEnd - lastBlock.startTime;
419
+ }
420
+ break;
421
+ }
422
+ }
423
+
424
+ // Handle single-block segment
425
+ if (segmentExpandedBlocks.length === 1) {
426
+ const onlyBlock = segmentExpandedBlocks[0];
427
+ if (onlyBlock.startTime + onlyBlock.duration < segmentEnd) {
428
+ onlyBlock.duration = segmentEnd - onlyBlock.startTime;
429
+ }
430
+ }
431
+
432
+ // Fifth pass: eliminate any remaining blocks shorter than MIN_TRANSITION_GAP.
433
+ // Walk backwards and merge short blocks into their predecessor.
434
+ // Skip index 0 (first block, typically section header) to preserve segment start.
435
+ let changed = true;
436
+ while (changed) {
437
+ changed = false;
438
+ for (let i = segmentExpandedBlocks.length - 1; i >= 1; i--) {
439
+ const block = segmentExpandedBlocks[i];
440
+ if (block.duration > 0 && block.duration < MIN_TRANSITION_GAP) {
441
+ const prev = segmentExpandedBlocks[i - 1];
442
+ const blockEnd = block.startTime + block.duration;
443
+ prev.duration = blockEnd - prev.startTime;
444
+ block.duration = 0;
445
+ block.startTime = segmentEnd;
446
+ segmentExpandedBlocks.splice(i, 1);
447
+ changed = true;
448
+ break; // Restart loop after modification
449
+ }
450
+ }
451
+ }
452
+
453
+ // Sixth pass: split blocks that are too long (>20s)
454
+ // This ensures no single block lingers for too long
455
+ const MAX_BLOCK_DURATION = 20;
456
+
457
+ for (let i = 0; i < segmentExpandedBlocks.length; i++) {
458
+ const block = segmentExpandedBlocks[i];
459
+
460
+ if (block.duration > MAX_BLOCK_DURATION) {
461
+ // Calculate how many parts we need
462
+ const numParts = Math.ceil(block.duration / MAX_BLOCK_DURATION);
463
+ const partDuration = block.duration / numParts;
464
+
465
+ // Only split if each part would meet minimum transition gap
466
+ if (partDuration >= MIN_TRANSITION_GAP) {
467
+ // Shorten original block to first part
468
+ block.duration = partDuration;
469
+
470
+ // Create additional blocks for remaining parts
471
+ for (let p = 1; p < numParts; p++) {
472
+ const splitBlock: Block = {
473
+ id: `${block.id}-split-${p}`,
474
+ startTime: block.startTime + p * partDuration,
475
+ duration: partDuration,
476
+ audioSegment: block.audioSegment,
477
+ layers: (block.layers ?? []).map((layer) => ({
478
+ ...layer,
479
+ id: `${layer.id}-split-${p}`,
480
+ })),
481
+ transition: { type: 'dissolve', duration: 1.0 },
482
+ template: block.template,
483
+ };
484
+ // Insert into expanded blocks array
485
+ expandedBlocks.push(splitBlock);
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ // Filter out zero-duration blocks (eliminated in earlier passes)
493
+ return expandedBlocks.filter((block) => block && block.duration > 0);
494
+ }
495
+
496
+ /**
497
+ * Get list of available template names.
498
+ */
499
+ export function getAvailableTemplates(): string[] {
500
+ return Object.keys(templateRegistry);
501
+ }
502
+
503
+ /**
504
+ * Check if a template exists.
505
+ */
506
+ export function hasTemplate(name: string): boolean {
507
+ return name in templateRegistry;
508
+ }
509
+
510
+ // Re-export types and utilities from schemas
511
+ export {
512
+ isTemplateBlock,
513
+ createTemplateContext,
514
+ scaledFontSize,
515
+ } from '../../schemas/BlockTemplates.js';
516
+ export type {
517
+ TemplateBlock,
518
+ DocBlock,
519
+ TemplateContext,
520
+ PersistentLayerConfig,
521
+ } from '../../schemas/BlockTemplates.js';
522
+ export {
523
+ DEFAULT_THEME,
524
+ resolveTheme,
525
+ getAvailableThemes,
526
+ getThemeSummaries,
527
+ } from '../../schemas/themeLibrary.js';
528
+ export type {
529
+ Theme,
530
+ ThemeColorPalette,
531
+ ThemeColorScheme,
532
+ ThemeTypography,
533
+ ThemeStyle,
534
+ RenderStyle,
535
+ } from '../../schemas/Theme.js';
536
+ export type { DocStylePreset } from './persistentLayers.js';
537
+ // Re-export timing types (AudioSegmentTiming and ExpandDocBlocksOptions are already exported above)
538
+ export { VIEWPORT_PRESETS, getViewport, getViewportOrientation } from '../../schemas/Viewport.js';
539
+ export type {
540
+ ViewportConfig,
541
+ ViewportPreset,
542
+ ViewportOrientation,
543
+ } from '../../schemas/Viewport.js';
544
+ export { getLayoutHints, getTwoColumnPositions } from '../../schemas/LayoutStrategy.js';
545
+ export type { LayoutHints } from '../../schemas/LayoutStrategy.js';
546
+ export { expandPersistentLayers, getDocStyleConfig } from './persistentLayers.js';
547
+
548
+ // Re-export individual templates for direct access
549
+ export { titleBlock } from './titleBlock.js';
550
+ export { sectionHeader } from './sectionHeader.js';
551
+ export { statHighlight } from './statHighlight.js';
552
+ export { quoteBlock } from './quoteBlock.js';
553
+ export { factCard } from './factCard.js';
554
+ export { twoColumn } from './twoColumn.js';
555
+ export { dateEvent } from './dateEvent.js';
556
+ export { imageWithCaption } from './imageWithCaption.js';
557
+ export { mapBlock } from './mapBlock.js';
558
+ export { coverBlock, expandCoverBlock } from './coverBlock.js';
559
+ export type { CoverBlockInput } from './coverBlock.js';
560
+ export { fullBleedQuote } from './fullBleedQuote.js';
561
+ export { listBlock } from './listBlock.js';
562
+ export { photoGrid } from './photoGrid.js';
563
+ export { definitionCard } from './definitionCard.js';
564
+ export { comparisonBar } from './comparisonBar.js';
565
+ export { pullQuote } from './pullQuote.js';
566
+ export { videoWithCaption } from './videoWithCaption.js';
567
+ export { videoPullQuote } from './videoPullQuote.js';
568
+
569
+ // Re-export accent image utilities
570
+ export { getAccentLayout, createAccentLayers, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
571
+ export type { AccentLayout } from './accentImage.js';
@@ -0,0 +1,101 @@
1
+ /**
2
+ * List Block Template
3
+ *
4
+ * Displays 3-5 items in a numbered vertical list with staggered animations.
5
+ * Good for enumerations like "things to see", "key features", or "tips".
6
+ * Supports optional accent images.
7
+ *
8
+ * This is shared code used by both site and efb-app doc renderers.
9
+ */
10
+
11
+ import type { Layer } from '../../schemas/Doc.js';
12
+ import type { ListBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
+ import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
15
+
16
+ export function listBlock(input: ListBlockInput, context: TemplateContext): Layer[] {
17
+ const { items, title, accentImage } = input;
18
+ const { theme } = context;
19
+
20
+ // Get layout adjustments if accent image is present
21
+ const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
22
+
23
+ const titleFontSize = scaledFontSize(44, context, true);
24
+ const itemFontSize = scaledFontSize(34, context, false);
25
+
26
+ const layers: Layer[] = [
27
+ // Background — gradient
28
+ {
29
+ type: 'shape',
30
+ id: 'bg',
31
+ content: {
32
+ shape: 'rect',
33
+ fill: `linear-gradient(155deg, ${theme.colors.backgroundLight} 0%, ${theme.colors.background} 100%)`,
34
+ },
35
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
36
+ },
37
+ ];
38
+
39
+ // Add accent image layers
40
+ if (accentImage) {
41
+ layers.push(...createAccentLayers(accentImage, input.id));
42
+ }
43
+
44
+ // Title if provided
45
+ const startY = title ? 30 : 22;
46
+ if (title) {
47
+ layers.push({
48
+ type: 'text',
49
+ id: 'list-title',
50
+ content: {
51
+ text: title,
52
+ style: {
53
+ fontSize: titleFontSize,
54
+ fontWeight: 'bold',
55
+ color: theme.colors.text,
56
+ textAlign: 'center',
57
+ shadow: !!accentImage,
58
+ },
59
+ },
60
+ position: {
61
+ x: accentLayout.textCenterX,
62
+ y: adjustY('16%', accentLayout),
63
+ anchor: 'center',
64
+ },
65
+ animation: { type: 'fadeIn', duration: 1 },
66
+ });
67
+ }
68
+
69
+ // Calculate vertical spacing for items
70
+ const endY = 80;
71
+ const spacing = items.length > 1 ? (endY - startY) / (items.length - 1) : 0;
72
+
73
+ // List items with staggered animation
74
+ for (let i = 0; i < items.length; i++) {
75
+ const y = startY + spacing * i;
76
+ const itemText = `${i + 1}. ${items[i]}`;
77
+
78
+ layers.push({
79
+ type: 'text',
80
+ id: `item-${i}`,
81
+ content: {
82
+ text: itemText,
83
+ style: {
84
+ fontSize: itemFontSize,
85
+ color: theme.colors.text,
86
+ lineHeight: 1.4,
87
+ shadow: !!accentImage,
88
+ },
89
+ },
90
+ position: {
91
+ x: accentLayout.textCenterX,
92
+ y: adjustY(`${y}%`, accentLayout),
93
+ anchor: 'center',
94
+ width: accentLayout.textWidth,
95
+ },
96
+ animation: { type: 'fadeIn', duration: 0.8, delay: 0.3 + 0.3 * i },
97
+ });
98
+ }
99
+
100
+ return layers;
101
+ }