@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
@@ -0,0 +1,487 @@
1
+ /**
2
+ * resolveTokens — placeholder substitution unit tests.
3
+ *
4
+ * Covers each supported token, the escape syntax, and the edge cases
5
+ * called out in the plan (missing data, out-of-range image index,
6
+ * unknown token left literal).
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { resolveTokens, collectListItems } from '../resolveTokens';
11
+ import type { Block, TextLayer, ImageLayer } from '../../../../schemas/Doc.js';
12
+ import type { CustomTemplateLayer } from '../../../../schemas/CustomTemplates.js';
13
+
14
+ function textLayer(id: string, text: string): TextLayer {
15
+ return {
16
+ id,
17
+ type: 'text',
18
+ position: { x: 0, y: 0, width: 100, height: 50 },
19
+ content: { text, style: { fontSize: 24, color: '#000' } },
20
+ };
21
+ }
22
+
23
+ function imageLayer(id: string, src: string, alt = ''): ImageLayer {
24
+ return {
25
+ id,
26
+ type: 'image',
27
+ position: { x: 0, y: 0, width: 100, height: 100 },
28
+ content: { src, alt },
29
+ };
30
+ }
31
+
32
+ function block(overrides: Partial<Block> = {}): Block {
33
+ return {
34
+ id: 'b',
35
+ startTime: 0,
36
+ duration: 1,
37
+ audioSegment: 0,
38
+ title: 'Block title',
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ describe('resolveTokens — TextLayer', () => {
44
+ it('substitutes {title} with block.title', () => {
45
+ const layers = resolveTokens([textLayer('a', 'Hello, {title}!')], block({ title: 'World' }));
46
+ const out = layers[0] as TextLayer;
47
+ expect(out.content.text).toBe('Hello, World!');
48
+ });
49
+
50
+ it('collapses {title} to empty when block has no title', () => {
51
+ const layers = resolveTokens([textLayer('a', '[{title}]')], block({ title: undefined }));
52
+ expect((layers[0] as TextLayer).content.text).toBe('[]');
53
+ });
54
+
55
+ it('substitutes {content} from block.contents (plain text of body)', () => {
56
+ const layers = resolveTokens(
57
+ [textLayer('a', 'Body: {content}')],
58
+ block({
59
+ contents: [
60
+ {
61
+ type: 'paragraph',
62
+ children: [{ type: 'text', value: 'Hello there' }],
63
+ },
64
+ ],
65
+ }),
66
+ );
67
+ expect((layers[0] as TextLayer).content.text).toBe('Body: Hello there');
68
+ });
69
+
70
+ it('joins multiple paragraphs in {content} with single spaces', () => {
71
+ const layers = resolveTokens(
72
+ [textLayer('a', '{content}')],
73
+ block({
74
+ contents: [
75
+ { type: 'paragraph', children: [{ type: 'text', value: 'First.' }] },
76
+ { type: 'paragraph', children: [{ type: 'text', value: 'Second.' }] },
77
+ ],
78
+ }),
79
+ );
80
+ expect((layers[0] as TextLayer).content.text).toBe('First. Second.');
81
+ });
82
+
83
+ it('joins child titles for {children}', () => {
84
+ const layers = resolveTokens(
85
+ [textLayer('a', 'Sections: {children}')],
86
+ block({
87
+ children: [
88
+ block({ id: 'c1', title: 'Alpha' }),
89
+ block({ id: 'c2', title: 'Beta' }),
90
+ block({ id: 'c3', title: 'Gamma' }),
91
+ ],
92
+ }),
93
+ );
94
+ expect((layers[0] as TextLayer).content.text).toBe('Sections: Alpha, Beta, Gamma');
95
+ });
96
+
97
+ it('substitutes {image:N} with the Nth image alt text', () => {
98
+ const layers = resolveTokens(
99
+ [textLayer('a', 'Caption: {image:0}')],
100
+ block({
101
+ contents: [
102
+ {
103
+ type: 'paragraph',
104
+ children: [{ type: 'image', url: '/a.png', alt: 'Alpha image' }],
105
+ },
106
+ ],
107
+ }),
108
+ );
109
+ expect((layers[0] as TextLayer).content.text).toBe('Caption: Alpha image');
110
+ });
111
+
112
+ it('preserves {{title}} as literal {title}', () => {
113
+ const layers = resolveTokens(
114
+ [textLayer('a', 'Use {{title}} as a token, or {title} to expand')],
115
+ block({ title: 'My Doc' }),
116
+ );
117
+ expect((layers[0] as TextLayer).content.text).toBe(
118
+ 'Use {title} as a token, or My Doc to expand',
119
+ );
120
+ });
121
+
122
+ it('leaves unknown tokens literal', () => {
123
+ const layers = resolveTokens([textLayer('a', 'Hello {nope} world')], block());
124
+ expect((layers[0] as TextLayer).content.text).toBe('Hello {nope} world');
125
+ });
126
+
127
+ it('substitutes multiple tokens in a single string', () => {
128
+ const layers = resolveTokens(
129
+ [textLayer('a', '{title} — {content}')],
130
+ block({
131
+ title: 'Doc',
132
+ contents: [{ type: 'paragraph', children: [{ type: 'text', value: 'body' }] }],
133
+ }),
134
+ );
135
+ expect((layers[0] as TextLayer).content.text).toBe('Doc — body');
136
+ });
137
+ });
138
+
139
+ describe('resolveTokens — ImageLayer', () => {
140
+ it('substitutes {image:0} with the Nth image URL in src', () => {
141
+ const layers = resolveTokens(
142
+ [imageLayer('a', '{image:0}')],
143
+ block({
144
+ contents: [
145
+ {
146
+ type: 'paragraph',
147
+ children: [{ type: 'image', url: '/hero.png', alt: 'hero' }],
148
+ },
149
+ ],
150
+ }),
151
+ );
152
+ expect(layers).toHaveLength(1);
153
+ expect((layers[0] as ImageLayer).content.src).toBe('/hero.png');
154
+ });
155
+
156
+ it('drops the layer entirely when the requested image index is out of range', () => {
157
+ const layers = resolveTokens(
158
+ [imageLayer('a', '{image:99}'), imageLayer('b', '/keep.png')],
159
+ block({
160
+ contents: [
161
+ {
162
+ type: 'paragraph',
163
+ children: [{ type: 'image', url: '/one.png', alt: '' }],
164
+ },
165
+ ],
166
+ }),
167
+ );
168
+ expect(layers).toHaveLength(1);
169
+ expect(layers[0].id).toBe('b');
170
+ });
171
+
172
+ it('substitutes tokens in the alt text when the src is also a token', () => {
173
+ const layers = resolveTokens(
174
+ [imageLayer('a', '{image:0}', 'Image for {title}')],
175
+ block({
176
+ title: 'My Doc',
177
+ contents: [
178
+ {
179
+ type: 'paragraph',
180
+ children: [{ type: 'image', url: '/x.png', alt: '' }],
181
+ },
182
+ ],
183
+ }),
184
+ );
185
+ const img = layers[0] as ImageLayer;
186
+ expect(img.content.src).toBe('/x.png');
187
+ expect(img.content.alt).toBe('Image for My Doc');
188
+ });
189
+
190
+ it('passes through ImageLayer with no token in src', () => {
191
+ const layers = resolveTokens([imageLayer('a', '/static.png')], block());
192
+ expect((layers[0] as ImageLayer).content.src).toBe('/static.png');
193
+ });
194
+ });
195
+
196
+ describe('resolveTokens — other layer types', () => {
197
+ it('passes ShapeLayer / PathLayer through unchanged', () => {
198
+ const shape = {
199
+ id: 's',
200
+ type: 'shape' as const,
201
+ position: { x: 0, y: 0, width: 50, height: 50 },
202
+ content: { shape: 'rect' as const, fill: '#fff' },
203
+ };
204
+ const path = {
205
+ id: 'p',
206
+ type: 'path' as const,
207
+ position: { x: 0, y: 0, width: 100, height: 100 },
208
+ content: { d: 'M 0 0 L 100 100', stroke: '#000' },
209
+ };
210
+ const layers = resolveTokens([shape, path], block());
211
+ expect(layers).toEqual([shape, path]);
212
+ });
213
+ });
214
+
215
+ describe('resolveTokens — purity', () => {
216
+ it('does not mutate the input layers array or the layers themselves', () => {
217
+ const original = textLayer('a', '{title}');
218
+ const before = JSON.stringify(original);
219
+ resolveTokens([original], block({ title: 'X' }));
220
+ expect(JSON.stringify(original)).toBe(before);
221
+ });
222
+ });
223
+
224
+ describe('resolveTokens — {attr:key}', () => {
225
+ it('reads from templateOverrides first, then templateData, then metadata', () => {
226
+ // key present in all three → overrides wins
227
+ const over = resolveTokens(
228
+ [textLayer('a', '{attr:role}')],
229
+ block({
230
+ templateOverrides: { role: 'from-overrides' },
231
+ templateData: { role: 'from-data' },
232
+ metadata: { role: 'from-metadata' },
233
+ }),
234
+ );
235
+ expect((over[0] as TextLayer).content.text).toBe('from-overrides');
236
+
237
+ // absent from overrides → templateData wins over metadata
238
+ const data = resolveTokens(
239
+ [textLayer('a', '{attr:role}')],
240
+ block({ templateData: { role: 'from-data' }, metadata: { role: 'from-metadata' } }),
241
+ );
242
+ expect((data[0] as TextLayer).content.text).toBe('from-data');
243
+
244
+ // only metadata has it
245
+ const meta = resolveTokens(
246
+ [textLayer('a', '{attr:role}')],
247
+ block({ metadata: { role: 'from-metadata' } }),
248
+ );
249
+ expect((meta[0] as TextLayer).content.text).toBe('from-metadata');
250
+ });
251
+
252
+ it('stringifies non-string templateData values', () => {
253
+ const layers = resolveTokens(
254
+ [textLayer('a', 'n={attr:count}')],
255
+ block({ templateData: { count: 42 } }),
256
+ );
257
+ expect((layers[0] as TextLayer).content.text).toBe('n=42');
258
+ });
259
+
260
+ it('collapses a missing attr to empty string', () => {
261
+ const layers = resolveTokens([textLayer('a', '[{attr:missing}]')], block());
262
+ expect((layers[0] as TextLayer).content.text).toBe('[]');
263
+ });
264
+ });
265
+
266
+ describe('resolveTokens — pipe defaults', () => {
267
+ it('uses the default when {title} is empty, the value when present', () => {
268
+ const empty = resolveTokens([textLayer('a', '{title|Untitled}')], block({ title: undefined }));
269
+ expect((empty[0] as TextLayer).content.text).toBe('Untitled');
270
+
271
+ const present = resolveTokens([textLayer('a', '{title|Untitled}')], block({ title: 'Real' }));
272
+ expect((present[0] as TextLayer).content.text).toBe('Real');
273
+ });
274
+
275
+ it('uses the default on a missing {attr:key|…}', () => {
276
+ const layers = resolveTokens(
277
+ [textLayer('a', '{attr:subtitle|Untitled}')],
278
+ block({ templateOverrides: {} }),
279
+ );
280
+ expect((layers[0] as TextLayer).content.text).toBe('Untitled');
281
+ });
282
+
283
+ it('uses the image-src default (and keeps the layer) when the image index is out of range', () => {
284
+ const layers = resolveTokens([imageLayer('a', '{image:1|fallback.jpg}')], block());
285
+ expect(layers).toHaveLength(1);
286
+ expect((layers[0] as ImageLayer).content.src).toBe('fallback.jpg');
287
+ });
288
+
289
+ it('still drops the image layer when {image:N} misses and no default is given', () => {
290
+ const layers = resolveTokens([imageLayer('a', '{image:1}')], block());
291
+ expect(layers).toHaveLength(0);
292
+ });
293
+
294
+ it('an empty default renders as empty (present token collapses to blank)', () => {
295
+ const layers = resolveTokens([textLayer('a', '[{attr:x|}]')], block());
296
+ expect((layers[0] as TextLayer).content.text).toBe('[]');
297
+ });
298
+ });
299
+
300
+ describe('resolveTokens — escape vs pipe defaults', () => {
301
+ it('leaves {{title|Untitled}} literal (escape shields the pipe default too)', () => {
302
+ const layers = resolveTokens(
303
+ [textLayer('a', 'Token: {{title|Untitled}}')],
304
+ block({ title: 'Real' }),
305
+ );
306
+ expect((layers[0] as TextLayer).content.text).toBe('Token: {title|Untitled}');
307
+ });
308
+ });
309
+
310
+ function repeatText(
311
+ id: string,
312
+ text: string,
313
+ repeat: CustomTemplateLayer['repeat'],
314
+ ): CustomTemplateLayer {
315
+ return {
316
+ id,
317
+ type: 'text',
318
+ position: { x: '0%', y: '10%', width: '30%', height: '20%' },
319
+ content: { text, style: { fontSize: 24, color: '#000' } },
320
+ repeat,
321
+ };
322
+ }
323
+
324
+ const imagesBlock = block({
325
+ contents: [
326
+ {
327
+ type: 'paragraph',
328
+ children: [
329
+ { type: 'image', url: '/one.png', alt: 'One' },
330
+ { type: 'image', url: '/two.png', alt: 'Two' },
331
+ { type: 'image', url: '/three.png', alt: 'Three' },
332
+ ],
333
+ },
334
+ ],
335
+ });
336
+
337
+ describe('resolveTokens — repeat', () => {
338
+ it('clones a layer once per image and resolves per-item tokens', () => {
339
+ const layers = resolveTokens(
340
+ [repeatText('cap', '{index}. {item} ({item:src})', { source: 'images' })],
341
+ imagesBlock,
342
+ );
343
+ expect(layers).toHaveLength(3);
344
+ expect((layers[0] as TextLayer).content.text).toBe('1. One (/one.png)');
345
+ expect((layers[1] as TextLayer).content.text).toBe('2. Two (/two.png)');
346
+ expect((layers[2] as TextLayer).content.text).toBe('3. Three (/three.png)');
347
+ });
348
+
349
+ it('gives each clone a unique id and strips the repeat prop', () => {
350
+ const layers = resolveTokens([repeatText('cap', '{item}', { source: 'images' })], imagesBlock);
351
+ expect(layers.map((l) => l.id)).toEqual(['cap-0', 'cap-1', 'cap-2']);
352
+ expect(layers.every((l) => !('repeat' in l))).toBe(true);
353
+ });
354
+
355
+ it('iterates children titles', () => {
356
+ const layers = resolveTokens(
357
+ [repeatText('c', '{index}:{item}', { source: 'children' })],
358
+ block({ children: [block({ id: 'a', title: 'Alpha' }), block({ id: 'b', title: 'Beta' })] }),
359
+ );
360
+ expect(layers.map((l) => (l as TextLayer).content.text)).toEqual(['1:Alpha', '2:Beta']);
361
+ });
362
+
363
+ it('iterates list items', () => {
364
+ const layers = resolveTokens(
365
+ [repeatText('li', '- {item}', { source: 'listItems' })],
366
+ block({
367
+ contents: [
368
+ {
369
+ type: 'list',
370
+ ordered: false,
371
+ children: [
372
+ {
373
+ type: 'listItem',
374
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'First' }] }],
375
+ },
376
+ {
377
+ type: 'listItem',
378
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Second' }] }],
379
+ },
380
+ ],
381
+ },
382
+ ],
383
+ }),
384
+ );
385
+ expect(layers.map((l) => (l as TextLayer).content.text)).toEqual(['- First', '- Second']);
386
+ });
387
+
388
+ it('caps the number of clones at max', () => {
389
+ const layers = resolveTokens(
390
+ [repeatText('cap', '{item}', { source: 'images', max: 2 })],
391
+ imagesBlock,
392
+ );
393
+ expect(layers).toHaveLength(2);
394
+ expect(layers.map((l) => (l as TextLayer).content.text)).toEqual(['One', 'Two']);
395
+ });
396
+
397
+ it('offsets position.y in column direction by height + gap', () => {
398
+ const layers = resolveTokens(
399
+ [repeatText('cap', '{item}', { source: 'images', direction: 'column', gap: 4 })],
400
+ imagesBlock,
401
+ );
402
+ // base y = 10%, height = 20%, gap = 4 → cursor advances by 24 each item
403
+ expect(layers.map((l) => l.position.y)).toEqual(['10%', '34%', '58%']);
404
+ // x is untouched in column direction
405
+ expect(layers.every((l) => l.position.x === '0%')).toBe(true);
406
+ });
407
+
408
+ it('offsets position.x in row direction by width + gap', () => {
409
+ const layers = resolveTokens(
410
+ [repeatText('cap', '{item}', { source: 'images', direction: 'row', gap: 10 })],
411
+ imagesBlock,
412
+ );
413
+ // base x = 0%, width = 30%, gap = 10 → cursor advances by 40 each item
414
+ expect(layers.map((l) => l.position.x)).toEqual(['0%', '40%', '80%']);
415
+ expect(layers.every((l) => l.position.y === '10%')).toBe(true);
416
+ });
417
+
418
+ it('renders nothing when the source collection is empty', () => {
419
+ const layers = resolveTokens([repeatText('cap', '{item}', { source: 'images' })], block());
420
+ expect(layers).toHaveLength(0);
421
+ });
422
+
423
+ it('resolves {item:src} in an image layer inside a repeat', () => {
424
+ const imgRepeat: CustomTemplateLayer = {
425
+ id: 'img',
426
+ type: 'image',
427
+ position: { x: '0%', y: '0%', width: '25%', height: '25%' },
428
+ content: { src: '{item:src}', alt: '{item:label}' },
429
+ repeat: { source: 'images', direction: 'row', gap: 0 },
430
+ };
431
+ const layers = resolveTokens([imgRepeat], imagesBlock);
432
+ expect(layers).toHaveLength(3);
433
+ expect((layers[0] as ImageLayer).content.src).toBe('/one.png');
434
+ expect((layers[0] as ImageLayer).content.alt).toBe('One');
435
+ expect((layers[2] as ImageLayer).content.src).toBe('/three.png');
436
+ });
437
+ });
438
+
439
+ describe('resolveTokens — {item}/{index} outside a repeat', () => {
440
+ it('leaves per-item tokens literal when there is no repeat context', () => {
441
+ const layers = resolveTokens([textLayer('a', '{item} {index}')], block());
442
+ expect((layers[0] as TextLayer).content.text).toBe('{item} {index}');
443
+ });
444
+ });
445
+
446
+ describe('collectListItems', () => {
447
+ it('returns top-level list-item text and skips nested double-counting', () => {
448
+ const b = block({
449
+ contents: [
450
+ {
451
+ type: 'list',
452
+ ordered: false,
453
+ children: [
454
+ {
455
+ type: 'listItem',
456
+ children: [
457
+ { type: 'paragraph', children: [{ type: 'text', value: 'Parent' }] },
458
+ {
459
+ type: 'list',
460
+ ordered: false,
461
+ children: [
462
+ {
463
+ type: 'listItem',
464
+ children: [
465
+ { type: 'paragraph', children: [{ type: 'text', value: 'Nested' }] },
466
+ ],
467
+ },
468
+ ],
469
+ },
470
+ ],
471
+ },
472
+ {
473
+ type: 'listItem',
474
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Sibling' }] }],
475
+ },
476
+ ],
477
+ },
478
+ ],
479
+ });
480
+ // Only two top-level items; the nested item is folded into "Parent Nested".
481
+ expect(collectListItems(b)).toEqual(['Parent Nested', 'Sibling']);
482
+ });
483
+
484
+ it('returns [] when the block has no list content', () => {
485
+ expect(collectListItems(block())).toEqual([]);
486
+ });
487
+ });