@bendyline/squisq 1.4.1 → 1.5.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 (216) hide show
  1. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BsMkmzH8.d.ts} +838 -268
  2. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-DvoeHMP9.d.ts} +11 -4
  3. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  4. package/dist/chunk-2OIBZYKP.js.map +1 -0
  5. package/dist/chunk-2UQOHYE2.js +45 -0
  6. package/dist/chunk-2UQOHYE2.js.map +1 -0
  7. package/dist/{chunk-B4EEON3N.js → chunk-4AKUQPEE.js} +536 -103
  8. package/dist/chunk-4AKUQPEE.js.map +1 -0
  9. package/dist/chunk-4ZFRZBU2.js +6582 -0
  10. package/dist/chunk-4ZFRZBU2.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-7MQGNR76.js} +79 -3
  12. package/dist/chunk-7MQGNR76.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-MYIH7FWD.js → chunk-FBKP5CAO.js} +1 -1
  16. package/dist/chunk-FBKP5CAO.js.map +1 -0
  17. package/dist/chunk-KZNXZ5SM.js +333 -0
  18. package/dist/chunk-KZNXZ5SM.js.map +1 -0
  19. package/dist/{chunk-WMOI7FN3.js → chunk-LRCV7N7F.js} +12 -2
  20. package/dist/{chunk-WMOI7FN3.js.map → chunk-LRCV7N7F.js.map} +1 -1
  21. package/dist/{chunk-3K5OG6KI.js → chunk-OH43RPTP.js} +403 -187
  22. package/dist/chunk-OH43RPTP.js.map +1 -0
  23. package/dist/{chunk-H3AXU4MJ.js → chunk-OKJOQXK4.js} +149 -2
  24. package/dist/chunk-OKJOQXK4.js.map +1 -0
  25. package/dist/chunk-PGGGKYWG.js +1 -0
  26. package/dist/chunk-PGGGKYWG.js.map +1 -0
  27. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  28. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  29. package/dist/chunk-QE7TWGVT.js +178 -0
  30. package/dist/chunk-QE7TWGVT.js.map +1 -0
  31. package/dist/chunk-RUDYOTA6.js +345 -0
  32. package/dist/chunk-RUDYOTA6.js.map +1 -0
  33. package/dist/chunk-TVSGLUID.js +596 -0
  34. package/dist/chunk-TVSGLUID.js.map +1 -0
  35. package/dist/chunk-ZLQKNOZQ.js +1 -0
  36. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  37. package/dist/doc/index.d.ts +4 -4
  38. package/dist/doc/index.js +124 -6
  39. package/dist/generate/index.d.ts +9 -2
  40. package/dist/generate/index.js +1 -1
  41. package/dist/icons/index.d.ts +3 -26
  42. package/dist/icons/index.js +13 -0
  43. package/dist/icons/inlineIconMarker.d.ts +1 -0
  44. package/dist/icons/inlineIconMarker.js +15 -0
  45. package/dist/icons/inlineIconMarker.js.map +1 -0
  46. package/dist/imageEdit/index.d.ts +11 -5
  47. package/dist/imageEdit/index.js +4 -1
  48. package/dist/index.d.ts +11 -8
  49. package/dist/index.js +243 -24
  50. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  51. package/dist/markdown/index.d.ts +212 -3
  52. package/dist/markdown/index.js +40 -7
  53. package/dist/recommend/index.d.ts +1 -1
  54. package/dist/recommend/index.js +3 -2
  55. package/dist/schemas/index.d.ts +28 -8
  56. package/dist/schemas/index.js +35 -5
  57. package/dist/spatial/index.d.ts +9 -4
  58. package/dist/spatial/index.js +1 -1
  59. package/dist/story/index.d.ts +906 -40
  60. package/dist/story/index.js +124 -6
  61. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-DQQKuRMx.d.ts} +1 -1
  62. package/dist/transform/index.d.ts +48 -10
  63. package/dist/transform/index.js +17 -481
  64. package/dist/transform/index.js.map +1 -1
  65. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  66. package/package.json +6 -1
  67. package/src/__tests__/animationUtils.test.ts +40 -0
  68. package/src/__tests__/annotationCoercion.test.ts +243 -0
  69. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  70. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  71. package/src/__tests__/attrTokens.test.ts +162 -0
  72. package/src/__tests__/customTemplateSample.test.ts +83 -0
  73. package/src/__tests__/customTemplatesFrontmatter.test.ts +205 -0
  74. package/src/__tests__/customThemesFrontmatter.test.ts +118 -0
  75. package/src/__tests__/diagramBlock.test.ts +122 -0
  76. package/src/__tests__/diagramLayout.test.ts +134 -0
  77. package/src/__tests__/drawing.test.ts +109 -0
  78. package/src/__tests__/drawingBlock.test.ts +124 -0
  79. package/src/__tests__/drawingLayout.test.ts +171 -0
  80. package/src/__tests__/geohash.test.ts +6 -5
  81. package/src/__tests__/getLayers.test.ts +6 -2
  82. package/src/__tests__/getLayersFallback.test.ts +103 -0
  83. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  84. package/src/__tests__/imageEditVersions.test.ts +72 -0
  85. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  86. package/src/__tests__/layoutBlock.test.ts +83 -0
  87. package/src/__tests__/layoutLayout.test.ts +169 -0
  88. package/src/__tests__/markdown.test.ts +23 -0
  89. package/src/__tests__/markdownSanitize.test.ts +64 -0
  90. package/src/__tests__/markdownToDoc.test.ts +223 -2
  91. package/src/__tests__/mediaAnnotations.test.ts +64 -0
  92. package/src/__tests__/mediaSchedule.test.ts +118 -0
  93. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  94. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  95. package/src/__tests__/rootExports.test.ts +43 -0
  96. package/src/__tests__/shapeGeometry.test.ts +89 -0
  97. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  98. package/src/__tests__/structuredData.test.ts +179 -0
  99. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  100. package/src/__tests__/templateMetadata.test.ts +21 -0
  101. package/src/__tests__/templates.test.ts +145 -4
  102. package/src/__tests__/themeCompile.test.ts +42 -0
  103. package/src/__tests__/themeValidator.test.ts +6 -0
  104. package/src/__tests__/transformV2.test.ts +137 -0
  105. package/src/__tests__/transitionNormalize.test.ts +61 -0
  106. package/src/__tests__/validateDoc.test.ts +100 -0
  107. package/src/doc/customTemplatesFrontmatter.ts +308 -0
  108. package/src/doc/customThemesFrontmatter.ts +90 -0
  109. package/src/doc/docToMarkdown.ts +73 -4
  110. package/src/doc/getLayers.ts +40 -17
  111. package/src/doc/index.ts +35 -1
  112. package/src/doc/markdownToDoc.ts +333 -12
  113. package/src/doc/mediaAnnotations.ts +118 -0
  114. package/src/doc/resolveDocTheme.ts +55 -0
  115. package/src/doc/structuredData.ts +230 -0
  116. package/src/doc/templateInputs.ts +240 -0
  117. package/src/doc/templates/__tests__/customTemplate.test.ts +151 -0
  118. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  119. package/src/doc/templates/accentImage.ts +30 -6
  120. package/src/doc/templates/captionUtils.ts +23 -0
  121. package/src/doc/templates/comparisonBar.ts +26 -18
  122. package/src/doc/templates/coverBlock.ts +7 -4
  123. package/src/doc/templates/customTemplate.ts +45 -0
  124. package/src/doc/templates/dataTable.ts +37 -19
  125. package/src/doc/templates/dateEvent.ts +41 -20
  126. package/src/doc/templates/definitionCard.ts +26 -18
  127. package/src/doc/templates/diagramBlock.ts +204 -0
  128. package/src/doc/templates/diagramLayout.ts +159 -0
  129. package/src/doc/templates/drawingBlock.ts +325 -0
  130. package/src/doc/templates/drawingLayout.ts +539 -0
  131. package/src/doc/templates/factCard.ts +51 -25
  132. package/src/doc/templates/fallbackBlock.ts +123 -0
  133. package/src/doc/templates/featureBlock.ts +20 -13
  134. package/src/doc/templates/fullBleedQuote.ts +18 -8
  135. package/src/doc/templates/imageWithCaption.ts +22 -9
  136. package/src/doc/templates/index.ts +215 -46
  137. package/src/doc/templates/layoutBlock.ts +43 -0
  138. package/src/doc/templates/layoutLayout.ts +243 -0
  139. package/src/doc/templates/listBlock.ts +56 -30
  140. package/src/doc/templates/mapBlock.ts +15 -12
  141. package/src/doc/templates/metadata.ts +120 -0
  142. package/src/doc/templates/persistentLayers.ts +129 -11
  143. package/src/doc/templates/photoGrid.ts +23 -12
  144. package/src/doc/templates/pullQuote.ts +30 -12
  145. package/src/doc/templates/quoteBlock.ts +54 -29
  146. package/src/doc/templates/sectionHeader.ts +25 -11
  147. package/src/doc/templates/statHighlight.ts +39 -22
  148. package/src/doc/templates/titleBlock.ts +59 -21
  149. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +221 -0
  150. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  151. package/src/doc/templates/twoColumn.ts +48 -30
  152. package/src/doc/templates/videoPullQuote.ts +22 -12
  153. package/src/doc/templates/videoWithCaption.ts +13 -8
  154. package/src/doc/utils/animationUtils.ts +267 -3
  155. package/src/doc/utils/applyRenderStyle.ts +113 -0
  156. package/src/doc/utils/imageTreatment.ts +70 -0
  157. package/src/doc/utils/shapeGeometry.ts +464 -0
  158. package/src/doc/utils/themeUtils.ts +75 -1
  159. package/src/doc/validate.ts +476 -0
  160. package/src/generate/slideshowGenerator.ts +7 -0
  161. package/src/icons/index.ts +8 -0
  162. package/src/icons/inlineIconMarker.ts +73 -0
  163. package/src/imageEdit/export.ts +85 -2
  164. package/src/imageEdit/index.ts +1 -0
  165. package/src/imageEdit/persistence.ts +51 -0
  166. package/src/index.ts +2 -0
  167. package/src/markdown/annotationCoercion.ts +295 -0
  168. package/src/markdown/attrTokens.ts +222 -0
  169. package/src/markdown/convert.ts +229 -26
  170. package/src/markdown/index.ts +36 -0
  171. package/src/markdown/sanitize.ts +298 -0
  172. package/src/markdown/stringify.ts +84 -3
  173. package/src/markdown/types.ts +66 -0
  174. package/src/markdown/utils.ts +49 -1
  175. package/src/recommend/templates.ts +24 -0
  176. package/src/schemas/BlockTemplates.ts +133 -4
  177. package/src/schemas/CustomTemplates.ts +74 -0
  178. package/src/schemas/Doc.ts +353 -29
  179. package/src/schemas/ImageEditDoc.ts +11 -4
  180. package/src/schemas/Media.ts +169 -0
  181. package/src/schemas/Theme.ts +23 -1
  182. package/src/schemas/Transitions.ts +224 -0
  183. package/src/schemas/colorUtils.ts +33 -0
  184. package/src/schemas/fontStacks.ts +5 -2
  185. package/src/schemas/index.ts +3 -0
  186. package/src/schemas/themeCompile.ts +23 -4
  187. package/src/schemas/themeValidator.ts +49 -11
  188. package/src/schemas/themes/cinematic.json +69 -12
  189. package/src/schemas/themes/documentary.json +58 -11
  190. package/src/schemas/themes/gezellig.json +60 -11
  191. package/src/schemas/themes/magazine.json +54 -9
  192. package/src/schemas/themes/morning-light.json +52 -9
  193. package/src/schemas/themes/standard-dark.json +48 -13
  194. package/src/schemas/themes/standard.json +44 -12
  195. package/src/schemas/themes/tech-dark.json +65 -12
  196. package/src/spatial/Geohash.ts +52 -21
  197. package/src/transform/applyTransform.ts +43 -4
  198. package/src/transform/index.ts +1 -0
  199. package/src/transform/registry.ts +44 -13
  200. package/src/transform/styles/dataDriven.ts +2 -1
  201. package/src/transform/styles/documentary.ts +2 -1
  202. package/src/transform/styles/magazine.ts +3 -1
  203. package/src/transform/styles/minimal.ts +2 -1
  204. package/src/transform/styles/narrative.ts +3 -1
  205. package/src/transform/templateSelector.ts +87 -11
  206. package/src/transform/types.ts +35 -2
  207. package/dist/chunk-3K5OG6KI.js.map +0 -1
  208. package/dist/chunk-57CVQCFX.js.map +0 -1
  209. package/dist/chunk-5WFRKURX.js.map +0 -1
  210. package/dist/chunk-7UDSRZKG.js.map +0 -1
  211. package/dist/chunk-B4EEON3N.js.map +0 -1
  212. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  213. package/dist/chunk-KSWJR66U.js +0 -3706
  214. package/dist/chunk-KSWJR66U.js.map +0 -1
  215. package/dist/chunk-MYIH7FWD.js.map +0 -1
  216. package/dist/chunk-ZTX4PFFT.js.map +0 -1
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Structured template data from markdown body content.
3
+ *
4
+ * Attribute strings on a heading annotation are fine for scalar params,
5
+ * but tabular or nested inputs (dataTable rows, map markers, chart data)
6
+ * don't pack well into a single quoted line. This module supplies the two
7
+ * structured channels instead:
8
+ *
9
+ * 1. **Data fences** — a fenced code block whose info string is
10
+ * `json data` or `yaml data`, placed in a heading's body content:
11
+ *
12
+ * ````markdown
13
+ * ## Quarterly numbers {[dataTable]}
14
+ *
15
+ * ```json data
16
+ * { "headers": ["Q", "Revenue"], "rows": [["Q1", "1.2M"], ["Q2", "1.4M"]] }
17
+ * ```
18
+ * ````
19
+ *
20
+ * The parsed object lands on `block.templateData` and merges into the
21
+ * template input (after defaults, before `{[…]}` string overrides).
22
+ * The `data` marker keeps ordinary ```json code samples — which should
23
+ * render as code — out of the template input.
24
+ *
25
+ * 2. **GFM tables** — for the `dataTable` template, the first table in the
26
+ * section body supplies `headers` / `rows` / `align` when the author
27
+ * didn't provide them explicitly.
28
+ *
29
+ * YAML support is a documented subset (this package has no YAML
30
+ * dependency): top-level `key: scalar`, `key: [inline, array]`, and
31
+ * `key:` followed by an indented `- item` block sequence whose items are
32
+ * scalars or inline arrays. Nested maps are rejected with a clear error —
33
+ * use a `json data` fence for deeply nested input.
34
+ */
35
+
36
+ import type {
37
+ MarkdownBlockNode,
38
+ MarkdownCodeBlock,
39
+ MarkdownTable,
40
+ MarkdownTableRow,
41
+ } from '../markdown/types.js';
42
+ import { extractPlainText } from '../markdown/utils.js';
43
+
44
+ // ============================================
45
+ // Data fences
46
+ // ============================================
47
+
48
+ /** Languages accepted for data fences. */
49
+ const DATA_FENCE_LANGS = new Set(['json', 'yaml', 'yml']);
50
+
51
+ /**
52
+ * Whether a code block is a structured-data fence: lang `json`/`yaml`/`yml`
53
+ * with the word `data` in the fence meta (` ```json data `).
54
+ */
55
+ export function isDataFence(node: MarkdownCodeBlock): boolean {
56
+ if (!node.lang || !DATA_FENCE_LANGS.has(node.lang.toLowerCase())) return false;
57
+ const meta = node.meta ?? '';
58
+ return /\bdata\b/.test(meta);
59
+ }
60
+
61
+ export interface DataFenceParseResult {
62
+ /** Parsed key→value map. Undefined when parsing failed. */
63
+ data?: Record<string, unknown>;
64
+ /** Parse failure description. Undefined when parsing succeeded. */
65
+ error?: string;
66
+ }
67
+
68
+ /**
69
+ * Parse a data fence's content into a key→value map. JSON fences must
70
+ * contain a top-level object (arrays have no key to merge under); YAML
71
+ * fences are parsed with {@link parseYamlSubset}.
72
+ */
73
+ export function parseDataFence(node: MarkdownCodeBlock): DataFenceParseResult {
74
+ const lang = node.lang?.toLowerCase();
75
+ if (lang === 'json') {
76
+ try {
77
+ const parsed: unknown = JSON.parse(node.value);
78
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
79
+ return { error: 'JSON data fence must contain a top-level object ({"key": …})' };
80
+ }
81
+ return { data: parsed as Record<string, unknown> };
82
+ } catch (err) {
83
+ return { error: `invalid JSON: ${err instanceof Error ? err.message : String(err)}` };
84
+ }
85
+ }
86
+ try {
87
+ return { data: parseYamlSubset(node.value) };
88
+ } catch (err) {
89
+ return { error: `invalid YAML: ${err instanceof Error ? err.message : String(err)}` };
90
+ }
91
+ }
92
+
93
+ // ============================================
94
+ // YAML subset parser
95
+ // ============================================
96
+
97
+ /** Parse a scalar token: quoted string, boolean, null, number, or string. */
98
+ function parseScalar(raw: string): unknown {
99
+ const trimmed = raw.trim();
100
+ if (trimmed.length >= 2) {
101
+ const q = trimmed[0];
102
+ if ((q === '"' || q === "'") && trimmed.endsWith(q)) {
103
+ return trimmed.slice(1, -1);
104
+ }
105
+ }
106
+ if (trimmed === 'true') return true;
107
+ if (trimmed === 'false') return false;
108
+ if (trimmed === 'null' || trimmed === '~') return null;
109
+ if (trimmed !== '' && !isNaN(Number(trimmed))) return Number(trimmed);
110
+ return trimmed;
111
+ }
112
+
113
+ /**
114
+ * Split an inline-array interior on top-level commas, respecting quotes.
115
+ */
116
+ function splitInlineArray(interior: string): string[] {
117
+ const items: string[] = [];
118
+ let current = '';
119
+ let quote: string | null = null;
120
+ for (const ch of interior) {
121
+ if (quote) {
122
+ current += ch;
123
+ if (ch === quote) quote = null;
124
+ } else if (ch === '"' || ch === "'") {
125
+ quote = ch;
126
+ current += ch;
127
+ } else if (ch === ',') {
128
+ items.push(current);
129
+ current = '';
130
+ } else {
131
+ current += ch;
132
+ }
133
+ }
134
+ items.push(current);
135
+ // A trailing comma (or empty interior) leaves an empty final item — drop it.
136
+ return items.map((s) => s.trim()).filter((s) => s !== '');
137
+ }
138
+
139
+ /** Parse a value that may be an inline array `[a, b]` or a scalar. */
140
+ function parseInlineValue(raw: string): unknown {
141
+ const trimmed = raw.trim();
142
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
143
+ return splitInlineArray(trimmed.slice(1, -1)).map(parseScalar);
144
+ }
145
+ return parseScalar(trimmed);
146
+ }
147
+
148
+ /**
149
+ * Parse the supported YAML subset (see module doc) into a key→value map.
150
+ * Throws an Error with a line-anchored message on anything outside the
151
+ * subset, so callers can surface a precise diagnostic.
152
+ */
153
+ export function parseYamlSubset(src: string): Record<string, unknown> {
154
+ const result: Record<string, unknown> = {};
155
+ const lines = src.split('\n');
156
+ let pendingListKey: string | null = null;
157
+
158
+ for (let i = 0; i < lines.length; i++) {
159
+ const line = lines[i];
160
+ const trimmed = line.trim();
161
+ if (!trimmed || trimmed.startsWith('#')) continue;
162
+
163
+ const indented = /^\s/.test(line);
164
+
165
+ if (indented && trimmed.startsWith('- ')) {
166
+ if (!pendingListKey) {
167
+ throw new Error(`line ${i + 1}: list item without a preceding "key:" line`);
168
+ }
169
+ (result[pendingListKey] as unknown[]).push(parseInlineValue(trimmed.slice(2)));
170
+ continue;
171
+ }
172
+
173
+ if (indented) {
174
+ throw new Error(
175
+ `line ${i + 1}: nested mappings are not supported — use a \`\`\`json data fence for nested input`,
176
+ );
177
+ }
178
+
179
+ pendingListKey = null;
180
+ const colonIdx = trimmed.indexOf(':');
181
+ if (colonIdx < 1) {
182
+ throw new Error(`line ${i + 1}: expected "key: value"`);
183
+ }
184
+ const key = trimmed.slice(0, colonIdx).trim();
185
+ const rawValue = trimmed.slice(colonIdx + 1).trim();
186
+
187
+ if (rawValue === '') {
188
+ // `key:` opens a block sequence; items follow as indented `- …` lines.
189
+ result[key] = [];
190
+ pendingListKey = key;
191
+ } else {
192
+ result[key] = parseInlineValue(rawValue);
193
+ }
194
+ }
195
+
196
+ return result;
197
+ }
198
+
199
+ // ============================================
200
+ // GFM table extraction
201
+ // ============================================
202
+
203
+ export interface ExtractedTableData {
204
+ headers: string[];
205
+ rows: string[][];
206
+ align?: (('left' | 'right' | 'center') | null)[];
207
+ }
208
+
209
+ /** Find the first GFM table among a block's body contents. */
210
+ export function findFirstTable(
211
+ contents: MarkdownBlockNode[] | undefined,
212
+ ): MarkdownTable | undefined {
213
+ return contents?.find((n): n is MarkdownTable => n.type === 'table');
214
+ }
215
+
216
+ /**
217
+ * Extract `headers` / `rows` / `align` (the `dataTable` input shape) from a
218
+ * GFM table node. The first row is the header row.
219
+ */
220
+ export function extractTableData(table: MarkdownTable): ExtractedTableData {
221
+ const toCells = (row: MarkdownTableRow): string[] =>
222
+ row.children.map((cell) => extractPlainText(cell).trim());
223
+
224
+ const [headerRow, ...bodyRows] = table.children;
225
+ return {
226
+ headers: headerRow ? toCells(headerRow) : [],
227
+ rows: bodyRows.map(toCells),
228
+ ...(table.align && table.align.some((a) => a != null) ? { align: table.align } : {}),
229
+ };
230
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Template input derivation — build a template's required inputs from a
3
+ * block's heading + markdown body.
4
+ *
5
+ * Used by:
6
+ * - `markdownToDoc`'s content-aware auto template picking, so an
7
+ * auto-picked `quote`/`statHighlight`/`leftFeature` block carries the
8
+ * inputs its template needs through every render path (player, linear
9
+ * view, exports).
10
+ * - `LinearDocView` / editor previews, to render annotated blocks whose
11
+ * authors didn't spell out every template param.
12
+ *
13
+ * Two modes:
14
+ * - strict (default): returns `null` when an essential input can't be
15
+ * derived (no image for a feature block, no table for dataTable…) —
16
+ * auto-picking uses this to fall back to the structural default.
17
+ * - placeholders: returns visible placeholder values instead of failing —
18
+ * preview surfaces use this so a half-authored block still renders.
19
+ */
20
+
21
+ import type { MarkdownBlockNode, MarkdownList, MarkdownTable } from '../markdown/types.js';
22
+ import { extractPlainText } from '../markdown/utils.js';
23
+
24
+ /** First image discovered in a block's body, with explicit dimensions when present. */
25
+ export interface FirstImage {
26
+ src: string;
27
+ alt: string;
28
+ width?: number;
29
+ height?: number;
30
+ }
31
+
32
+ /** Plain text of a block's body contents (excluding the heading). */
33
+ export function extractBodyPlainText(contents?: MarkdownBlockNode[]): string {
34
+ if (!contents || contents.length === 0) return '';
35
+ return contents
36
+ .map((n) => extractPlainText(n))
37
+ .join('\n')
38
+ .trim();
39
+ }
40
+
41
+ /** Extract list items as plain text. */
42
+ export function extractListItems(contents?: MarkdownBlockNode[]): string[] {
43
+ if (!contents) return [];
44
+ const items: string[] = [];
45
+ for (const node of contents) {
46
+ if (node.type === 'list') {
47
+ for (const item of (node as MarkdownList).children) {
48
+ const text = extractPlainText(item).trim();
49
+ if (text) items.push(text);
50
+ }
51
+ }
52
+ }
53
+ return items;
54
+ }
55
+
56
+ /** Parse a `width`/`height` HTML attribute to a positive number. */
57
+ function parseDim(raw: string | undefined): number | undefined {
58
+ if (raw === undefined) return undefined;
59
+ const n = parseFloat(raw);
60
+ return Number.isFinite(n) && n > 0 ? n : undefined;
61
+ }
62
+
63
+ /**
64
+ * Find images referenced anywhere in block contents — both markdown
65
+ * shorthand `![alt](url)` (type `image`) and raw HTML `<img>` tags
66
+ * (type `htmlBlock`/`htmlInline`). The WYSIWYG editor emits the HTML form
67
+ * whenever a user resizes an image, so missing that path silently breaks
68
+ * every resized image.
69
+ *
70
+ * @param limit Stop after this many images (default: all).
71
+ */
72
+ export function extractImages(
73
+ contents: MarkdownBlockNode[] | undefined,
74
+ limit = Infinity,
75
+ ): FirstImage[] {
76
+ if (!contents || contents.length === 0) return [];
77
+ const found: FirstImage[] = [];
78
+
79
+ function fromHtml(nodes: unknown[]): void {
80
+ for (const node of nodes) {
81
+ if (found.length >= limit) return;
82
+ if (!node || typeof node !== 'object') continue;
83
+ const n = node as Record<string, unknown>;
84
+ if (n.type === 'htmlElement' && n.tagName === 'img') {
85
+ const attrs = n.attributes as Record<string, string> | undefined;
86
+ if (attrs && typeof attrs.src === 'string' && attrs.src) {
87
+ found.push({
88
+ src: attrs.src,
89
+ alt: typeof attrs.alt === 'string' ? attrs.alt : '',
90
+ width: parseDim(attrs.width),
91
+ height: parseDim(attrs.height),
92
+ });
93
+ }
94
+ }
95
+ if (Array.isArray(n.children)) fromHtml(n.children);
96
+ }
97
+ }
98
+
99
+ function walk(node: unknown): void {
100
+ if (found.length >= limit) return;
101
+ if (!node || typeof node !== 'object') return;
102
+ const n = node as Record<string, unknown>;
103
+ if (n.type === 'image' && typeof n.url === 'string' && n.url) {
104
+ found.push({ src: n.url, alt: typeof n.alt === 'string' ? n.alt : '' });
105
+ return;
106
+ }
107
+ if ((n.type === 'htmlBlock' || n.type === 'htmlInline') && Array.isArray(n.htmlChildren)) {
108
+ fromHtml(n.htmlChildren);
109
+ }
110
+ if (Array.isArray(n.children)) {
111
+ for (const child of n.children) walk(child);
112
+ }
113
+ }
114
+
115
+ for (const node of contents) {
116
+ if (found.length >= limit) break;
117
+ walk(node);
118
+ }
119
+ return found;
120
+ }
121
+
122
+ /** First image in a block's body, or null. */
123
+ export function extractFirstImage(contents: MarkdownBlockNode[] | undefined): FirstImage | null {
124
+ return extractImages(contents, 1)[0] ?? null;
125
+ }
126
+
127
+ /** Extract table data (headers, rows, alignment) from block contents. */
128
+ export function extractTableFromContents(contents?: MarkdownBlockNode[]): {
129
+ headers: string[];
130
+ rows: string[][];
131
+ align?: (('left' | 'right' | 'center') | null)[];
132
+ } | null {
133
+ if (!contents) return null;
134
+ for (const node of contents) {
135
+ if (node.type === 'table') {
136
+ const table = node as MarkdownTable;
137
+ const [headerRow, ...bodyRows] = table.children;
138
+ if (!headerRow) return null;
139
+ const headers = headerRow.children.map((cell) => extractPlainText(cell).trim());
140
+ const rows = bodyRows.map((row) => row.children.map((cell) => extractPlainText(cell).trim()));
141
+ return { headers, rows, align: table.align };
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** First blockquote's plain text, or ''. */
148
+ export function extractBlockquoteText(contents?: MarkdownBlockNode[]): string {
149
+ if (!contents) return '';
150
+ for (const node of contents) {
151
+ if (node.type === 'blockquote') return extractPlainText(node).trim();
152
+ }
153
+ return '';
154
+ }
155
+
156
+ export interface DeriveTemplateInputsOptions {
157
+ /**
158
+ * Return visible placeholder values instead of `null` when an essential
159
+ * input can't be derived (preview surfaces). Default false (strict).
160
+ */
161
+ placeholders?: boolean;
162
+ }
163
+
164
+ /**
165
+ * Derive a template's inputs from a block's heading text and body nodes.
166
+ * Returns the input fields to merge onto the block, or `null` in strict
167
+ * mode when an essential input is missing.
168
+ */
169
+ export function deriveTemplateInputs(
170
+ templateName: string,
171
+ headingText: string,
172
+ contents: MarkdownBlockNode[] | undefined,
173
+ options: DeriveTemplateInputsOptions = {},
174
+ ): Record<string, unknown> | null {
175
+ const placeholders = options.placeholders === true;
176
+ const bodyText = extractBodyPlainText(contents);
177
+
178
+ switch (templateName) {
179
+ case 'statHighlight':
180
+ return { stat: headingText, description: bodyText || headingText };
181
+ case 'quote': {
182
+ const quote = extractBlockquoteText(contents) || bodyText || headingText;
183
+ return { quote };
184
+ }
185
+ case 'fullBleedQuote':
186
+ case 'pullQuote': {
187
+ // These templates take `text` (not `quote`).
188
+ const text = extractBlockquoteText(contents) || bodyText || headingText;
189
+ return { text };
190
+ }
191
+ case 'factCard':
192
+ return { fact: headingText, explanation: bodyText || headingText };
193
+ case 'comparisonBar':
194
+ return placeholders
195
+ ? { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 }
196
+ : null;
197
+ case 'list': {
198
+ const items = extractListItems(contents);
199
+ if (items.length > 0) return { items };
200
+ return placeholders ? { items: ['Item 1', 'Item 2', 'Item 3'] } : null;
201
+ }
202
+ case 'definitionCard':
203
+ return { term: headingText, definition: bodyText || headingText };
204
+ case 'dateEvent':
205
+ return { date: headingText, description: bodyText || headingText };
206
+ case 'dataTable': {
207
+ const tableData = extractTableFromContents(contents);
208
+ if (tableData) return tableData;
209
+ return placeholders ? { headers: ['Column'], rows: [['Data']] } : null;
210
+ }
211
+ case 'imageWithCaption': {
212
+ const img = extractFirstImage(contents);
213
+ if (!img) return placeholders ? { caption: headingText } : null;
214
+ return { imageSrc: img.src, imageAlt: img.alt || headingText, caption: headingText };
215
+ }
216
+ case 'photoGrid': {
217
+ const images = extractImages(contents, 4);
218
+ if (images.length < 2) return placeholders ? {} : null;
219
+ return {
220
+ images: images.map((i) => ({ src: i.src, alt: i.alt })),
221
+ caption: headingText,
222
+ };
223
+ }
224
+ case 'leftFeature':
225
+ case 'rightFeature': {
226
+ const img = extractFirstImage(contents);
227
+ if (!img && !placeholders) return null;
228
+ return {
229
+ imageSrc: img?.src ?? '',
230
+ imageAlt: img?.alt || headingText,
231
+ imageWidth: img?.width,
232
+ imageHeight: img?.height,
233
+ title: headingText,
234
+ body: bodyText,
235
+ };
236
+ }
237
+ default:
238
+ return placeholders ? {} : null;
239
+ }
240
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * makeCustomTemplateFn — verifies a CustomTemplateDefinition expands
3
+ * correctly via the standard template function signature and that
4
+ * `%`-based positions resolve correctly across viewport presets.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { makeCustomTemplateFn } from '../customTemplate';
9
+ import type { CustomTemplateDefinition } from '../../../schemas/CustomTemplates.js';
10
+ import type { Block, TextLayer } from '../../../schemas/Doc.js';
11
+ import type { RawLayersInput, TemplateContext } from '../../../schemas/BlockTemplates.js';
12
+ import { createTemplateContext } from '../../../schemas/BlockTemplates.js';
13
+ import { DEFAULT_THEME } from '../../../schemas/themeLibrary.js';
14
+ import { VIEWPORT_PRESETS, type ViewportConfig } from '../../../schemas/Viewport.js';
15
+
16
+ function defn(layers: TextLayer[]): CustomTemplateDefinition {
17
+ return {
18
+ name: 'hero',
19
+ label: 'Hero',
20
+ viewport: { width: 1920, height: 1080 },
21
+ layers,
22
+ };
23
+ }
24
+
25
+ function makeContext(
26
+ block: Block,
27
+ viewport: ViewportConfig = VIEWPORT_PRESETS.landscape,
28
+ ): TemplateContext {
29
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, viewport);
30
+ ctx.block = block;
31
+ return ctx;
32
+ }
33
+
34
+ function makeBlock(overrides: Partial<Block> = {}): Block {
35
+ return {
36
+ id: 'b',
37
+ startTime: 0,
38
+ duration: 1,
39
+ audioSegment: 0,
40
+ title: 'Welcome',
41
+ template: 'hero',
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function makeInput(): RawLayersInput {
47
+ return {
48
+ id: 'b',
49
+ template: 'layout',
50
+ duration: 1,
51
+ audioSegment: 0,
52
+ };
53
+ }
54
+
55
+ describe('makeCustomTemplateFn', () => {
56
+ it('substitutes the block title into a layer carrying {title}', () => {
57
+ const fn = makeCustomTemplateFn(
58
+ defn([
59
+ {
60
+ id: 't',
61
+ type: 'text',
62
+ position: { x: '10%', y: '20%', width: '80%', height: '15%' },
63
+ content: { text: 'Hello, {title}!', style: { fontSize: 48, color: '#000' } },
64
+ },
65
+ ]),
66
+ );
67
+ const ctx = makeContext(makeBlock({ title: 'World' }));
68
+ const layers = fn(makeInput(), ctx);
69
+ expect(layers).toHaveLength(1);
70
+ expect((layers[0] as TextLayer).content.text).toBe('Hello, World!');
71
+ });
72
+
73
+ it('returns layers unchanged when context.block is missing', () => {
74
+ const fn = makeCustomTemplateFn(
75
+ defn([
76
+ {
77
+ id: 't',
78
+ type: 'text',
79
+ position: { x: '0%', y: '0%', width: '100%', height: '10%' },
80
+ content: { text: '{title}', style: { fontSize: 24, color: '#000' } },
81
+ },
82
+ ]),
83
+ );
84
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
85
+ const layers = fn(makeInput(), ctx);
86
+ expect((layers[0] as TextLayer).content.text).toBe('{title}');
87
+ });
88
+
89
+ it('preserves %-based positions verbatim — they resolve at SSR render time', () => {
90
+ const fn = makeCustomTemplateFn(
91
+ defn([
92
+ {
93
+ id: 't',
94
+ type: 'text',
95
+ position: { x: '10%', y: '20%', width: '80%' },
96
+ content: { text: 'static', style: { fontSize: 24, color: '#000' } },
97
+ },
98
+ ]),
99
+ );
100
+
101
+ // Landscape: 1920×1080
102
+ const landscapeLayers = fn(makeInput(), makeContext(makeBlock(), VIEWPORT_PRESETS.landscape));
103
+ expect(landscapeLayers[0].position.x).toBe('10%');
104
+
105
+ // Portrait: 1080×1920
106
+ const portraitLayers = fn(makeInput(), makeContext(makeBlock(), VIEWPORT_PRESETS.portrait));
107
+ expect(portraitLayers[0].position.x).toBe('10%');
108
+
109
+ // Square: 1080×1080
110
+ const squareLayers = fn(makeInput(), makeContext(makeBlock(), VIEWPORT_PRESETS.square));
111
+ expect(squareLayers[0].position.x).toBe('10%');
112
+
113
+ // The positions stay as `%`-strings — the SSR layer renderer (not
114
+ // this function) is responsible for resolving them into pixels
115
+ // against the target viewport. We verify the function doesn't
116
+ // perturb authoring intent.
117
+ });
118
+
119
+ it('round-trips multiple tokens in one definition', () => {
120
+ const fn = makeCustomTemplateFn(
121
+ defn([
122
+ {
123
+ id: 'title',
124
+ type: 'text',
125
+ position: { x: '5%', y: '5%', width: '90%', height: '20%' },
126
+ content: { text: '{title}', style: { fontSize: 72, color: '#000' } },
127
+ },
128
+ {
129
+ id: 'body',
130
+ type: 'text',
131
+ position: { x: '5%', y: '30%', width: '90%', height: '60%' },
132
+ content: { text: '{content}', style: { fontSize: 32, color: '#333' } },
133
+ },
134
+ ]),
135
+ );
136
+ const ctx = makeContext(
137
+ makeBlock({
138
+ title: 'Welcome',
139
+ contents: [
140
+ {
141
+ type: 'paragraph',
142
+ children: [{ type: 'text', value: 'A small editor for big ideas.' }],
143
+ },
144
+ ],
145
+ }),
146
+ );
147
+ const layers = fn(makeInput(), ctx);
148
+ expect((layers[0] as TextLayer).content.text).toBe('Welcome');
149
+ expect((layers[1] as TextLayer).content.text).toBe('A small editor for big ideas.');
150
+ });
151
+ });