@bendyline/squisq 1.1.0 → 1.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.
Files changed (226) hide show
  1. package/dist/ContentContainer-DNx460y8.d.ts +88 -0
  2. package/dist/{Doc-1hmT1LZl.d.ts → Doc-Dwn5aHFC.d.ts} +115 -6
  3. package/dist/__tests__/dataTable.test.d.ts +2 -0
  4. package/dist/__tests__/dataTable.test.d.ts.map +1 -0
  5. package/dist/__tests__/dataTable.test.js +110 -0
  6. package/dist/__tests__/dataTable.test.js.map +1 -0
  7. package/dist/__tests__/markdownToDoc.test.js +75 -0
  8. package/dist/__tests__/markdownToDoc.test.js.map +1 -1
  9. package/dist/{chunk-6YJPI6VI.js → chunk-4X3JQXNM.js} +1 -1
  10. package/dist/chunk-DNJKBAI7.js +1 -0
  11. package/dist/chunk-DNJKBAI7.js.map +1 -0
  12. package/dist/{chunk-S4F2RY37.js → chunk-KEFWM2VC.js} +1 -1
  13. package/dist/chunk-KEFWM2VC.js.map +1 -0
  14. package/dist/{chunk-VP5SZTZ2.js → chunk-MIBRIRGJ.js} +2 -139
  15. package/dist/chunk-MIBRIRGJ.js.map +1 -0
  16. package/dist/{chunk-URU6QMLY.js → chunk-NJLL5XYC.js} +15 -8
  17. package/dist/chunk-NJLL5XYC.js.map +1 -0
  18. package/dist/chunk-QM5PKNPW.js +165 -0
  19. package/dist/chunk-QM5PKNPW.js.map +1 -0
  20. package/dist/{chunk-PJ7AODIM.js → chunk-T6WBC3ND.js} +459 -53
  21. package/dist/chunk-T6WBC3ND.js.map +1 -0
  22. package/dist/chunk-TSG2OSE6.js +145 -0
  23. package/dist/chunk-TSG2OSE6.js.map +1 -0
  24. package/dist/{chunk-O5HCIUAD.js → chunk-TVPKD76V.js} +81 -187
  25. package/dist/chunk-TVPKD76V.js.map +1 -0
  26. package/dist/contentExtractor-BNfVJV2U.d.ts +113 -0
  27. package/dist/doc/audioMapping.d.ts +36 -0
  28. package/dist/doc/audioMapping.d.ts.map +1 -0
  29. package/dist/doc/audioMapping.js +279 -0
  30. package/dist/doc/audioMapping.js.map +1 -0
  31. package/dist/doc/index.d.ts +1 -0
  32. package/dist/doc/index.d.ts.map +1 -1
  33. package/dist/doc/index.js +1 -0
  34. package/dist/doc/index.js.map +1 -1
  35. package/dist/doc/markdownToDoc.d.ts.map +1 -1
  36. package/dist/doc/markdownToDoc.js +126 -1
  37. package/dist/doc/markdownToDoc.js.map +1 -1
  38. package/dist/doc/templates/comparisonBar.d.ts.map +1 -1
  39. package/dist/doc/templates/comparisonBar.js +23 -5
  40. package/dist/doc/templates/comparisonBar.js.map +1 -1
  41. package/dist/doc/templates/coverBlock.d.ts.map +1 -1
  42. package/dist/doc/templates/coverBlock.js +3 -0
  43. package/dist/doc/templates/coverBlock.js.map +1 -1
  44. package/dist/doc/templates/dataTable.d.ts +14 -0
  45. package/dist/doc/templates/dataTable.d.ts.map +1 -0
  46. package/dist/doc/templates/dataTable.js +73 -0
  47. package/dist/doc/templates/dataTable.js.map +1 -0
  48. package/dist/doc/templates/dateEvent.d.ts.map +1 -1
  49. package/dist/doc/templates/dateEvent.js +4 -0
  50. package/dist/doc/templates/dateEvent.js.map +1 -1
  51. package/dist/doc/templates/definitionCard.d.ts.map +1 -1
  52. package/dist/doc/templates/definitionCard.js +4 -1
  53. package/dist/doc/templates/definitionCard.js.map +1 -1
  54. package/dist/doc/templates/factCard.d.ts.map +1 -1
  55. package/dist/doc/templates/factCard.js +4 -0
  56. package/dist/doc/templates/factCard.js.map +1 -1
  57. package/dist/doc/templates/fullBleedQuote.d.ts.map +1 -1
  58. package/dist/doc/templates/fullBleedQuote.js +2 -1
  59. package/dist/doc/templates/fullBleedQuote.js.map +1 -1
  60. package/dist/doc/templates/imageWithCaption.d.ts.map +1 -1
  61. package/dist/doc/templates/imageWithCaption.js +4 -0
  62. package/dist/doc/templates/imageWithCaption.js.map +1 -1
  63. package/dist/doc/templates/index.d.ts +1 -0
  64. package/dist/doc/templates/index.d.ts.map +1 -1
  65. package/dist/doc/templates/index.js +3 -0
  66. package/dist/doc/templates/index.js.map +1 -1
  67. package/dist/doc/templates/listBlock.d.ts.map +1 -1
  68. package/dist/doc/templates/listBlock.js +3 -0
  69. package/dist/doc/templates/listBlock.js.map +1 -1
  70. package/dist/doc/templates/mapBlock.d.ts.map +1 -1
  71. package/dist/doc/templates/mapBlock.js +3 -0
  72. package/dist/doc/templates/mapBlock.js.map +1 -1
  73. package/dist/doc/templates/photoGrid.d.ts.map +1 -1
  74. package/dist/doc/templates/photoGrid.js +3 -0
  75. package/dist/doc/templates/photoGrid.js.map +1 -1
  76. package/dist/doc/templates/pullQuote.d.ts.map +1 -1
  77. package/dist/doc/templates/pullQuote.js +4 -0
  78. package/dist/doc/templates/pullQuote.js.map +1 -1
  79. package/dist/doc/templates/quoteBlock.d.ts.map +1 -1
  80. package/dist/doc/templates/quoteBlock.js +4 -0
  81. package/dist/doc/templates/quoteBlock.js.map +1 -1
  82. package/dist/doc/templates/sectionHeader.d.ts.map +1 -1
  83. package/dist/doc/templates/sectionHeader.js +3 -2
  84. package/dist/doc/templates/sectionHeader.js.map +1 -1
  85. package/dist/doc/templates/statHighlight.d.ts.map +1 -1
  86. package/dist/doc/templates/statHighlight.js +4 -1
  87. package/dist/doc/templates/statHighlight.js.map +1 -1
  88. package/dist/doc/templates/titleBlock.d.ts.map +1 -1
  89. package/dist/doc/templates/titleBlock.js +3 -0
  90. package/dist/doc/templates/titleBlock.js.map +1 -1
  91. package/dist/doc/templates/twoColumn.d.ts.map +1 -1
  92. package/dist/doc/templates/twoColumn.js +7 -1
  93. package/dist/doc/templates/twoColumn.js.map +1 -1
  94. package/dist/doc/templates/videoPullQuote.d.ts.map +1 -1
  95. package/dist/doc/templates/videoPullQuote.js +4 -0
  96. package/dist/doc/templates/videoPullQuote.js.map +1 -1
  97. package/dist/doc/templates/videoWithCaption.d.ts.map +1 -1
  98. package/dist/doc/templates/videoWithCaption.js +3 -0
  99. package/dist/doc/templates/videoWithCaption.js.map +1 -1
  100. package/dist/doc/utils/themeUtils.d.ts +9 -0
  101. package/dist/doc/utils/themeUtils.d.ts.map +1 -1
  102. package/dist/doc/utils/themeUtils.js +26 -0
  103. package/dist/doc/utils/themeUtils.js.map +1 -1
  104. package/dist/markdown/convert.d.ts.map +1 -1
  105. package/dist/markdown/convert.js +16 -7
  106. package/dist/markdown/convert.js.map +1 -1
  107. package/dist/markdown/types.d.ts +4 -2
  108. package/dist/markdown/types.d.ts.map +1 -1
  109. package/dist/schemas/BlockTemplates.d.ts +20 -3
  110. package/dist/schemas/BlockTemplates.d.ts.map +1 -1
  111. package/dist/schemas/BlockTemplates.js +2 -2
  112. package/dist/schemas/BlockTemplates.js.map +1 -1
  113. package/dist/schemas/Doc.d.ts +93 -1
  114. package/dist/schemas/Doc.d.ts.map +1 -1
  115. package/dist/schemas/Doc.js.map +1 -1
  116. package/dist/schemas/themeLibrary.d.ts +2 -2
  117. package/dist/schemas/themeLibrary.d.ts.map +1 -1
  118. package/dist/schemas/themeLibrary.js +91 -26
  119. package/dist/schemas/themeLibrary.js.map +1 -1
  120. package/dist/story/index.d.ts +63 -5
  121. package/dist/story/index.js +17 -5
  122. package/dist/{themeLibrary-DR1VRo-8.d.ts → themeLibrary-DLJtGh8-.d.ts} +3 -3
  123. package/dist/transform/applyTransform.d.ts +22 -0
  124. package/dist/transform/applyTransform.d.ts.map +1 -0
  125. package/dist/transform/applyTransform.js +68 -0
  126. package/dist/transform/applyTransform.js.map +1 -0
  127. package/dist/transform/blockAnalyzer.d.ts +48 -0
  128. package/dist/transform/blockAnalyzer.d.ts.map +1 -0
  129. package/dist/transform/blockAnalyzer.js +135 -0
  130. package/dist/transform/blockAnalyzer.js.map +1 -0
  131. package/dist/transform/index.d.ts +6 -0
  132. package/dist/transform/index.d.ts.map +1 -0
  133. package/dist/transform/index.js +4 -0
  134. package/dist/transform/index.js.map +1 -0
  135. package/dist/transform/registry.d.ts +19 -0
  136. package/dist/transform/registry.d.ts.map +1 -0
  137. package/dist/transform/registry.js +41 -0
  138. package/dist/transform/registry.js.map +1 -0
  139. package/dist/transform/styles/dataDriven.d.ts +4 -0
  140. package/dist/transform/styles/dataDriven.d.ts.map +1 -0
  141. package/dist/transform/styles/dataDriven.js +15 -0
  142. package/dist/transform/styles/dataDriven.js.map +1 -0
  143. package/dist/transform/styles/documentary.d.ts +4 -0
  144. package/dist/transform/styles/documentary.d.ts.map +1 -0
  145. package/dist/transform/styles/documentary.js +15 -0
  146. package/dist/transform/styles/documentary.js.map +1 -0
  147. package/dist/transform/styles/index.d.ts +6 -0
  148. package/dist/transform/styles/index.d.ts.map +1 -0
  149. package/dist/transform/styles/index.js +6 -0
  150. package/dist/transform/styles/index.js.map +1 -0
  151. package/dist/transform/styles/magazine.d.ts +4 -0
  152. package/dist/transform/styles/magazine.d.ts.map +1 -0
  153. package/dist/transform/styles/magazine.js +15 -0
  154. package/dist/transform/styles/magazine.js.map +1 -0
  155. package/dist/transform/styles/minimal.d.ts +4 -0
  156. package/dist/transform/styles/minimal.d.ts.map +1 -0
  157. package/dist/transform/styles/minimal.js +15 -0
  158. package/dist/transform/styles/minimal.js.map +1 -0
  159. package/dist/transform/styles/narrative.d.ts +4 -0
  160. package/dist/transform/styles/narrative.d.ts.map +1 -0
  161. package/dist/transform/styles/narrative.js +15 -0
  162. package/dist/transform/styles/narrative.js.map +1 -0
  163. package/dist/transform/templateSelector.d.ts +30 -0
  164. package/dist/transform/templateSelector.d.ts.map +1 -0
  165. package/dist/transform/templateSelector.js +218 -0
  166. package/dist/transform/templateSelector.js.map +1 -0
  167. package/dist/transform/timingAllocator.d.ts +21 -0
  168. package/dist/transform/timingAllocator.d.ts.map +1 -0
  169. package/dist/transform/timingAllocator.js +111 -0
  170. package/dist/transform/timingAllocator.js.map +1 -0
  171. package/dist/transform/types.d.ts +86 -0
  172. package/dist/transform/types.d.ts.map +1 -0
  173. package/dist/transform/types.js +9 -0
  174. package/dist/transform/types.js.map +1 -0
  175. package/dist/{types-DHiv_Pnm.d.ts → types-DYAzisMG.d.ts} +4 -2
  176. package/package.json +6 -1
  177. package/src/__tests__/dataTable.test.ts +135 -0
  178. package/src/__tests__/markdownToDoc.test.ts +96 -0
  179. package/src/doc/audioMapping.ts +346 -0
  180. package/src/doc/index.ts +1 -0
  181. package/src/doc/markdownToDoc.ts +136 -2
  182. package/src/doc/templates/comparisonBar.ts +23 -5
  183. package/src/doc/templates/coverBlock.ts +3 -0
  184. package/src/doc/templates/dataTable.ts +84 -0
  185. package/src/doc/templates/dateEvent.ts +4 -0
  186. package/src/doc/templates/definitionCard.ts +4 -1
  187. package/src/doc/templates/factCard.ts +4 -0
  188. package/src/doc/templates/fullBleedQuote.ts +2 -1
  189. package/src/doc/templates/imageWithCaption.ts +4 -0
  190. package/src/doc/templates/index.ts +3 -0
  191. package/src/doc/templates/listBlock.ts +3 -0
  192. package/src/doc/templates/mapBlock.ts +3 -0
  193. package/src/doc/templates/photoGrid.ts +3 -0
  194. package/src/doc/templates/pullQuote.ts +4 -0
  195. package/src/doc/templates/quoteBlock.ts +4 -0
  196. package/src/doc/templates/sectionHeader.ts +3 -2
  197. package/src/doc/templates/statHighlight.ts +4 -1
  198. package/src/doc/templates/titleBlock.ts +3 -0
  199. package/src/doc/templates/twoColumn.ts +7 -1
  200. package/src/doc/templates/videoPullQuote.ts +4 -0
  201. package/src/doc/templates/videoWithCaption.ts +3 -0
  202. package/src/doc/utils/themeUtils.ts +29 -0
  203. package/src/markdown/convert.ts +17 -7
  204. package/src/markdown/types.ts +4 -2
  205. package/src/schemas/BlockTemplates.ts +22 -3
  206. package/src/schemas/Doc.ts +103 -1
  207. package/src/schemas/themeLibrary.ts +95 -26
  208. package/src/transform/applyTransform.ts +83 -0
  209. package/src/transform/blockAnalyzer.ts +181 -0
  210. package/src/transform/index.ts +20 -0
  211. package/src/transform/registry.ts +47 -0
  212. package/src/transform/styles/dataDriven.ts +16 -0
  213. package/src/transform/styles/documentary.ts +16 -0
  214. package/src/transform/styles/index.ts +5 -0
  215. package/src/transform/styles/magazine.ts +16 -0
  216. package/src/transform/styles/minimal.ts +16 -0
  217. package/src/transform/styles/narrative.ts +16 -0
  218. package/src/transform/templateSelector.ts +294 -0
  219. package/src/transform/timingAllocator.ts +126 -0
  220. package/src/transform/types.ts +105 -0
  221. package/dist/chunk-O5HCIUAD.js.map +0 -1
  222. package/dist/chunk-PJ7AODIM.js.map +0 -1
  223. package/dist/chunk-S4F2RY37.js.map +0 -1
  224. package/dist/chunk-URU6QMLY.js.map +0 -1
  225. package/dist/chunk-VP5SZTZ2.js.map +0 -1
  226. /package/dist/{chunk-6YJPI6VI.js.map → chunk-4X3JQXNM.js.map} +0 -0
@@ -372,3 +372,99 @@ describe('template annotation in markdownToDoc', () => {
372
372
  expect(doc.blocks[0].children![0].template).toBe('chart');
373
373
  });
374
374
  });
375
+
376
+ // ============================================
377
+ // Reading-time durations & caption generation
378
+ // ============================================
379
+
380
+ describe('reading-time durations', () => {
381
+ it('sets block duration based on body content reading time', () => {
382
+ // ~50 words at 200 WPM = 15 seconds
383
+ const body = Array.from({ length: 50 }, (_, i) => `word${i}`).join(' ');
384
+ const md = parseMarkdown(`# Title\n\n${body}`);
385
+ const doc = markdownToDoc(md);
386
+
387
+ const block = doc.blocks[0];
388
+ expect(block.duration).toBeGreaterThan(5); // more than default
389
+ expect(block.duration).toBe(15); // 50 words / 200 WPM * 60 = 15s
390
+ });
391
+
392
+ it('applies minimum 3s duration for very short content', () => {
393
+ const md = parseMarkdown('# Title\n\nHi.');
394
+ const doc = markdownToDoc(md);
395
+
396
+ expect(doc.blocks[0].duration).toBe(3);
397
+ });
398
+
399
+ it('uses defaultDuration for blocks with no body content', () => {
400
+ const md = parseMarkdown('# Title');
401
+ const doc = markdownToDoc(md);
402
+
403
+ expect(doc.blocks[0].duration).toBe(5); // default
404
+ });
405
+
406
+ it('doc.duration sums all reading-time block durations', () => {
407
+ // Block A: no body → 5s default. Block B: ~20 words → 6s
408
+ const body = Array.from({ length: 20 }, (_, i) => `word${i}`).join(' ');
409
+ const md = parseMarkdown(`# A\n\n# B\n\n${body}`);
410
+ const doc = markdownToDoc(md);
411
+
412
+ const flat = flattenBlocks(doc.blocks);
413
+ expect(flat[0].duration).toBe(5); // A: no body
414
+ expect(flat[1].duration).toBe(6); // B: 20 words / 200 WPM * 60 = 6s
415
+ expect(doc.duration).toBe(11);
416
+ });
417
+ });
418
+
419
+ describe('caption generation', () => {
420
+ it('generates captions from body content', () => {
421
+ const md = parseMarkdown('# Title\n\nThis is the first sentence. This is the second sentence.');
422
+ const doc = markdownToDoc(md);
423
+
424
+ expect(doc.captions).toBeDefined();
425
+ expect(doc.captions!.phrases.length).toBeGreaterThanOrEqual(2);
426
+ expect(doc.captions!.phrases[0].text).toContain('first sentence');
427
+ expect(doc.captions!.phrases[1].text).toContain('second sentence');
428
+ });
429
+
430
+ it('caption times span the block duration', () => {
431
+ const md = parseMarkdown('# Section\n\nSome text here. More text follows.');
432
+ const doc = markdownToDoc(md);
433
+
434
+ expect(doc.captions).toBeDefined();
435
+ const phrases = doc.captions!.phrases;
436
+ // First phrase starts at block start
437
+ expect(phrases[0].startTime).toBe(doc.blocks[0].startTime);
438
+ // Last phrase ends at block end
439
+ const last = phrases[phrases.length - 1];
440
+ expect(last.endTime).toBeCloseTo(doc.blocks[0].startTime + doc.blocks[0].duration, 1);
441
+ });
442
+
443
+ it('does not generate captions for blocks without body content', () => {
444
+ const md = parseMarkdown('# A\n\n# B\n\n# C');
445
+ const doc = markdownToDoc(md);
446
+
447
+ // No body content anywhere → no captions
448
+ expect(doc.captions).toBeUndefined();
449
+ });
450
+
451
+ it('generates captions version and timestamp', () => {
452
+ const md = parseMarkdown('# Title\n\nHello world and more text.');
453
+ const doc = markdownToDoc(md);
454
+
455
+ expect(doc.captions!.version).toBe(1);
456
+ expect(doc.captions!.generatedAt).toBeTruthy();
457
+ });
458
+
459
+ it('caption phrases have sequential non-overlapping times', () => {
460
+ const md = parseMarkdown(
461
+ '# A\n\nFirst sentence here. Second sentence here.\n\n# B\n\nThird sentence. Fourth sentence.',
462
+ );
463
+ const doc = markdownToDoc(md);
464
+
465
+ const phrases = doc.captions!.phrases;
466
+ for (let i = 1; i < phrases.length; i++) {
467
+ expect(phrases[i].startTime).toBeGreaterThanOrEqual(phrases[i - 1].endTime - 0.001);
468
+ }
469
+ });
470
+ });
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Audio Mapping
3
+ *
4
+ * Associates audio segments (MP3 files) with document blocks.
5
+ * Supports two modes:
6
+ *
7
+ * 1. **Explicit annotation**: Blocks with `{[audio=filename.mp3]}` in their
8
+ * heading annotation are directly mapped to that audio file.
9
+ *
10
+ * 2. **Auto-matching**: When no annotations exist but the container has MP3s
11
+ * with `.timing.json` files, matches MP3s to blocks by comparing the
12
+ * timing.json `sourceText` against block text content (word overlap).
13
+ * Falls back to filename-based matching (slugified title comparison).
14
+ */
15
+
16
+ import type { Doc, Block, AudioSegment, AudioTimingData } from '../schemas/Doc.js';
17
+ import type { ContentContainer } from '../storage/ContentContainer.js';
18
+ import { extractPlainText } from '../markdown/utils.js';
19
+
20
+ // ── Helpers ──────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Parse a timing.json file from raw data.
24
+ */
25
+ function parseTimingJson(data: ArrayBuffer): AudioTimingData | null {
26
+ try {
27
+ const text = new TextDecoder().decode(data);
28
+ const parsed = JSON.parse(text);
29
+ if (parsed && typeof parsed.sourceText === 'string' && typeof parsed.duration === 'number') {
30
+ return {
31
+ sourceText: parsed.sourceText,
32
+ bookmarks: Array.isArray(parsed.bookmarks) ? parsed.bookmarks : [],
33
+ duration: parsed.duration,
34
+ };
35
+ }
36
+ return null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Normalize text for comparison: lowercase, strip punctuation, split into word set.
44
+ */
45
+ function normalizeToWords(text: string): Set<string> {
46
+ const words = text
47
+ .toLowerCase()
48
+ .replace(/[^\w\s]/g, ' ')
49
+ .split(/\s+/)
50
+ .filter((w) => w.length > 2);
51
+ return new Set(words);
52
+ }
53
+
54
+ /**
55
+ * Calculate Jaccard similarity between two texts (word overlap).
56
+ * Returns 0–1 where 1 = identical word sets.
57
+ */
58
+ export function scoreTextSimilarity(a: string, b: string): number {
59
+ const wordsA = normalizeToWords(a);
60
+ const wordsB = normalizeToWords(b);
61
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
62
+
63
+ let intersection = 0;
64
+ for (const w of wordsA) {
65
+ if (wordsB.has(w)) intersection++;
66
+ }
67
+
68
+ const union = new Set([...wordsA, ...wordsB]).size;
69
+ return union > 0 ? intersection / union : 0;
70
+ }
71
+
72
+ /**
73
+ * Slugify a title for filename comparison.
74
+ * "The Behring Gambit" → "the-behring-gambit"
75
+ */
76
+ function slugify(text: string): string {
77
+ return text
78
+ .toLowerCase()
79
+ .replace(/[^\w\s-]/g, '')
80
+ .replace(/\s+/g, '-')
81
+ .replace(/-+/g, '-')
82
+ .replace(/^-|-$/g, '');
83
+ }
84
+
85
+ /**
86
+ * Extract plain text from a block's contents and title.
87
+ */
88
+ function blockToPlainText(block: Block): string {
89
+ const parts: string[] = [];
90
+ if (block.title) parts.push(block.title);
91
+ if (block.contents) {
92
+ for (const node of block.contents) {
93
+ parts.push(extractPlainText(node));
94
+ }
95
+ }
96
+ return parts.join('\n');
97
+ }
98
+
99
+ /**
100
+ * Flatten a block tree into a linear array of leaf blocks
101
+ * (blocks that represent actual content sections).
102
+ */
103
+ function flattenForAudio(blocks: Block[]): Block[] {
104
+ const result: Block[] = [];
105
+ for (const block of blocks) {
106
+ if (block.children && block.children.length > 0) {
107
+ result.push(...flattenForAudio(block.children));
108
+ } else {
109
+ result.push(block);
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+
115
+ /** Info about an MP3 file found in the container. */
116
+ interface Mp3Info {
117
+ /** Path in the container (e.g., "images/c23n-issaquah-highlands-intro.mp3") */
118
+ path: string;
119
+ /** Filename without path (e.g., "c23n-issaquah-highlands-intro.mp3") */
120
+ filename: string;
121
+ /** Parsed timing data (if .timing.json exists) */
122
+ timing: AudioTimingData | null;
123
+ }
124
+
125
+ // ── Core Functions ───────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Discover all MP3 files in a container and load their timing data.
129
+ */
130
+ async function discoverMp3s(container: ContentContainer): Promise<Mp3Info[]> {
131
+ const files = await container.listFiles();
132
+ const mp3Files = files.filter((f) => f.path.endsWith('.mp3'));
133
+ const results: Mp3Info[] = [];
134
+
135
+ for (const file of mp3Files) {
136
+ const filename = file.path.split('/').pop() ?? file.path;
137
+ let timing: AudioTimingData | null = null;
138
+
139
+ // Try to load timing.json
140
+ const timingPath = `${file.path}.timing.json`;
141
+ const timingData = await container.readFile(timingPath);
142
+ if (timingData) {
143
+ timing = parseTimingJson(timingData);
144
+ }
145
+
146
+ results.push({ path: file.path, filename, timing });
147
+ }
148
+
149
+ return results;
150
+ }
151
+
152
+ /**
153
+ * Resolve audio from explicit `{[audio=filename.mp3]}` annotations on blocks.
154
+ * Returns the matched pairs or an empty array if no annotations found.
155
+ */
156
+ function resolveFromAnnotations(
157
+ blocks: Block[],
158
+ mp3s: Mp3Info[],
159
+ ): Array<{ block: Block; mp3: Mp3Info }> {
160
+ const matches: Array<{ block: Block; mp3: Mp3Info }> = [];
161
+ const flat = flattenForAudio(blocks);
162
+
163
+ for (const block of flat) {
164
+ const audioRef = block.templateOverrides?.audio;
165
+ if (!audioRef) continue;
166
+
167
+ // Find the MP3 by filename (exact or suffix match)
168
+ const mp3 = mp3s.find(
169
+ (m) => m.filename === audioRef || m.path === audioRef || m.path.endsWith(`/${audioRef}`),
170
+ );
171
+
172
+ if (mp3) {
173
+ matches.push({ block, mp3 });
174
+ }
175
+ }
176
+
177
+ return matches;
178
+ }
179
+
180
+ /** Minimum similarity score to consider a content match. */
181
+ const MIN_CONTENT_SIMILARITY = 0.35;
182
+
183
+ /** Minimum similarity score for filename-based matching. */
184
+ const MIN_FILENAME_SIMILARITY = 0.5;
185
+
186
+ /**
187
+ * Auto-match MP3 files to blocks by content similarity.
188
+ * Uses timing.json sourceText when available, falls back to filename matching.
189
+ */
190
+ function autoMatchBlocks(
191
+ blocks: Block[],
192
+ mp3s: Mp3Info[],
193
+ ): Array<{ block: Block; mp3: Mp3Info; score: number }> {
194
+ const flat = flattenForAudio(blocks);
195
+ if (flat.length === 0 || mp3s.length === 0) return [];
196
+
197
+ // Score all (mp3, block) pairs
198
+ const scoredPairs: Array<{
199
+ block: Block;
200
+ blockIndex: number;
201
+ mp3: Mp3Info;
202
+ mp3Index: number;
203
+ score: number;
204
+ }> = [];
205
+
206
+ for (let mi = 0; mi < mp3s.length; mi++) {
207
+ const mp3 = mp3s[mi];
208
+
209
+ for (let bi = 0; bi < flat.length; bi++) {
210
+ const block = flat[bi];
211
+ const blockText = blockToPlainText(block);
212
+ if (!blockText.trim()) continue;
213
+
214
+ let score = 0;
215
+
216
+ // Primary: content similarity via timing.json sourceText
217
+ if (mp3.timing?.sourceText) {
218
+ score = scoreTextSimilarity(mp3.timing.sourceText, blockText);
219
+ }
220
+
221
+ // Fallback: filename-based matching
222
+ if (score < MIN_CONTENT_SIMILARITY && block.title) {
223
+ const titleSlug = slugify(block.title);
224
+ const fileSlug = slugify(mp3.filename.replace(/\.mp3$/, ''));
225
+ // Check if the title slug appears in the filename slug
226
+ if (fileSlug.includes(titleSlug) && titleSlug.length >= 3) {
227
+ score = Math.max(score, MIN_FILENAME_SIMILARITY + 0.1);
228
+ }
229
+ }
230
+
231
+ if (score >= MIN_CONTENT_SIMILARITY) {
232
+ scoredPairs.push({ block, blockIndex: bi, mp3, mp3Index: mi, score });
233
+ }
234
+ }
235
+ }
236
+
237
+ // Greedy assignment: highest score first, each mp3 and block used at most once
238
+ scoredPairs.sort((a, b) => b.score - a.score);
239
+ const usedBlocks = new Set<number>();
240
+ const usedMp3s = new Set<number>();
241
+ const matches: Array<{ block: Block; mp3: Mp3Info; score: number }> = [];
242
+
243
+ for (const pair of scoredPairs) {
244
+ if (usedBlocks.has(pair.blockIndex) || usedMp3s.has(pair.mp3Index)) continue;
245
+ usedBlocks.add(pair.blockIndex);
246
+ usedMp3s.add(pair.mp3Index);
247
+ matches.push({ block: pair.block, mp3: pair.mp3, score: pair.score });
248
+ }
249
+
250
+ return matches;
251
+ }
252
+
253
+ // ── Public API ───────────────────────────────────────────────────────
254
+
255
+ /**
256
+ * Resolve audio mapping for a Doc using a ContentContainer.
257
+ *
258
+ * Tries explicit `{[audio=filename.mp3]}` annotations first,
259
+ * then falls back to auto-matching by content similarity.
260
+ *
261
+ * Returns a new Doc with `audio.segments` populated and blocks
262
+ * assigned to the correct `audioSegment` indices.
263
+ *
264
+ * @param doc - The source Doc (not mutated).
265
+ * @param container - ContentContainer with MP3 and timing files.
266
+ * @returns New Doc with audio segments resolved, or original doc if no matches.
267
+ */
268
+ export async function resolveAudioMapping(doc: Doc, container: ContentContainer): Promise<Doc> {
269
+ // Discover MP3 files and their timing data
270
+ const mp3s = await discoverMp3s(container);
271
+ if (mp3s.length === 0) return doc;
272
+
273
+ // Try annotations first
274
+ let matches = resolveFromAnnotations(doc.blocks, mp3s);
275
+
276
+ // Fall back to auto-matching if no annotations found
277
+ if (matches.length === 0) {
278
+ matches = autoMatchBlocks(doc.blocks, mp3s).map((m) => ({
279
+ block: m.block,
280
+ mp3: m.mp3,
281
+ }));
282
+ }
283
+
284
+ if (matches.length === 0) return doc;
285
+
286
+ // Build audio segments in document order
287
+ // Sort matches by the block's position in the flattened tree
288
+ const flat = flattenForAudio(doc.blocks);
289
+ const blockOrder = new Map(flat.map((b, i) => [b.id, i]));
290
+ matches.sort((a, b) => (blockOrder.get(a.block.id) ?? 0) - (blockOrder.get(b.block.id) ?? 0));
291
+
292
+ // Build segment list and assign indices
293
+ const segments: AudioSegment[] = [];
294
+ const blockToSegment = new Map<string, number>();
295
+ let startTime = 0;
296
+
297
+ for (let i = 0; i < matches.length; i++) {
298
+ const { block, mp3 } = matches[i];
299
+ const duration = mp3.timing?.duration ?? 30; // Fallback duration if no timing
300
+
301
+ segments.push({
302
+ src: mp3.path,
303
+ name: block.title ?? block.id,
304
+ duration,
305
+ startTime,
306
+ });
307
+
308
+ blockToSegment.set(block.id, i);
309
+ startTime += duration;
310
+ }
311
+
312
+ // Deep clone blocks and assign audioSegment indices
313
+ const newBlocks = assignSegmentIndices(doc.blocks, blockToSegment);
314
+
315
+ return {
316
+ ...doc,
317
+ blocks: newBlocks,
318
+ duration: startTime,
319
+ audio: { segments },
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Recursively clone blocks and assign audioSegment indices.
325
+ * Blocks not in the map keep their original audioSegment.
326
+ * Children of a matched block inherit the parent's segment.
327
+ */
328
+ function assignSegmentIndices(
329
+ blocks: Block[],
330
+ blockToSegment: Map<string, number>,
331
+ inheritedSegment?: number,
332
+ ): Block[] {
333
+ return blocks.map((block) => {
334
+ const segment = blockToSegment.get(block.id) ?? inheritedSegment ?? block.audioSegment;
335
+ const newBlock: Block = {
336
+ ...block,
337
+ audioSegment: segment,
338
+ };
339
+
340
+ if (block.children) {
341
+ newBlock.children = assignSegmentIndices(block.children, blockToSegment, segment);
342
+ }
343
+
344
+ return newBlock;
345
+ });
346
+ }
package/src/doc/index.ts CHANGED
@@ -6,3 +6,4 @@ export type { MarkdownToDocOptions } from './markdownToDoc.js';
6
6
  export { docToMarkdown } from './docToMarkdown.js';
7
7
  export { getLayers } from './getLayers.js';
8
8
  export type { RenderContext } from './getLayers.js';
9
+ export { resolveAudioMapping, scoreTextSimilarity } from './audioMapping.js';
@@ -22,9 +22,10 @@
22
22
  * ```
23
23
  */
24
24
 
25
- import type { Doc, Block } from '../schemas/Doc.js';
25
+ import type { Doc, Block, CaptionTrack, CaptionPhrase } from '../schemas/Doc.js';
26
26
  import type { MarkdownDocument, MarkdownBlockNode, MarkdownHeading } from '../markdown/types.js';
27
27
  import { extractPlainText } from '../markdown/utils.js';
28
+ import { estimateReadingTime } from '../timing/readingTime.js';
28
29
 
29
30
  // ============================================
30
31
  // Options
@@ -141,6 +142,10 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
141
142
  const annotation = heading?.templateAnnotation;
142
143
  const template = annotation?.template ?? (heading ? defaultTemplate : undefined);
143
144
 
145
+ // Extract heading text so templates (e.g. sectionHeader) that expect a
146
+ // `title` property receive it without having to reach into sourceHeading.
147
+ const title = heading ? extractPlainText(heading) : undefined;
148
+
144
149
  const block: Block = {
145
150
  id,
146
151
  startTime: 0,
@@ -148,6 +153,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
148
153
  audioSegment: 0,
149
154
  template,
150
155
  ...(heading ? { sourceHeading: heading } : {}),
156
+ ...(title ? { title } : {}),
151
157
  };
152
158
 
153
159
  // Propagate key-value params from annotation to templateOverrides
@@ -217,14 +223,50 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
217
223
  rootBlocks.push(currentBlock);
218
224
  }
219
225
 
220
- // Calculate basic timing
226
+ // Calculate reading-time-based durations and generate captions
221
227
  const allBlocks = flattenBlocks(rootBlocks);
228
+ const minDuration = 3; // seconds — minimum for blocks with little/no text
229
+ const phrases: CaptionPhrase[] = [];
230
+
231
+ // First pass: compute duration from body-content reading time
232
+ for (const block of allBlocks) {
233
+ const bodyText = getBlockBodyText(block);
234
+ if (bodyText.length > 0) {
235
+ const estimate = estimateReadingTime(bodyText);
236
+ block.duration = Math.max(minDuration, estimate.seconds);
237
+ } else {
238
+ block.duration = defaultDuration;
239
+ }
240
+ }
241
+
242
+ // Second pass: assign start times sequentially and build caption phrases
222
243
  let currentTime = 0;
223
244
  for (const block of allBlocks) {
224
245
  block.startTime = currentTime;
246
+
247
+ // Generate caption phrases from the block's body content
248
+ const bodyText = getBlockBodyText(block);
249
+ if (bodyText.length > 0) {
250
+ const sentences = splitIntoSentences(bodyText);
251
+ if (sentences.length > 0) {
252
+ const timePerSentence = block.duration / sentences.length;
253
+ for (let i = 0; i < sentences.length; i++) {
254
+ phrases.push({
255
+ text: sentences[i],
256
+ startTime: currentTime + i * timePerSentence,
257
+ endTime: currentTime + (i + 1) * timePerSentence,
258
+ audioSegment: 0,
259
+ });
260
+ }
261
+ }
262
+ }
263
+
225
264
  currentTime += block.duration;
226
265
  }
227
266
 
267
+ const captions: CaptionTrack | undefined =
268
+ phrases.length > 0 ? { phrases, generatedAt: new Date().toISOString(), version: 1 } : undefined;
269
+
228
270
  return {
229
271
  articleId,
230
272
  duration: currentTime,
@@ -232,6 +274,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
232
274
  audio: {
233
275
  segments: [],
234
276
  },
277
+ ...(captions ? { captions } : {}),
235
278
  ...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
236
279
  };
237
280
  }
@@ -275,3 +318,94 @@ export function countBlocks(blocks: Block[]): number {
275
318
  export function getBlockDepth(block: Block): number {
276
319
  return block.sourceHeading?.depth ?? 0;
277
320
  }
321
+
322
+ // ============================================
323
+ // Internal helpers
324
+ // ============================================
325
+
326
+ /**
327
+ * Extract the plain text from a block's body contents (excluding heading text).
328
+ */
329
+ function getBlockBodyText(block: Block): string {
330
+ if (!block.contents || block.contents.length === 0) return '';
331
+ // Join with newlines to preserve paragraph/list-item boundaries.
332
+ // splitIntoPhrases uses these newlines as natural split points.
333
+ return block.contents
334
+ .map((node) => extractPlainText(node))
335
+ .join('\n')
336
+ .trim();
337
+ }
338
+
339
+ /** Maximum words per caption phrase. Long sentences get split at this point. */
340
+ const MAX_PHRASE_WORDS = 12;
341
+
342
+ /**
343
+ * Split text into caption-sized phrases.
344
+ *
345
+ * Splits on:
346
+ * 1. Newlines (paragraph/list-item boundaries from markdown)
347
+ * 2. Sentence endings (.!?) followed by whitespace
348
+ * 3. Long fragments (> MAX_PHRASE_WORDS) at clause boundaries (commas, semicolons, dashes)
349
+ *
350
+ * Merges very short fragments (< 15 chars) with the previous phrase.
351
+ */
352
+ function splitIntoSentences(text: string): string[] {
353
+ // First split on newlines (each paragraph/list item becomes separate)
354
+ const lines = text
355
+ .split(/\n+/)
356
+ .map((s) => s.trim())
357
+ .filter((s) => s.length > 0);
358
+
359
+ // Then split each line on sentence boundaries
360
+ const sentenceRe = /(?<=[.!?])\s+/;
361
+ const fragments: string[] = [];
362
+ for (const line of lines) {
363
+ const sentences = line
364
+ .split(sentenceRe)
365
+ .map((s) => s.trim())
366
+ .filter((s) => s.length > 0);
367
+ fragments.push(...sentences);
368
+ }
369
+
370
+ if (fragments.length === 0) return [];
371
+
372
+ // Split long fragments at clause boundaries
373
+ const split: string[] = [];
374
+ for (const frag of fragments) {
375
+ const words = frag.split(/\s+/);
376
+ if (words.length > MAX_PHRASE_WORDS) {
377
+ // Try to split at comma, semicolon, or dash near the midpoint
378
+ const mid = Math.floor(frag.length / 2);
379
+ const clauseRe = /[,;]\s+|\s+—\s+|\s+-\s+/g;
380
+ let bestSplit = -1;
381
+ let bestDist = Infinity;
382
+ let match;
383
+ while ((match = clauseRe.exec(frag)) !== null) {
384
+ const dist = Math.abs(match.index - mid);
385
+ if (dist < bestDist) {
386
+ bestDist = dist;
387
+ bestSplit = match.index + match[0].length;
388
+ }
389
+ }
390
+ if (bestSplit > 0 && bestSplit < frag.length - 5) {
391
+ split.push(frag.slice(0, bestSplit).trim());
392
+ split.push(frag.slice(bestSplit).trim());
393
+ } else {
394
+ split.push(frag);
395
+ }
396
+ } else {
397
+ split.push(frag);
398
+ }
399
+ }
400
+
401
+ // Merge very short fragments (< 15 chars) with the previous phrase
402
+ const merged: string[] = [split[0]];
403
+ for (let i = 1; i < split.length; i++) {
404
+ if (split[i].length < 15 && merged.length > 0) {
405
+ merged[merged.length - 1] += ' ' + split[i];
406
+ } else {
407
+ merged.push(split[i]);
408
+ }
409
+ }
410
+ return merged;
411
+ }
@@ -11,7 +11,7 @@
11
11
  import type { Layer } from '../../schemas/Doc.js';
12
12
  import type { ComparisonBarInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
13
  import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
- import { resolveColorScheme } from '../utils/themeUtils.js';
14
+ import { resolveColorScheme, getThemeFont } from '../utils/themeUtils.js';
15
15
 
16
16
  export function comparisonBar(input: ComparisonBarInput, context: TemplateContext): Layer[] {
17
17
  const { leftLabel, leftValue, rightLabel, rightValue, unit, colorScheme = 'blue' } = input;
@@ -61,7 +61,11 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
61
61
  id: 'left-label',
62
62
  content: {
63
63
  text: leftLabel,
64
- style: { fontSize: labelFontSize, color: theme.colors.textMuted },
64
+ style: {
65
+ fontSize: labelFontSize,
66
+ fontFamily: getThemeFont(context, 'body'),
67
+ color: theme.colors.textMuted,
68
+ },
65
69
  },
66
70
  position: { x: `${barStartX}%`, y: `${topBarY - 8}%` },
67
71
  },
@@ -72,7 +76,12 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
72
76
  id: 'left-value',
73
77
  content: {
74
78
  text: leftDisplay,
75
- style: { fontSize: valueFontSize, fontWeight: 'bold', color: colors.text },
79
+ style: {
80
+ fontSize: valueFontSize,
81
+ fontFamily: getThemeFont(context, 'body'),
82
+ fontWeight: 'bold',
83
+ color: colors.text,
84
+ },
76
85
  },
77
86
  position: { x: `${barStartX + leftBarWidth + 2}%`, y: `${topBarY - 1}%` },
78
87
  animation: { type: 'fadeIn', duration: 0.8, delay: 0.3 },
@@ -102,7 +111,11 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
102
111
  id: 'right-label',
103
112
  content: {
104
113
  text: rightLabel,
105
- style: { fontSize: labelFontSize, color: theme.colors.textMuted },
114
+ style: {
115
+ fontSize: labelFontSize,
116
+ fontFamily: getThemeFont(context, 'body'),
117
+ color: theme.colors.textMuted,
118
+ },
106
119
  },
107
120
  position: { x: `${barStartX}%`, y: `${bottomBarY - 8}%` },
108
121
  },
@@ -113,7 +126,12 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
113
126
  id: 'right-value',
114
127
  content: {
115
128
  text: rightDisplay,
116
- style: { fontSize: valueFontSize, fontWeight: 'bold', color: colors.accent },
129
+ style: {
130
+ fontSize: valueFontSize,
131
+ fontFamily: getThemeFont(context, 'body'),
132
+ fontWeight: 'bold',
133
+ color: colors.accent,
134
+ },
117
135
  },
118
136
  position: { x: `${barStartX + rightBarWidth + 2}%`, y: `${bottomBarY - 1}%` },
119
137
  animation: { type: 'fadeIn', duration: 0.8, delay: 0.6 },