@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,170 @@
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('falls back to the input block 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
+ // No ctx.block set; the fn resolves against `input` instead. The
86
+ // default input carries no title, so {title} collapses to empty.
87
+ const layers = fn(makeInput(), ctx);
88
+ expect((layers[0] as TextLayer).content.text).toBe('');
89
+ });
90
+
91
+ it('resolves {attr:key} from the input block when context.block is missing', () => {
92
+ const fn = makeCustomTemplateFn(
93
+ defn([
94
+ {
95
+ id: 't',
96
+ type: 'text',
97
+ position: { x: '0%', y: '0%', width: '100%', height: '10%' },
98
+ content: { text: '{attr:role}', style: { fontSize: 24, color: '#000' } },
99
+ },
100
+ ]),
101
+ );
102
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
103
+ const input = { ...makeInput(), templateOverrides: { role: 'lead' } } as RawLayersInput;
104
+ const layers = fn(input, ctx);
105
+ expect((layers[0] as TextLayer).content.text).toBe('lead');
106
+ });
107
+
108
+ it('preserves %-based positions verbatim — they resolve at SSR render time', () => {
109
+ const fn = makeCustomTemplateFn(
110
+ defn([
111
+ {
112
+ id: 't',
113
+ type: 'text',
114
+ position: { x: '10%', y: '20%', width: '80%' },
115
+ content: { text: 'static', style: { fontSize: 24, color: '#000' } },
116
+ },
117
+ ]),
118
+ );
119
+
120
+ // Landscape: 1920×1080
121
+ const landscapeLayers = fn(makeInput(), makeContext(makeBlock(), VIEWPORT_PRESETS.landscape));
122
+ expect(landscapeLayers[0].position.x).toBe('10%');
123
+
124
+ // Portrait: 1080×1920
125
+ const portraitLayers = fn(makeInput(), makeContext(makeBlock(), VIEWPORT_PRESETS.portrait));
126
+ expect(portraitLayers[0].position.x).toBe('10%');
127
+
128
+ // Square: 1080×1080
129
+ const squareLayers = fn(makeInput(), makeContext(makeBlock(), VIEWPORT_PRESETS.square));
130
+ expect(squareLayers[0].position.x).toBe('10%');
131
+
132
+ // The positions stay as `%`-strings — the SSR layer renderer (not
133
+ // this function) is responsible for resolving them into pixels
134
+ // against the target viewport. We verify the function doesn't
135
+ // perturb authoring intent.
136
+ });
137
+
138
+ it('round-trips multiple tokens in one definition', () => {
139
+ const fn = makeCustomTemplateFn(
140
+ defn([
141
+ {
142
+ id: 'title',
143
+ type: 'text',
144
+ position: { x: '5%', y: '5%', width: '90%', height: '20%' },
145
+ content: { text: '{title}', style: { fontSize: 72, color: '#000' } },
146
+ },
147
+ {
148
+ id: 'body',
149
+ type: 'text',
150
+ position: { x: '5%', y: '30%', width: '90%', height: '60%' },
151
+ content: { text: '{content}', style: { fontSize: 32, color: '#333' } },
152
+ },
153
+ ]),
154
+ );
155
+ const ctx = makeContext(
156
+ makeBlock({
157
+ title: 'Welcome',
158
+ contents: [
159
+ {
160
+ type: 'paragraph',
161
+ children: [{ type: 'text', value: 'A small editor for big ideas.' }],
162
+ },
163
+ ],
164
+ }),
165
+ );
166
+ const layers = fn(makeInput(), ctx);
167
+ expect((layers[0] as TextLayer).content.text).toBe('Welcome');
168
+ expect((layers[1] as TextLayer).content.text).toBe('A small editor for big ideas.');
169
+ });
170
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Registry merge — end-to-end: a doc that uses a custom template name
3
+ * resolves through `expandDocBlocks(opts.customTemplates)` and produces
4
+ * layers with tokens substituted from the source block.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { buildRegistry, expandDocBlocks } from '../index';
9
+ import type { CustomTemplateDefinition } from '../../../schemas/CustomTemplates.js';
10
+ import type { Block, TextLayer } from '../../../schemas/Doc.js';
11
+
12
+ const heroDef: CustomTemplateDefinition = {
13
+ name: 'hero',
14
+ label: 'Hero',
15
+ viewport: { width: 1920, height: 1080 },
16
+ layers: [
17
+ {
18
+ id: 'title',
19
+ type: 'text',
20
+ position: { x: '5%', y: '10%', width: '90%' },
21
+ content: { text: '{title}', style: { fontSize: 72, color: '#000' } },
22
+ },
23
+ {
24
+ id: 'body',
25
+ type: 'text',
26
+ position: { x: '5%', y: '40%', width: '90%' },
27
+ content: { text: '{content}', style: { fontSize: 32, color: '#333' } },
28
+ },
29
+ ],
30
+ };
31
+
32
+ describe('buildRegistry', () => {
33
+ it('merges custom templates onto the built-in registry', () => {
34
+ const reg = buildRegistry([heroDef]);
35
+ expect(reg.hero).toBeDefined();
36
+ expect(reg.title).toBeDefined(); // built-in survives
37
+ });
38
+
39
+ it('does not let custom templates shadow built-in names', () => {
40
+ const collide: CustomTemplateDefinition = {
41
+ ...heroDef,
42
+ name: 'title', // collides with built-in
43
+ };
44
+ const reg = buildRegistry([collide]);
45
+ // Built-in `title` is preserved — the collide entry is silently
46
+ // dropped (callers shouldn't have created it). We verify by
47
+ // checking the function reference is the original built-in's.
48
+ const builtIn = buildRegistry();
49
+ expect(reg.title).toBe(builtIn.title);
50
+ });
51
+ });
52
+
53
+ describe('expandDocBlocks with customTemplates', () => {
54
+ it('expands a doc-defined template name into resolved layers', () => {
55
+ const block: Block = {
56
+ id: 'b1',
57
+ startTime: 0,
58
+ duration: 1,
59
+ audioSegment: 0,
60
+ title: 'Welcome',
61
+ template: 'hero',
62
+ contents: [
63
+ {
64
+ type: 'paragraph',
65
+ children: [{ type: 'text', value: 'A small editor for big ideas.' }],
66
+ },
67
+ ],
68
+ };
69
+
70
+ const expanded = expandDocBlocks([block], { customTemplates: [heroDef] });
71
+ expect(expanded).toHaveLength(1);
72
+ const layers = expanded[0].layers ?? [];
73
+ expect(layers).toHaveLength(2);
74
+ expect((layers[0] as TextLayer).content.text).toBe('Welcome');
75
+ expect((layers[1] as TextLayer).content.text).toBe('A small editor for big ideas.');
76
+ });
77
+
78
+ it('preserves %-based positions so the SSR renderer can resolve them per viewport', () => {
79
+ const block: Block = {
80
+ id: 'b1',
81
+ startTime: 0,
82
+ duration: 1,
83
+ audioSegment: 0,
84
+ title: 'X',
85
+ template: 'hero',
86
+ };
87
+ const expanded = expandDocBlocks([block], { customTemplates: [heroDef] });
88
+ expect(expanded[0].layers![0].position.x).toBe('5%');
89
+ expect(expanded[0].layers![0].position.width).toBe('90%');
90
+ });
91
+
92
+ it('falls back to a no-layer block (with a warning) when the template is unknown', () => {
93
+ // Suppress the expected console.warn from `expandTemplateBlock`.
94
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
95
+ const block: Block = {
96
+ id: 'b1',
97
+ startTime: 0,
98
+ duration: 1,
99
+ audioSegment: 0,
100
+ title: 'Y',
101
+ template: 'nonexistent',
102
+ };
103
+ const expanded = expandDocBlocks([block], { customTemplates: [heroDef] });
104
+ expect(expanded[0].layers).toBeUndefined();
105
+ expect(warnSpy).toHaveBeenCalled();
106
+ warnSpy.mockRestore();
107
+ });
108
+ });
109
+
110
+ import { vi } from 'vitest';
@@ -13,7 +13,13 @@
13
13
  * This is shared code used by both site and efb-app doc renderers.
14
14
  */
15
15
 
16
- import type { Layer, ImageLayer, ShapeLayer, Animation } from '../../schemas/Doc.js';
16
+ import type {
17
+ Layer,
18
+ ImageLayer,
19
+ ImageTreatment,
20
+ ShapeLayer,
21
+ Animation,
22
+ } from '../../schemas/Doc.js';
17
23
  import type { AccentImage, AccentPosition } from '../../schemas/BlockTemplates.js';
18
24
 
19
25
  const PERCENTAGE_RE = /^(\d+(?:\.\d+)?)\s*%?$/;
@@ -125,28 +131,40 @@ export function getAccentLayout(position: AccentPosition): AccentLayout {
125
131
  * Create layers for an accent image.
126
132
  * Returns the image layer and any overlay/gradient layers needed.
127
133
  */
128
- export function createAccentLayers(accent: AccentImage, slideId: string): Layer[] {
134
+ export function createAccentLayers(
135
+ accent: AccentImage,
136
+ slideId: string,
137
+ treatment?: ImageTreatment,
138
+ ): Layer[] {
129
139
  const layers: Layer[] = [];
130
140
  const { src, alt, position, ambientMotion, credit, license } = accent;
131
141
 
132
142
  switch (position) {
133
143
  case 'left-strip':
134
- layers.push(createStripImage(src, alt, 'left', slideId, ambientMotion, credit, license));
144
+ layers.push(
145
+ createStripImage(src, alt, 'left', slideId, ambientMotion, credit, license, treatment),
146
+ );
135
147
  layers.push(createStripGradient('left', slideId));
136
148
  break;
137
149
 
138
150
  case 'right-strip':
139
- layers.push(createStripImage(src, alt, 'right', slideId, ambientMotion, credit, license));
151
+ layers.push(
152
+ createStripImage(src, alt, 'right', slideId, ambientMotion, credit, license, treatment),
153
+ );
140
154
  layers.push(createStripGradient('right', slideId));
141
155
  break;
142
156
 
143
157
  case 'bottom-strip':
144
- layers.push(createBottomStripImage(src, alt, slideId, ambientMotion, credit, license));
158
+ layers.push(
159
+ createBottomStripImage(src, alt, slideId, ambientMotion, credit, license, treatment),
160
+ );
145
161
  layers.push(createBottomStripGradient(slideId));
146
162
  break;
147
163
 
148
164
  case 'corner-inset':
149
- layers.push(createCornerInsetImage(src, alt, slideId, ambientMotion, credit, license));
165
+ layers.push(
166
+ createCornerInsetImage(src, alt, slideId, ambientMotion, credit, license, treatment),
167
+ );
150
168
  layers.push(createCornerVignette(slideId));
151
169
  break;
152
170
  }
@@ -165,6 +183,7 @@ function createStripImage(
165
183
  ambientMotion?: AccentImage['ambientMotion'],
166
184
  credit?: string,
167
185
  license?: string,
186
+ treatment?: ImageTreatment,
168
187
  ): ImageLayer {
169
188
  return {
170
189
  type: 'image',
@@ -175,6 +194,7 @@ function createStripImage(
175
194
  fit: 'cover',
176
195
  credit,
177
196
  license,
197
+ ...(treatment ? { treatment } : {}),
178
198
  },
179
199
  position: {
180
200
  x: side === 'left' ? 0 : `${100 - STRIP_SIZE}%`,
@@ -221,6 +241,7 @@ function createBottomStripImage(
221
241
  ambientMotion?: AccentImage['ambientMotion'],
222
242
  credit?: string,
223
243
  license?: string,
244
+ treatment?: ImageTreatment,
224
245
  ): ImageLayer {
225
246
  return {
226
247
  type: 'image',
@@ -231,6 +252,7 @@ function createBottomStripImage(
231
252
  fit: 'cover',
232
253
  credit,
233
254
  license,
255
+ ...(treatment ? { treatment } : {}),
234
256
  },
235
257
  position: {
236
258
  x: 0,
@@ -272,6 +294,7 @@ function createCornerInsetImage(
272
294
  ambientMotion?: AccentImage['ambientMotion'],
273
295
  credit?: string,
274
296
  license?: string,
297
+ treatment?: ImageTreatment,
275
298
  ): ImageLayer {
276
299
  return {
277
300
  type: 'image',
@@ -282,6 +305,7 @@ function createCornerInsetImage(
282
305
  fit: 'cover',
283
306
  credit,
284
307
  license,
308
+ ...(treatment ? { treatment } : {}),
285
309
  },
286
310
  position: {
287
311
  x: '70%',
@@ -31,3 +31,26 @@ export function createBackgroundLayer(id: string, fill: string): Layer {
31
31
  position: { x: 0, y: 0, width: '100%', height: '100%' },
32
32
  };
33
33
  }
34
+
35
+ /**
36
+ * Estimate the rendered height (px) of a wrapped text block.
37
+ *
38
+ * Templates are pure functions with no access to real text metrics, but
39
+ * stacked lockups (quote + attribution, fact + explanation + source) need
40
+ * to place each element relative to the previous one instead of at fixed
41
+ * slots — fixed slots leave 200px voids for short content and collide for
42
+ * long content. The 0.52 average-glyph-width factor is tuned for the
43
+ * sans/serif faces the built-in themes use; it only needs to be right
44
+ * within ~20% for spacing purposes.
45
+ */
46
+ export function estimateTextHeight(
47
+ text: string,
48
+ fontSizePx: number,
49
+ maxWidthPx: number,
50
+ lineHeight: number,
51
+ ): number {
52
+ const avgCharWidth = fontSizePx * 0.52;
53
+ const charsPerLine = Math.max(8, Math.floor(maxWidthPx / avgCharWidth));
54
+ const lines = Math.max(1, Math.ceil(text.length / charsPerLine));
55
+ return lines * fontSizePx * lineHeight;
56
+ }
@@ -10,31 +10,30 @@
10
10
 
11
11
  import type { Layer } from '../../schemas/Doc.js';
12
12
  import type { ComparisonBarInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
- import { resolveColorScheme, getThemeFont } from '../utils/themeUtils.js';
13
+ import {
14
+ resolveColorScheme,
15
+ getThemeFont,
16
+ themedFontSize,
17
+ themedSurfaceGradient,
18
+ } from '../utils/themeUtils.js';
15
19
 
16
20
  export function comparisonBar(input: ComparisonBarInput, context: TemplateContext): Layer[] {
17
- const { leftLabel, leftValue, rightLabel, rightValue, unit, colorScheme = 'blue' } = input;
18
- const { theme } = context;
21
+ const { leftLabel, rightLabel, unit, colorScheme = 'blue' } = input;
22
+ const { theme, viewport } = context;
19
23
  const colors = resolveColorScheme(context, colorScheme);
20
24
 
21
- const labelFontSize = scaledFontSize(28, context, false);
22
- const valueFontSize = scaledFontSize(48, context, true);
25
+ // Defensive: coerce non-finite values to 0. Callers should pass real numbers,
26
+ // but a NaN would otherwise render as literal "NaN" text and collapse every
27
+ // bar/label to a `NaN%` position, cramming the block against its left edge.
28
+ const leftValue = Number.isFinite(input.leftValue) ? input.leftValue : 0;
29
+ const rightValue = Number.isFinite(input.rightValue) ? input.rightValue : 0;
23
30
 
24
- // Calculate proportional bar widths (max bar = 65% of viewport width)
25
- const maxValue = Math.max(leftValue, rightValue, 1);
26
- const maxBarWidth = 65;
27
- const leftBarWidth = (leftValue / maxValue) * maxBarWidth;
28
- const rightBarWidth = (rightValue / maxValue) * maxBarWidth;
29
-
30
- // Bar positioning
31
- const barStartX = 15;
32
- const barHeight = 6; // % of viewport height
33
- const topBarY = 36;
34
- const bottomBarY = 58;
31
+ const labelFontSize = themedFontSize(28, context, false);
32
+ const valueFontSize = themedFontSize(48, context, true);
35
33
 
36
34
  // Format values for display
37
35
  const formatValue = (v: number): string => {
36
+ if (!Number.isFinite(v)) return '—';
38
37
  if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
39
38
  if (v >= 1_000) return `${(v / 1_000).toFixed(v >= 10_000 ? 0 : 1)}K`;
40
39
  return v.toLocaleString();
@@ -43,6 +42,22 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
43
42
  const leftDisplay = unit ? `${formatValue(leftValue)} ${unit}` : formatValue(leftValue);
44
43
  const rightDisplay = unit ? `${formatValue(rightValue)} ${unit}` : formatValue(rightValue);
45
44
 
45
+ // Bar positioning
46
+ const barStartX = 15;
47
+ const barHeight = 6; // % of viewport height
48
+ const topBarY = 36;
49
+ const bottomBarY = 58;
50
+
51
+ // Calculate proportional bar widths. The widest bar must leave room
52
+ // for its trailing value label inside a 96% safe area — otherwise a
53
+ // long label ("84 clarity score") runs off the right edge of the block.
54
+ const maxValue = Math.max(leftValue, rightValue, 1);
55
+ const longestLabelPx = Math.max(leftDisplay.length, rightDisplay.length) * valueFontSize * 0.58;
56
+ const labelWidthPct = (longestLabelPx / viewport.width) * 100;
57
+ const maxBarWidth = Math.max(20, Math.min(65, 96 - barStartX - labelWidthPct - 2));
58
+ const leftBarWidth = (leftValue / maxValue) * maxBarWidth;
59
+ const rightBarWidth = (rightValue / maxValue) * maxBarWidth;
60
+
46
61
  return [
47
62
  // Background
48
63
  {
@@ -50,7 +65,7 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
50
65
  id: 'bg',
51
66
  content: {
52
67
  shape: 'rect',
53
- fill: `linear-gradient(180deg, ${theme.colors.background} 0%, #0f1520 100%)`,
68
+ fill: themedSurfaceGradient(context, 180),
54
69
  },
55
70
  position: { x: 0, y: 0, width: '100%', height: '100%' },
56
71
  },
@@ -16,9 +16,8 @@
16
16
 
17
17
  import type { Layer } from '../../schemas/Doc.js';
18
18
  import type { TemplateContext } from '../../schemas/BlockTemplates.js';
19
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
20
19
  import type { StartBlockConfig } from '../../schemas/Doc.js';
21
- import { getThemeFont } from '../utils/themeUtils.js';
20
+ import { getThemeFont, themedFontSize, themedImageTreatment } from '../utils/themeUtils.js';
22
21
  import { mapAmbientMotion } from './accentImage.js';
23
22
 
24
23
  /**
@@ -39,18 +38,48 @@ export interface CoverBlockInput {
39
38
  heroCredit?: string;
40
39
  /** License identifier */
41
40
  heroLicense?: string;
41
+ /** Per-block override for the theme's photographic image grade. */
42
+ imageTreatment?: 'none' | 'mono' | 'duotone' | 'warm' | 'cool';
43
+ }
44
+
45
+ /** Base (pre-viewport-scale) font size for a full-size cover title. */
46
+ const COVER_TITLE_BASE_PX = 120;
47
+ /** Title length (chars) that still reads well at the full base size. */
48
+ const COVER_TITLE_COMFORTABLE_CHARS = 26;
49
+ /** Readability floor so very long titles never shrink to nothing. */
50
+ const COVER_TITLE_MIN_BASE_PX = 62;
51
+
52
+ /**
53
+ * Compute the base cover-title font size for a given title, shrinking long
54
+ * titles so their wrapped block fits the frame. Returns the pre-viewport-scale
55
+ * value expected by {@link themedFontSize}; short titles get the full base.
56
+ */
57
+ export function fitCoverTitleSize(title: string): number {
58
+ const len = title.trim().length;
59
+ if (len <= COVER_TITLE_COMFORTABLE_CHARS) return COVER_TITLE_BASE_PX;
60
+ const scaled = COVER_TITLE_BASE_PX * Math.sqrt(COVER_TITLE_COMFORTABLE_CHARS / len);
61
+ return Math.max(COVER_TITLE_MIN_BASE_PX, Math.round(scaled));
42
62
  }
43
63
 
44
64
  /**
45
65
  * Generate cover block layers from StartBlockConfig.
46
66
  */
47
67
  export function coverBlock(input: CoverBlockInput, context: TemplateContext): Layer[] {
68
+ const treatment = themedImageTreatment(context, input.imageTreatment);
48
69
  const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
49
70
  const { theme, layout } = context;
50
71
 
51
- // Scale font sizes for viewport - cover titles are larger than regular title blocks
52
- const titleFontSize = scaledFontSize(120, context, true);
53
- const subtitleFontSize = scaledFontSize(40, context, false);
72
+ // Scale font sizes for viewport - cover titles are larger than regular title blocks.
73
+ // The cover title wraps within maxTextWidth but does not otherwise shrink, so a long
74
+ // title (e.g. "Seattle: The Emerald City That Rebuilt Itself on Top of Itself") wrapped
75
+ // to three 120px lines overflows the frame and is clipped by the player controls.
76
+ // Auto-fit the base size to title length so long titles stay within the frame. The
77
+ // wrapped title's total height grows with fontSize² × length (line count ∝ length ×
78
+ // fontSize / width, line height ∝ fontSize), so scaling the base by √(threshold/length)
79
+ // keeps the wrapped block's area roughly constant past the threshold. Short titles are
80
+ // unaffected (scale clamps at 1).
81
+ const titleFontSize = themedFontSize(fitCoverTitleSize(title), context, true);
82
+ const subtitleFontSize = themedFontSize(40, context, false);
54
83
 
55
84
  const layers: Layer[] = [];
56
85
 
@@ -68,6 +97,7 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
68
97
  fit: 'cover',
69
98
  credit: heroCredit,
70
99
  license: heroLicense,
100
+ ...(treatment ? { treatment } : {}),
71
101
  },
72
102
  position: { x: 0, y: 0, width: '100%', height: '100%' },
73
103
  animation: imageAnimation,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Custom template factory.
3
+ *
4
+ * Turns a `CustomTemplateDefinition` (user-authored visual design with
5
+ * placeholder tokens) into a runtime `TemplateFunction` that can be
6
+ * dropped into the template registry alongside built-ins.
7
+ *
8
+ * The generated function's job is small: read the source `Block` from
9
+ * `context.block` and hand the definition's layers off to
10
+ * `resolveTokens` for placeholder substitution. The hard work (drag-
11
+ * to-position, token grammar, image lookup) happens in the designer
12
+ * UI and the resolver, not here.
13
+ */
14
+
15
+ import type { Block, Layer } from '../../schemas/Doc.js';
16
+ import type {
17
+ RawLayersInput,
18
+ TemplateContext,
19
+ TemplateFunction,
20
+ } from '../../schemas/BlockTemplates.js';
21
+ import type { CustomTemplateDefinition } from '../../schemas/CustomTemplates.js';
22
+ import { resolveTokens } from './tokens/resolveTokens.js';
23
+
24
+ /**
25
+ * Build a template function for a user-authored custom template.
26
+ *
27
+ * The returned function is registered under `def.name` in the merged
28
+ * registry (see `buildRegistry` in `templates/index.ts`). At expansion
29
+ * time it receives the standard `TemplateBlock` + `TemplateContext`
30
+ * pair; the context's `block` field carries the source markdown-derived
31
+ * block whose data drives token resolution.
32
+ *
33
+ * When `context.block` is missing (shouldn't happen in the normal
34
+ * pipeline but defensively handled), the layers are returned with
35
+ * tokens left unresolved — better visible placeholders than a thrown
36
+ * error.
37
+ */
38
+ export function makeCustomTemplateFn(
39
+ def: CustomTemplateDefinition,
40
+ ): TemplateFunction<RawLayersInput> {
41
+ return (input: RawLayersInput, context: TemplateContext): Layer[] => {
42
+ // `{attr:…}` reads the source block's `templateOverrides` /
43
+ // `templateData` / `metadata`. Prefer `context.block` (the raw
44
+ // block the expander attaches); fall back to `input` (the merged
45
+ // TemplateBlock, itself a Block superset) so attributes still
46
+ // resolve if `context.block` is ever absent.
47
+ const block = context.block ?? (input as unknown as Block | undefined);
48
+ if (!block) return def.layers.slice() as Layer[];
49
+ return resolveTokens(def.layers, block);
50
+ };
51
+ }