@bendyline/squisq 1.4.1 → 1.5.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 (254) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
  4. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +11 -4
  5. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  6. package/dist/chunk-2OIBZYKP.js.map +1 -0
  7. package/dist/chunk-2UQOHYE2.js +45 -0
  8. package/dist/chunk-2UQOHYE2.js.map +1 -0
  9. package/dist/chunk-6ATE2PSM.js +344 -0
  10. package/dist/chunk-6ATE2PSM.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
  12. package/dist/chunk-A4O7GIWE.js.map +1 -0
  13. package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
  14. package/dist/chunk-DLXZMS5K.js.map +1 -0
  15. package/dist/chunk-ET53IIEP.js +1 -0
  16. package/dist/chunk-ET53IIEP.js.map +1 -0
  17. package/dist/chunk-HWVFJAAH.js +362 -0
  18. package/dist/chunk-HWVFJAAH.js.map +1 -0
  19. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  20. package/dist/chunk-IMSCRLLV.js.map +1 -0
  21. package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
  22. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  23. package/dist/chunk-LDQ2HJIX.js +596 -0
  24. package/dist/chunk-LDQ2HJIX.js.map +1 -0
  25. package/dist/chunk-LH7I6SH7.js +7208 -0
  26. package/dist/chunk-LH7I6SH7.js.map +1 -0
  27. package/dist/chunk-PGGGKYWG.js +1 -0
  28. package/dist/chunk-PGGGKYWG.js.map +1 -0
  29. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  30. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  31. package/dist/chunk-QE7TWGVT.js +178 -0
  32. package/dist/chunk-QE7TWGVT.js.map +1 -0
  33. package/dist/chunk-RUDYOTA6.js +345 -0
  34. package/dist/chunk-RUDYOTA6.js.map +1 -0
  35. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  36. package/dist/chunk-T5UK6YOB.js.map +1 -0
  37. package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
  38. package/dist/chunk-TEEEILMP.js.map +1 -0
  39. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  40. package/dist/chunk-TQWLI6S2.js.map +1 -0
  41. package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
  42. package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
  43. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  44. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  45. package/dist/chunk-ZLQKNOZQ.js +1 -0
  46. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  47. package/dist/doc/index.d.ts +5 -5
  48. package/dist/doc/index.js +135 -8
  49. package/dist/generate/index.d.ts +3 -74
  50. package/dist/generate/index.js +2 -7
  51. package/dist/icons/index.d.ts +3 -26
  52. package/dist/icons/index.js +13 -0
  53. package/dist/icons/inlineIconMarker.d.ts +1 -0
  54. package/dist/icons/inlineIconMarker.js +15 -0
  55. package/dist/icons/inlineIconMarker.js.map +1 -0
  56. package/dist/imageEdit/index.d.ts +12 -6
  57. package/dist/imageEdit/index.js +4 -1
  58. package/dist/index.d.ts +14 -11
  59. package/dist/index.js +265 -37
  60. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  61. package/dist/jsonForm/index.d.ts +42 -1
  62. package/dist/jsonForm/index.js +9 -1
  63. package/dist/markdown/index.d.ts +228 -3
  64. package/dist/markdown/index.js +42 -7
  65. package/dist/recommend/index.d.ts +1 -1
  66. package/dist/recommend/index.js +3 -2
  67. package/dist/schemas/index.d.ts +28 -8
  68. package/dist/schemas/index.js +36 -8
  69. package/dist/spatial/index.d.ts +9 -4
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.d.ts +2 -2
  72. package/dist/storage/index.js +1 -1
  73. package/dist/story/index.d.ts +1021 -41
  74. package/dist/story/index.js +135 -8
  75. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  76. package/dist/timing/index.js +3 -5
  77. package/dist/transform/index.d.ts +48 -10
  78. package/dist/transform/index.js +17 -481
  79. package/dist/transform/index.js.map +1 -1
  80. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  81. package/dist/versions/index.d.ts +1 -1
  82. package/package.json +6 -1
  83. package/src/__tests__/animationUtils.test.ts +40 -0
  84. package/src/__tests__/annotationCoercion.test.ts +243 -0
  85. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  86. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  87. package/src/__tests__/attrTokens.test.ts +162 -0
  88. package/src/__tests__/contentExtractor.test.ts +60 -0
  89. package/src/__tests__/coverBlock.test.ts +46 -1
  90. package/src/__tests__/customTemplateSample.test.ts +144 -0
  91. package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
  92. package/src/__tests__/customThemesFrontmatter.test.ts +145 -0
  93. package/src/__tests__/diagramBlock.test.ts +122 -0
  94. package/src/__tests__/diagramLayout.test.ts +134 -0
  95. package/src/__tests__/drawing.test.ts +109 -0
  96. package/src/__tests__/drawingBlock.test.ts +124 -0
  97. package/src/__tests__/drawingLayout.test.ts +171 -0
  98. package/src/__tests__/geohash.test.ts +6 -5
  99. package/src/__tests__/getLayers.test.ts +6 -2
  100. package/src/__tests__/getLayersFallback.test.ts +103 -0
  101. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  102. package/src/__tests__/imageEditVersions.test.ts +72 -0
  103. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  104. package/src/__tests__/inputDescriptors.test.ts +162 -0
  105. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  106. package/src/__tests__/layoutBlock.test.ts +83 -0
  107. package/src/__tests__/layoutLayout.test.ts +169 -0
  108. package/src/__tests__/markdown.test.ts +87 -0
  109. package/src/__tests__/markdownSanitize.test.ts +79 -0
  110. package/src/__tests__/markdownToDoc.test.ts +391 -2
  111. package/src/__tests__/mediaAnnotations.test.ts +91 -0
  112. package/src/__tests__/mediaSchedule.test.ts +118 -0
  113. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  114. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  115. package/src/__tests__/rootExports.test.ts +43 -0
  116. package/src/__tests__/shapeGeometry.test.ts +89 -0
  117. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  118. package/src/__tests__/structuredData.test.ts +249 -0
  119. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  120. package/src/__tests__/templateMetadata.test.ts +31 -0
  121. package/src/__tests__/templates.test.ts +257 -4
  122. package/src/__tests__/themeCompile.test.ts +42 -0
  123. package/src/__tests__/themeValidator.test.ts +6 -0
  124. package/src/__tests__/transformV2.test.ts +137 -0
  125. package/src/__tests__/transitionNormalize.test.ts +61 -0
  126. package/src/__tests__/validateDoc.test.ts +260 -0
  127. package/src/doc/annotationBlocks.ts +117 -0
  128. package/src/doc/customTemplatesFrontmatter.ts +314 -0
  129. package/src/doc/customThemesFrontmatter.ts +95 -0
  130. package/src/doc/docToMarkdown.ts +104 -5
  131. package/src/doc/getLayers.ts +49 -17
  132. package/src/doc/index.ts +47 -1
  133. package/src/doc/markdownToDoc.ts +411 -13
  134. package/src/doc/mediaAnnotations.ts +100 -0
  135. package/src/doc/resolveDocTheme.ts +55 -0
  136. package/src/doc/standaloneAnnotation.ts +58 -0
  137. package/src/doc/structuredData.ts +301 -0
  138. package/src/doc/templateInputs.ts +240 -0
  139. package/src/doc/templates/__tests__/customTemplate.test.ts +170 -0
  140. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  141. package/src/doc/templates/accentImage.ts +30 -6
  142. package/src/doc/templates/captionUtils.ts +23 -0
  143. package/src/doc/templates/comparisonBar.ts +33 -18
  144. package/src/doc/templates/coverBlock.ts +35 -5
  145. package/src/doc/templates/customTemplate.ts +51 -0
  146. package/src/doc/templates/dataTable.ts +37 -19
  147. package/src/doc/templates/dateEvent.ts +41 -20
  148. package/src/doc/templates/definitionCard.ts +26 -18
  149. package/src/doc/templates/diagramBlock.ts +204 -0
  150. package/src/doc/templates/diagramLayout.ts +159 -0
  151. package/src/doc/templates/drawingBlock.ts +325 -0
  152. package/src/doc/templates/drawingLayout.ts +539 -0
  153. package/src/doc/templates/factCard.ts +54 -26
  154. package/src/doc/templates/fallbackBlock.ts +123 -0
  155. package/src/doc/templates/featureBlock.ts +20 -13
  156. package/src/doc/templates/fullBleedQuote.ts +18 -8
  157. package/src/doc/templates/imageWithCaption.ts +30 -15
  158. package/src/doc/templates/index.ts +225 -46
  159. package/src/doc/templates/inputDescriptors.ts +432 -0
  160. package/src/doc/templates/layoutBlock.ts +43 -0
  161. package/src/doc/templates/layoutLayout.ts +243 -0
  162. package/src/doc/templates/listBlock.ts +56 -30
  163. package/src/doc/templates/mapBlock.ts +15 -12
  164. package/src/doc/templates/metadata.ts +120 -0
  165. package/src/doc/templates/persistentLayers.ts +129 -11
  166. package/src/doc/templates/photoGrid.ts +22 -34
  167. package/src/doc/templates/pullQuote.ts +30 -12
  168. package/src/doc/templates/quoteBlock.ts +54 -29
  169. package/src/doc/templates/sectionHeader.ts +25 -11
  170. package/src/doc/templates/statHighlight.ts +39 -22
  171. package/src/doc/templates/titleBlock.ts +59 -21
  172. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +487 -0
  173. package/src/doc/templates/tokens/resolveTokens.ts +453 -0
  174. package/src/doc/templates/twoColumn.ts +48 -30
  175. package/src/doc/templates/videoPullQuote.ts +22 -12
  176. package/src/doc/templates/videoWithCaption.ts +21 -15
  177. package/src/doc/utils/animationUtils.ts +267 -3
  178. package/src/doc/utils/applyRenderStyle.ts +113 -0
  179. package/src/doc/utils/imageTreatment.ts +70 -0
  180. package/src/doc/utils/nearest.ts +59 -0
  181. package/src/doc/utils/shapeGeometry.ts +464 -0
  182. package/src/doc/utils/themeUtils.ts +75 -1
  183. package/src/doc/validate.ts +575 -0
  184. package/src/generate/contentExtractor.ts +148 -35
  185. package/src/generate/index.ts +0 -3
  186. package/src/icons/index.ts +8 -0
  187. package/src/icons/inlineIconMarker.ts +73 -0
  188. package/src/imageEdit/export.ts +85 -2
  189. package/src/imageEdit/index.ts +1 -0
  190. package/src/imageEdit/persistence.ts +51 -0
  191. package/src/index.ts +2 -0
  192. package/src/jsonForm/index.ts +2 -0
  193. package/src/jsonForm/tokens.ts +78 -0
  194. package/src/markdown/annotationCoercion.ts +295 -0
  195. package/src/markdown/attrTokens.ts +241 -0
  196. package/src/markdown/convert.ts +229 -26
  197. package/src/markdown/index.ts +37 -0
  198. package/src/markdown/sanitize.ts +318 -0
  199. package/src/markdown/stringify.ts +96 -6
  200. package/src/markdown/types.ts +66 -0
  201. package/src/markdown/utils.ts +111 -2
  202. package/src/recommend/templates.ts +24 -0
  203. package/src/schemas/BlockTemplates.ts +133 -4
  204. package/src/schemas/CustomTemplates.ts +126 -0
  205. package/src/schemas/Doc.ts +387 -65
  206. package/src/schemas/ImageEditDoc.ts +11 -4
  207. package/src/schemas/Media.ts +169 -0
  208. package/src/schemas/Theme.ts +23 -1
  209. package/src/schemas/Transitions.ts +224 -0
  210. package/src/schemas/colorUtils.ts +33 -0
  211. package/src/schemas/fontStacks.ts +5 -2
  212. package/src/schemas/index.ts +3 -0
  213. package/src/schemas/themeCompile.ts +24 -5
  214. package/src/schemas/themeValidator.ts +49 -11
  215. package/src/schemas/themes/cinematic.json +69 -12
  216. package/src/schemas/themes/documentary.json +58 -11
  217. package/src/schemas/themes/gezellig.json +60 -11
  218. package/src/schemas/themes/magazine.json +54 -9
  219. package/src/schemas/themes/morning-light.json +52 -9
  220. package/src/schemas/themes/standard-dark.json +48 -13
  221. package/src/schemas/themes/standard.json +44 -12
  222. package/src/schemas/themes/tech-dark.json +65 -12
  223. package/src/spatial/Geohash.ts +52 -21
  224. package/src/storage/ContentContainer.ts +6 -2
  225. package/src/transform/applyTransform.ts +43 -4
  226. package/src/transform/index.ts +1 -0
  227. package/src/transform/registry.ts +44 -13
  228. package/src/transform/styles/dataDriven.ts +2 -1
  229. package/src/transform/styles/documentary.ts +2 -1
  230. package/src/transform/styles/magazine.ts +3 -1
  231. package/src/transform/styles/minimal.ts +2 -1
  232. package/src/transform/styles/narrative.ts +3 -1
  233. package/src/transform/templateSelector.ts +87 -11
  234. package/src/transform/types.ts +35 -2
  235. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  236. package/dist/chunk-3E5F2XMR.js.map +0 -1
  237. package/dist/chunk-3K5OG6KI.js.map +0 -1
  238. package/dist/chunk-4X3JQXNM.js +0 -35
  239. package/dist/chunk-4X3JQXNM.js.map +0 -1
  240. package/dist/chunk-57CVQCFX.js.map +0 -1
  241. package/dist/chunk-5WFRKURX.js.map +0 -1
  242. package/dist/chunk-7UDSRZKG.js.map +0 -1
  243. package/dist/chunk-B4EEON3N.js.map +0 -1
  244. package/dist/chunk-FR2RBTKO.js.map +0 -1
  245. package/dist/chunk-H3AXU4MJ.js +0 -179
  246. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  247. package/dist/chunk-K32VJONL.js.map +0 -1
  248. package/dist/chunk-KSWJR66U.js +0 -3706
  249. package/dist/chunk-KSWJR66U.js.map +0 -1
  250. package/dist/chunk-MYIH7FWD.js +0 -145
  251. package/dist/chunk-MYIH7FWD.js.map +0 -1
  252. package/dist/chunk-ZTX4PFFT.js.map +0 -1
  253. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  254. package/src/generate/slideshowGenerator.ts +0 -273
@@ -22,7 +22,16 @@
22
22
  * ```
23
23
  */
24
24
 
25
- import type { Doc, Block, CaptionTrack, CaptionPhrase, StartBlockConfig } from '../schemas/Doc.js';
25
+ import type {
26
+ Doc,
27
+ Block,
28
+ CaptionTrack,
29
+ CaptionPhrase,
30
+ StartBlockConfig,
31
+ DocDiagnostic,
32
+ } from '../schemas/Doc.js';
33
+ import { readCustomTemplatesFromFrontmatter } from './customTemplatesFrontmatter.js';
34
+ import { readCustomThemesFromFrontmatter } from './customThemesFrontmatter.js';
26
35
  import type {
27
36
  MarkdownDocument,
28
37
  MarkdownBlockNode,
@@ -31,8 +40,16 @@ import type {
31
40
  HtmlNode,
32
41
  } from '../markdown/types.js';
33
42
  import { extractPlainText } from '../markdown/utils.js';
43
+ import { coerceAnnotationValues, type CoercedBlockMeta } from '../markdown/annotationCoercion.js';
34
44
  import { estimateReadingTime } from '../timing/readingTime.js';
35
- import { resolveTemplateName } from './templates/index.js';
45
+ import { resolveTemplateName, isContainerTemplate } from './templates/index.js';
46
+ import { isDataFence, parseDataFence, findFirstTable, extractTableData } from './structuredData.js';
47
+ import { extractMediaFromContents } from './mediaAnnotations.js';
48
+ import { extractTemplateBlocksFromContents } from './annotationBlocks.js';
49
+ import type { ParsedAnnotation } from './standaloneAnnotation.js';
50
+ import { profileBlockContents, pickAutoTemplate } from '../recommend/templates.js';
51
+ import { deriveTemplateInputs } from './templateInputs.js';
52
+ import type { MediaClip } from '../schemas/Media.js';
36
53
 
37
54
  // ============================================
38
55
  // Options
@@ -61,6 +78,25 @@ export interface MarkdownToDocOptions {
61
78
  * is used as the hero. Set to false to suppress automatic cover generation.
62
79
  */
63
80
  generateCoverBlock?: boolean;
81
+
82
+ /**
83
+ * Timestamp recorded as `captions.generatedAt`. When omitted, the field
84
+ * is left unset so that conversion is fully deterministic — the same
85
+ * markdown always converts to an identical Doc (important for snapshot
86
+ * tests, caching, and agents that verify their output by re-converting).
87
+ */
88
+ captionsGeneratedAt?: string;
89
+
90
+ /**
91
+ * Content-aware template auto-picking for unannotated headings
92
+ * (default: true). When a heading block carries a strong content signal
93
+ * — a table, images, a blockquote, a list, a stat-looking line — the
94
+ * matching template is applied (with inputs derived from the body)
95
+ * instead of the structural `defaultTemplate`. Explicit `{[template]}`
96
+ * annotations always win. Authors can also disable per document with
97
+ * frontmatter `squisq-auto-templates: false`.
98
+ */
99
+ autoTemplates?: boolean;
64
100
  }
65
101
 
66
102
  // ============================================
@@ -90,12 +126,14 @@ function slugify(text: string): string {
90
126
 
91
127
  /**
92
128
  * Creates an ID generator that produces unique slugified IDs.
93
- * Appends -2, -3, etc. for duplicate headings.
129
+ * Appends -2, -3, etc. for duplicate headings. `reserve()` registers an
130
+ * author-pinned `{#id}` so a later heading whose slug matches doesn't
131
+ * collide with it.
94
132
  */
95
133
  function createIdGenerator() {
96
134
  const used = new Map<string, number>();
97
135
 
98
- return (heading: MarkdownHeading, _index: number): string => {
136
+ const generate = (heading: MarkdownHeading, _index: number): string => {
99
137
  const text = extractPlainText(heading);
100
138
  const base = slugify(text);
101
139
  const count = used.get(base) ?? 0;
@@ -104,6 +142,22 @@ function createIdGenerator() {
104
142
  if (count === 0) return base;
105
143
  return `${base}-${count + 1}`;
106
144
  };
145
+
146
+ generate.reserve = (id: string): void => {
147
+ used.set(id, Math.max(used.get(id) ?? 0, 1));
148
+ };
149
+
150
+ // Slug + dedupe from arbitrary text (used for heading-less standalone
151
+ // annotation blocks, which have no MarkdownHeading to slug from). Shares
152
+ // the same `used` map so a standalone id can't collide with a heading id.
153
+ generate.fromText = (text: string): string => {
154
+ const base = slugify(text);
155
+ const count = used.get(base) ?? 0;
156
+ used.set(base, count + 1);
157
+ return count === 0 ? base : `${base}-${count + 1}`;
158
+ };
159
+
160
+ return generate;
107
161
  }
108
162
 
109
163
  // ============================================
@@ -125,13 +179,85 @@ function createIdGenerator() {
125
179
  * @param options - Conversion options
126
180
  * @returns A Doc whose blocks mirror the markdown heading structure
127
181
  */
182
+ /**
183
+ * Apply coerced block-meta values onto a block's typed fields. Only keys
184
+ * actually present are written, so a later caller (the Pandoc attribute
185
+ * block) can override values supplied by the squiggly `{[…]}` annotation.
186
+ */
187
+ function applyBlockMeta(block: Block, m: CoercedBlockMeta): void {
188
+ if (m.x != null) block.x = m.x;
189
+ if (m.y != null) block.y = m.y;
190
+ if (m.startTime != null) block.startTime = m.startTime;
191
+ if (m.duration != null) block.duration = m.duration;
192
+ if (m.connectsTo) block.connectsTo = m.connectsTo;
193
+ if (m.transition) block.transition = m.transition;
194
+ }
195
+
196
+ /**
197
+ * The block-meta an author pinned on a heading, drawn from either the
198
+ * Pandoc `{key=value}` block or the squiggly `{[key=value]}` annotation
199
+ * (Pandoc wins on conflict — same precedence as {@link makeBlock}). Used by
200
+ * the timing passes to tell "author pinned this" from "derive it".
201
+ */
202
+ function pinnedHeadingMeta(heading: MarkdownHeading | undefined): CoercedBlockMeta {
203
+ if (!heading) return {};
204
+ const fromAnnotation = heading.templateAnnotation?.params
205
+ ? coerceAnnotationValues(heading.templateAnnotation.params).blockMeta
206
+ : {};
207
+ const fromPandoc = heading.attributes?.blockMeta ?? {};
208
+ return { ...fromAnnotation, ...fromPandoc };
209
+ }
210
+
211
+ /**
212
+ * Pinned block-meta for any block: the heading pins ({@link pinnedHeadingMeta})
213
+ * for heading blocks, or the standalone-annotation params for heading-less
214
+ * standalone blocks. Lets a standalone `{[quote duration=8]}` pin timing the
215
+ * same way a heading annotation would.
216
+ */
217
+ function pinnedMeta(block: Block): CoercedBlockMeta {
218
+ if (block.sourceHeading) return pinnedHeadingMeta(block.sourceHeading);
219
+ if (block.sourceAnnotation?.params) {
220
+ return coerceAnnotationValues(block.sourceAnnotation.params).blockMeta;
221
+ }
222
+ return {};
223
+ }
224
+
225
+ /**
226
+ * Rebuild a block list, lifting standalone `{[…]}` template annotations out of
227
+ * each block's body into heading-less sibling blocks (recursing into children
228
+ * first). See {@link extractTemplateBlocksFromContents}.
229
+ */
230
+ function expandStandaloneAnnotations(
231
+ blocks: Block[],
232
+ makeId: (parsed: ParsedAnnotation) => string,
233
+ defaultDuration: number,
234
+ ): Block[] {
235
+ const out: Block[] = [];
236
+ for (const block of blocks) {
237
+ if (block.children && block.children.length > 0) {
238
+ block.children = expandStandaloneAnnotations(block.children, makeId, defaultDuration);
239
+ }
240
+ const { leading, blocks: produced } = extractTemplateBlocksFromContents(block.contents, {
241
+ makeId,
242
+ defaultDuration,
243
+ });
244
+ if (produced.length > 0) {
245
+ block.contents = leading;
246
+ }
247
+ out.push(block, ...produced);
248
+ }
249
+ return out;
250
+ }
251
+
128
252
  export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownToDocOptions): Doc {
129
253
  const articleId = options?.articleId ?? 'markdown-doc';
130
254
  const defaultTemplate = options?.defaultTemplate ?? 'sectionHeader';
131
255
  const defaultDuration = options?.defaultDuration ?? 5;
132
- const generateId = options?.generateId ?? createIdGenerator();
256
+ const idGenerator = options?.generateId ? null : createIdGenerator();
257
+ const generateId = options?.generateId ?? idGenerator!;
133
258
 
134
- const rootBlocks: Block[] = [];
259
+ let rootBlocks: Block[] = [];
260
+ const diagnostics: DocDiagnostic[] = [];
135
261
  let headingIndex = 0;
136
262
 
137
263
  // Stack tracks the nesting context: each entry is a block and its heading depth.
@@ -151,7 +277,14 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
151
277
  }
152
278
 
153
279
  function makeBlock(heading: MarkdownHeading | null): Block {
154
- const id = heading ? generateId(heading, headingIndex++) : 'preamble';
280
+ // Pandoc `{#id}` overrides slug-from-heading when present. Pinned ids
281
+ // are reserved in the slug generator so a later un-pinned heading with
282
+ // the same slugified text doesn't collide.
283
+ const pandocId = heading?.attributes?.id;
284
+ const id = pandocId ? pandocId : heading ? generateId(heading, headingIndex++) : 'preamble';
285
+ if (pandocId && idGenerator) {
286
+ idGenerator.reserve(pandocId);
287
+ }
155
288
 
156
289
  // Use template from annotation if present, otherwise fall back to default.
157
290
  // Legacy template ids (e.g. `titleBlock`) are normalized to their canonical
@@ -175,9 +308,29 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
175
308
  ...(title ? { title } : {}),
176
309
  };
177
310
 
178
- // Propagate key-value params from annotation to templateOverrides
311
+ // {[…]} template params templateOverrides (template-specific overrides).
312
+ // Known block-meta keys (duration, startTime, x, y, connectsTo) are *also*
313
+ // honored in the squiggly form, so an author — or the timeline editor —
314
+ // can write `{[duration=8]}` instead of the Pandoc `{duration=8}` block.
315
+ // The raw params still flow to `templateOverrides` for round-tripping; the
316
+ // Pandoc attribute block applied below wins when a key appears in both.
179
317
  if (annotation?.params) {
180
318
  block.templateOverrides = annotation.params;
319
+ applyBlockMeta(block, coerceAnnotationValues(annotation.params).blockMeta);
320
+ }
321
+
322
+ // Pandoc {#id .class key=value} attributes → block-level fields.
323
+ const attrs = heading?.attributes;
324
+ if (attrs) {
325
+ if (attrs.blockMeta) {
326
+ applyBlockMeta(block, attrs.blockMeta);
327
+ }
328
+ if (attrs.classes && attrs.classes.length > 0) {
329
+ block.classes = attrs.classes;
330
+ }
331
+ if (attrs.metadata && Object.keys(attrs.metadata).length > 0) {
332
+ block.metadata = attrs.metadata;
333
+ }
181
334
  }
182
335
 
183
336
  return block;
@@ -242,13 +395,124 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
242
395
  rootBlocks.push(currentBlock);
243
396
  }
244
397
 
245
- // Calculate reading-time-based durations and generate captions
398
+ // Lift standalone body-level media annotations (`{[audio …]}` / `{[video …]}`)
399
+ // into typed clips: block-anchored clips ride on `block.media`; clips flagged
400
+ // `anchor=document` collect into the doc-level `documentMedia`. Done before
401
+ // the reading-time pass so annotation text doesn't inflate block durations.
402
+ const documentMedia: MediaClip[] = [];
403
+ for (const block of flattenBlocks(rootBlocks)) {
404
+ const {
405
+ media,
406
+ documentMedia: docMedia,
407
+ remaining,
408
+ } = extractMediaFromContents(
409
+ block.contents,
410
+ (src, i) => `${block.id}-media-${i}-${src.replace(/[^a-zA-Z0-9]+/g, '-')}`,
411
+ );
412
+ if (media.length > 0) block.media = media;
413
+ if (docMedia.length > 0) documentMedia.push(...docMedia);
414
+ if (block.contents) block.contents = remaining;
415
+ }
416
+
417
+ // A preamble block that held only document-media annotations is now empty —
418
+ // drop it so a doc-spanning narration doesn't leave a phantom 5s block at the
419
+ // top of the timeline. Only the heading-less preamble (rootBlocks[0]) can be
420
+ // emptied this way.
421
+ if (
422
+ rootBlocks.length > 0 &&
423
+ !rootBlocks[0].sourceHeading &&
424
+ (rootBlocks[0].contents?.length ?? 0) === 0 &&
425
+ (rootBlocks[0].children?.length ?? 0) === 0 &&
426
+ !rootBlocks[0].media
427
+ ) {
428
+ rootBlocks.shift();
429
+ }
430
+
431
+ // Standalone `{[templateName …]}` paragraphs (non-media) become heading-less
432
+ // template blocks; the body after each one, up to the next annotation,
433
+ // becomes its `contents`. Done after media extraction (so media names keep
434
+ // their meaning) and before structured-data parsing (so `json data` fences
435
+ // attach to the produced blocks). Produced blocks are inserted as siblings
436
+ // following the block whose body held the annotation.
437
+ const makeStandaloneId = (parsed: ParsedAnnotation): string => {
438
+ const base = parsed.params.title ?? parsed.template ?? 'block';
439
+ return idGenerator ? idGenerator.fromText(base) : slugify(base);
440
+ };
441
+ rootBlocks = expandStandaloneAnnotations(rootBlocks, makeStandaloneId, defaultDuration);
442
+ // A leading preamble whose entire body was consumed into standalone blocks
443
+ // is now empty — drop it so it doesn't leave a phantom slide.
444
+ if (
445
+ rootBlocks.length > 0 &&
446
+ !rootBlocks[0].sourceHeading &&
447
+ !rootBlocks[0].standaloneAnnotation &&
448
+ (rootBlocks[0].contents?.length ?? 0) === 0 &&
449
+ (rootBlocks[0].children?.length ?? 0) === 0 &&
450
+ !rootBlocks[0].media
451
+ ) {
452
+ rootBlocks.shift();
453
+ }
454
+
246
455
  const allBlocks = flattenBlocks(rootBlocks);
456
+
457
+ // Structured template data: ```json data / ```yaml data fences in a
458
+ // block's body parse into `templateData`; for dataTable blocks the first
459
+ // GFM table supplies headers/rows when not explicitly provided. Parse
460
+ // failures degrade gracefully (the fence stays visible as code) and are
461
+ // recorded as diagnostics.
462
+ for (const block of allBlocks) {
463
+ applyStructuredData(block, diagnostics);
464
+ }
465
+
466
+ // Content-aware template auto-pick (default on): unannotated heading
467
+ // blocks whose body carries a strong signal get the matching template
468
+ // plus inputs derived from that body. Strict derivation — when the
469
+ // essential input can't be built (e.g. feature without an image src),
470
+ // the block keeps the structural default.
471
+ if ((options?.autoTemplates ?? true) && !frontmatterDisablesAutoTemplates(markdownDoc)) {
472
+ applyAutoTemplates(rootBlocks, resolveTemplateName(defaultTemplate), { featureIndex: 0 });
473
+ }
474
+
475
+ // Duplicate ids make connections and navigation ambiguous. Generated
476
+ // slugs are already deduped; this catches author-pinned `{#id}` clashes.
477
+ const seenIds = new Map<string, Block>();
478
+ for (const block of allBlocks) {
479
+ if (seenIds.has(block.id)) {
480
+ diagnostics.push({
481
+ severity: 'error',
482
+ code: 'duplicate-id',
483
+ message: `Duplicate block id "${block.id}" — later connections and links are ambiguous`,
484
+ blockId: block.id,
485
+ ...lineOf(block),
486
+ });
487
+ } else {
488
+ seenIds.set(block.id, block);
489
+ }
490
+ }
491
+
492
+ // connectsTo targets must resolve to a block id in this doc.
493
+ for (const block of allBlocks) {
494
+ for (const conn of block.connectsTo ?? []) {
495
+ if (!seenIds.has(conn.target)) {
496
+ diagnostics.push({
497
+ severity: 'warning',
498
+ code: 'unresolved-connection',
499
+ message: `Block "${block.id}" connects to unknown target "${conn.target}"`,
500
+ blockId: block.id,
501
+ ...lineOf(block),
502
+ });
503
+ }
504
+ }
505
+ }
506
+
507
+ // Calculate reading-time-based durations and generate captions
247
508
  const minDuration = 3; // seconds — minimum for blocks with little/no text
248
509
  const phrases: CaptionPhrase[] = [];
249
510
 
250
- // First pass: compute duration from body-content reading time
511
+ // First pass: compute duration from body-content reading time.
512
+ // Skip blocks that pinned an explicit duration via a heading attribute —
513
+ // author intent wins over reading-time heuristics.
251
514
  for (const block of allBlocks) {
515
+ if (pinnedMeta(block).duration != null) continue;
252
516
  const bodyText = getBlockBodyText(block);
253
517
  if (bodyText.length > 0) {
254
518
  const estimate = estimateReadingTime(bodyText);
@@ -258,10 +522,16 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
258
522
  }
259
523
  }
260
524
 
261
- // Second pass: assign start times sequentially and build caption phrases
525
+ // Second pass: assign start times sequentially and build caption phrases.
526
+ // Blocks with an explicit `startTime` attribute keep their pinned value;
527
+ // the running cursor still advances by their duration so following blocks
528
+ // sequence after them.
262
529
  let currentTime = 0;
263
530
  for (const block of allBlocks) {
264
- block.startTime = currentTime;
531
+ const explicitStart = pinnedMeta(block).startTime;
532
+ if (explicitStart == null) {
533
+ block.startTime = currentTime;
534
+ }
265
535
 
266
536
  // Generate caption phrases from the block's body content
267
537
  const bodyText = getBlockBodyText(block);
@@ -283,9 +553,20 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
283
553
  currentTime += block.duration;
284
554
  }
285
555
 
556
+ // `generatedAt` is only stamped when the caller provides a timestamp —
557
+ // conversion itself never reads the clock, so identical markdown always
558
+ // converts to an identical Doc.
286
559
  const captions: CaptionTrack | undefined =
287
- phrases.length > 0 ? { phrases, generatedAt: new Date().toISOString(), version: 1 } : undefined;
560
+ phrases.length > 0
561
+ ? {
562
+ phrases,
563
+ ...(options?.captionsGeneratedAt ? { generatedAt: options.captionsGeneratedAt } : {}),
564
+ version: 1,
565
+ }
566
+ : undefined;
288
567
 
568
+ const customTemplates = readCustomTemplatesFromFrontmatter(markdownDoc.frontmatter);
569
+ const customThemes = readCustomThemesFromFrontmatter(markdownDoc.frontmatter);
289
570
  const doc: Doc = {
290
571
  articleId,
291
572
  duration: currentTime,
@@ -295,6 +576,10 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
295
576
  },
296
577
  ...(captions ? { captions } : {}),
297
578
  ...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
579
+ ...(customTemplates ? { customTemplates } : {}),
580
+ ...(customThemes ? { customThemes } : {}),
581
+ ...(diagnostics.length > 0 ? { diagnostics } : {}),
582
+ ...(documentMedia.length > 0 ? { documentMedia } : {}),
298
583
  };
299
584
 
300
585
  // Auto-generate cover startBlock from the first H1 heading
@@ -327,6 +612,29 @@ export function flattenBlocks(blocks: Block[]): Block[] {
327
612
  return result;
328
613
  }
329
614
 
615
+ /**
616
+ * Flatten the block tree into the list of *independently renderable*
617
+ * blocks — like {@link flattenBlocks}, but it does NOT descend into the
618
+ * children of a container template (`diagram`, `drawing`; see
619
+ * `isContainerTemplate`). Those children are consumed by the parent's
620
+ * render (as nodes / shapes), so they must not also surface as their own
621
+ * slides or sections.
622
+ *
623
+ * Use this anywhere a doc is flattened for rendering (slideshow, video,
624
+ * static pages). Use {@link flattenBlocks} when you genuinely need every
625
+ * block regardless of role — validation, timing, duplicate-id checks.
626
+ */
627
+ export function flattenRenderableBlocks(blocks: Block[]): Block[] {
628
+ const result: Block[] = [];
629
+ for (const block of blocks) {
630
+ result.push(block);
631
+ if (block.children && !isContainerTemplate(block.template)) {
632
+ result.push(...flattenRenderableBlocks(block.children));
633
+ }
634
+ }
635
+ return result;
636
+ }
637
+
330
638
  /**
331
639
  * Count the total number of blocks in a nested tree (including children at all levels).
332
640
  */
@@ -352,6 +660,96 @@ export function getBlockDepth(block: Block): number {
352
660
  // Internal helpers
353
661
  // ============================================
354
662
 
663
+ /** Source line of a block's heading, when position info is available. */
664
+ function lineOf(block: Block): { line: number } | Record<string, never> {
665
+ const line = block.sourceHeading?.position?.start.line;
666
+ return line != null ? { line } : {};
667
+ }
668
+
669
+ /**
670
+ * Populate `block.templateData` from structured body content:
671
+ * 1. Every ```json data / ```yaml data fence in the body merges its parsed
672
+ * object in (later fences override earlier keys).
673
+ * 2. For `dataTable` blocks, the first GFM table supplies headers/rows/align
674
+ * unless the author already provided them via a fence or `{[…]}` params.
675
+ * Parse failures are recorded on `diagnostics` and skip the fence.
676
+ */
677
+ function applyStructuredData(block: Block, diagnostics: DocDiagnostic[]): void {
678
+ let data: Record<string, unknown> | undefined;
679
+
680
+ for (const node of block.contents ?? []) {
681
+ if (node.type !== 'code' || !isDataFence(node)) continue;
682
+ const result = parseDataFence(node);
683
+ if (result.error) {
684
+ diagnostics.push({
685
+ severity: 'error',
686
+ code: 'data-fence-parse',
687
+ message: `Data fence in block "${block.id}": ${result.error}`,
688
+ blockId: block.id,
689
+ ...(node.position ? { line: node.position.start.line } : lineOf(block)),
690
+ });
691
+ continue;
692
+ }
693
+ data = { ...data, ...result.data };
694
+ }
695
+
696
+ if (block.template === 'dataTable') {
697
+ const provided = (key: string) =>
698
+ (data && key in data) || (block.templateOverrides && key in block.templateOverrides);
699
+ if (!provided('headers') && !provided('rows')) {
700
+ const table = findFirstTable(block.contents);
701
+ if (table) {
702
+ data = { ...extractTableData(table), ...data };
703
+ }
704
+ }
705
+ }
706
+
707
+ if (data && Object.keys(data).length > 0) {
708
+ block.templateData = data;
709
+ }
710
+ }
711
+
712
+ /** Truthiness of the `squisq-auto-templates` frontmatter kill-switch. */
713
+ function frontmatterDisablesAutoTemplates(markdownDoc: MarkdownDocument): boolean {
714
+ const v = markdownDoc.frontmatter?.['squisq-auto-templates'];
715
+ return v === false || v === 'false' || v === 'off' || v === 'no' || v === 0;
716
+ }
717
+
718
+ /**
719
+ * Walk heading blocks and apply content-aware templates to the ones that
720
+ * still hold the structural default. Children of container templates
721
+ * (diagram/drawing/layout) are consumed by their parent and never
722
+ * re-templated. `state.featureIndex` alternates left/right feature
723
+ * composition across the document.
724
+ */
725
+ function applyAutoTemplates(
726
+ blocks: Block[],
727
+ resolvedDefault: string,
728
+ state: { featureIndex: number },
729
+ ): void {
730
+ for (const block of blocks) {
731
+ const annotated = !!block.sourceHeading?.templateAnnotation?.template;
732
+ if (block.sourceHeading && !annotated && block.template === resolvedDefault) {
733
+ const profile = profileBlockContents(block.contents ?? []);
734
+ const picked = pickAutoTemplate(profile, state.featureIndex);
735
+ if (picked) {
736
+ const inputs = deriveTemplateInputs(picked, block.title ?? '', block.contents);
737
+ if (inputs) {
738
+ if (picked === 'leftFeature' || picked === 'rightFeature') state.featureIndex += 1;
739
+ block.template = picked;
740
+ block.autoTemplate = true;
741
+ // Author-provided structured data (```json data fences) wins
742
+ // over derived inputs.
743
+ block.templateData = { ...inputs, ...block.templateData };
744
+ }
745
+ }
746
+ }
747
+ if (block.children && block.children.length > 0 && !isContainerTemplate(block.template)) {
748
+ applyAutoTemplates(block.children, resolvedDefault, state);
749
+ }
750
+ }
751
+ }
752
+
355
753
  /**
356
754
  * Extract the plain text from a block's body contents (excluding heading text).
357
755
  */
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Media-annotation extraction.
3
+ *
4
+ * Lifts standalone body-level media annotations — `{[audio …]}` / `{[video …]}`
5
+ * — out of a block's markdown contents into typed {@link MediaClip}s. A block
6
+ * can hold several clips (unlike the single heading annotation). An annotation
7
+ * flagged `anchor=document` (or `span=document`) becomes a document-spanning
8
+ * clip instead. The annotation grammar is the shared one used for heading
9
+ * annotations, so quoting/escaping behave identically.
10
+ */
11
+
12
+ import { parseTimeSeconds } from '../markdown/annotationCoercion.js';
13
+ import type { MarkdownBlockNode } from '../markdown/types.js';
14
+ import type { MediaClip } from '../schemas/Media.js';
15
+ import { parseStandaloneAnnotation, type ParsedAnnotation } from './standaloneAnnotation.js';
16
+
17
+ /** Annotation template names that produce a timed media clip. */
18
+ const MEDIA_TEMPLATES = new Set(['audio', 'video', 'media']);
19
+
20
+ /**
21
+ * Whether a standalone-annotation template name is a media name
22
+ * (`audio`/`video`/`media`). The standalone template-block extractor uses
23
+ * this to leave media annotations to {@link extractMediaFromContents}.
24
+ */
25
+ export function isMediaAnnotationName(name: string | undefined): boolean {
26
+ return name != null && MEDIA_TEMPLATES.has(name);
27
+ }
28
+
29
+ function time(raw: string | undefined): number | null {
30
+ if (raw == null) return null;
31
+ return parseTimeSeconds(raw);
32
+ }
33
+
34
+ /** Build a {@link MediaClip} from a parsed media annotation, or null if invalid. */
35
+ function toMediaClip(parsed: ParsedAnnotation, id: string): MediaClip | null {
36
+ const { template, params } = parsed;
37
+ if (!template || !MEDIA_TEMPLATES.has(template)) return null;
38
+ const src = params.src;
39
+ if (!src) return null;
40
+
41
+ const kind: MediaClip['kind'] = template === 'video' ? 'video' : 'audio';
42
+ const anchor: MediaClip['anchor'] =
43
+ params.anchor === 'document' || params.span === 'document' ? 'document' : 'block';
44
+ // `startAt` is canonical; `startTime` is accepted as an alias (it is the
45
+ // heading-attribute spelling, so authors reach for it here too).
46
+ const clip: MediaClip = {
47
+ id,
48
+ src,
49
+ kind,
50
+ startAt: time(params.startAt ?? params.startTime) ?? 0,
51
+ anchor,
52
+ };
53
+
54
+ const clipStart = time(params.clipStart);
55
+ const clipEnd = time(params.clipEnd);
56
+ if (clipStart != null) clip.clipStart = clipStart;
57
+ if (clipEnd != null) clip.clipEnd = clipEnd;
58
+ // `spillover` or `spillover=true` → true; absent/`false` → omitted.
59
+ if (params.spillover === 'true' || params.spillover === '') clip.spillover = true;
60
+
61
+ return clip;
62
+ }
63
+
64
+ export interface MediaExtraction {
65
+ /** Block-anchored clips (anchor='block'). */
66
+ media: MediaClip[];
67
+ /** Document-spanning clips (anchor='document'). */
68
+ documentMedia: MediaClip[];
69
+ /** Contents with the lifted media-annotation paragraphs removed. */
70
+ remaining: MarkdownBlockNode[];
71
+ }
72
+
73
+ /**
74
+ * Walk `contents`, lifting every standalone media annotation into a
75
+ * {@link MediaClip}. `makeId(src, index)` produces stable clip ids.
76
+ */
77
+ export function extractMediaFromContents(
78
+ contents: MarkdownBlockNode[] | undefined,
79
+ makeId: (src: string, index: number) => string,
80
+ ): MediaExtraction {
81
+ const media: MediaClip[] = [];
82
+ const documentMedia: MediaClip[] = [];
83
+ const remaining: MarkdownBlockNode[] = [];
84
+ let index = 0;
85
+
86
+ for (const node of contents ?? []) {
87
+ const parsed = parseStandaloneAnnotation(node);
88
+ const clip = parsed ? toMediaClip(parsed, '') : null;
89
+ if (parsed && clip) {
90
+ clip.id = makeId(clip.src, index++);
91
+ const line = node.position?.start.line;
92
+ if (line != null) clip.sourceLine = line;
93
+ (clip.anchor === 'document' ? documentMedia : media).push(clip);
94
+ } else {
95
+ remaining.push(node);
96
+ }
97
+ }
98
+
99
+ return { media, documentMedia, remaining };
100
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Doc-scoped theme resolution.
3
+ *
4
+ * The theme analog of how `buildRegistry` resolves custom templates: pure and
5
+ * doc-scoped, with no global state. `resolveThemeForDoc` looks up the active
6
+ * theme id in the doc's own custom themes first, then falls back to the
7
+ * built-in / globally-registered themes via `resolveTheme`.
8
+ *
9
+ * It accepts either a squisq `Doc` (which carries a typed `customThemes`
10
+ * array) or anything with raw `frontmatter` — notably a `MarkdownDocument`,
11
+ * which is the shape every export pipeline pivots through. In the latter case
12
+ * the inline themes are decoded from the `squisq-custom-themes` frontmatter
13
+ * payload. Either way, resolution needs no `registerTheme` call — an inline
14
+ * custom theme ships with the doc, exactly like custom templates.
15
+ */
16
+
17
+ import type { Theme } from '../schemas/Theme.js';
18
+ import { resolveTheme } from '../schemas/themeLibrary.js';
19
+ import { readFrontmatterThemeId } from '../markdown/utils.js';
20
+ import { readCustomThemesFromFrontmatter } from './customThemesFrontmatter.js';
21
+
22
+ /**
23
+ * The minimal shape `resolveThemeForDoc` needs. Satisfied by both the squisq
24
+ * `Doc` (typed `customThemes` + `themeId`) and a `MarkdownDocument` (raw
25
+ * `frontmatter` carrying the encoded payload).
26
+ */
27
+ export interface ThemeResolvable {
28
+ themeId?: string;
29
+ customThemes?: Theme[];
30
+ frontmatter?: Record<string, unknown>;
31
+ }
32
+
33
+ /**
34
+ * Resolve the Theme a document should render with.
35
+ *
36
+ * @param doc - the document (may carry inline custom themes plus a `themeId`
37
+ * / `squisq-theme` frontmatter selector).
38
+ * @param explicitId - an id that overrides the doc's own selection (e.g. the
39
+ * editor's theme dropdown, or an export `--theme` option). When omitted, the
40
+ * doc's `themeId` / frontmatter theme id is used.
41
+ * @returns the matching inline custom theme (doc-scoped) when present, else
42
+ * the built-in / registered theme for that id, else the default theme.
43
+ */
44
+ export function resolveThemeForDoc(
45
+ doc: ThemeResolvable | null | undefined,
46
+ explicitId?: string,
47
+ ): Theme {
48
+ const id = explicitId ?? doc?.themeId ?? readFrontmatterThemeId(doc?.frontmatter);
49
+ // Prefer the already-parsed list on a squisq Doc; otherwise decode the
50
+ // frontmatter payload (the shape exports see on a MarkdownDocument).
51
+ const inline = doc?.customThemes ?? readCustomThemesFromFrontmatter(doc?.frontmatter);
52
+ const custom = id ? inline?.find((t) => t.id === id) : undefined;
53
+ if (custom) return custom;
54
+ return resolveTheme(id);
55
+ }