@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
@@ -137,7 +137,7 @@ describe('expandTemplateBlock', () => {
137
137
  expect(imageLayer!.position.height).toBe('90%');
138
138
  });
139
139
 
140
- it('expands rightFeature template with image on the right half and right-aligned text left', () => {
140
+ it('expands rightFeature template with image on the right half and left-aligned text on the left', () => {
141
141
  const block: TemplateBlock = {
142
142
  template: 'rightFeature',
143
143
  id: 'rf-1',
@@ -157,9 +157,11 @@ describe('expandTemplateBlock', () => {
157
157
  expect(imageLayer!.position.width).toBe('50%');
158
158
  const textLayers = layers.filter((l) => l.type === 'text');
159
159
  for (const t of textLayers) {
160
- expect(t.position.x).toBe('46%');
161
- expect(t.position.anchor).toBe('top-right');
162
- expect((t.content as { style: { textAlign?: string } }).style.textAlign).toBe('right');
160
+ // The mirror lives in the layout only: the text column occupies the
161
+ // left half, but the running text stays left-aligned for readability.
162
+ expect(t.position.x).toBe('6%');
163
+ expect(t.position.anchor).toBe('top-left');
164
+ expect((t.content as { style: { textAlign?: string } }).style.textAlign).toBe('left');
163
165
  }
164
166
  });
165
167
 
@@ -309,6 +311,145 @@ describe('expandDocBlocks', () => {
309
311
  expect((landscape[0].layers ?? []).length).toBeGreaterThan(0);
310
312
  expect((portrait[0].layers ?? []).length).toBeGreaterThan(0);
311
313
  });
314
+
315
+ describe('theme renderStyle wiring', () => {
316
+ const blocks = (): TemplateBlock[] => [
317
+ { template: 'title', id: 't-1', duration: 5, audioSegment: 0, title: 'One' },
318
+ {
319
+ template: 'imageWithCaption',
320
+ id: 't-2',
321
+ duration: 6,
322
+ audioSegment: 0,
323
+ imageSrc: 'photo.jpg',
324
+ imageAlt: 'photo',
325
+ caption: 'A caption',
326
+ },
327
+ ];
328
+
329
+ it('fills the theme default transition on blocks after the first, never block 0', () => {
330
+ const theme = {
331
+ ...DEFAULT_THEME,
332
+ renderStyle: {
333
+ ...DEFAULT_THEME.renderStyle,
334
+ defaultTransition: { type: 'dissolve' as const, duration: 1.1 },
335
+ },
336
+ };
337
+ const result = expandDocBlocks(blocks(), { theme });
338
+ expect(result[0].transition).toBeUndefined();
339
+ expect(result[1].transition).toEqual({ type: 'dissolve', duration: 1.1 });
340
+ });
341
+
342
+ it('never overrides an authored block transition', () => {
343
+ const theme = {
344
+ ...DEFAULT_THEME,
345
+ renderStyle: {
346
+ ...DEFAULT_THEME.renderStyle,
347
+ defaultTransition: { type: 'dissolve' as const },
348
+ },
349
+ };
350
+ const authored = blocks();
351
+ authored[1].transition = { type: 'cut' };
352
+ const result = expandDocBlocks(authored, { theme });
353
+ expect(result[1].transition).toEqual({ type: 'cut' });
354
+ });
355
+
356
+ it('scales template animation durations by theme animationSpeed', () => {
357
+ const theme = {
358
+ ...DEFAULT_THEME,
359
+ style: { ...DEFAULT_THEME.style, animationSpeed: 2.0 },
360
+ };
361
+ const base = expandDocBlocks(blocks());
362
+ const slowed = expandDocBlocks(blocks(), { theme });
363
+ const baseTitle = base[0].layers?.find((l) => l.id === 'title');
364
+ const slowedTitle = slowed[0].layers?.find((l) => l.id === 'title');
365
+ expect(slowedTitle?.animation?.duration).toBe((baseTitle?.animation?.duration ?? 0) * 2);
366
+ });
367
+
368
+ it('gives full-bleed imagery ambient Ken Burns when the theme opts in', () => {
369
+ const theme = {
370
+ ...DEFAULT_THEME,
371
+ renderStyle: { ...DEFAULT_THEME.renderStyle, ambientMotion: true },
372
+ };
373
+ const result = expandDocBlocks(blocks(), { theme });
374
+ const bg = result[1].layers?.find((l) => l.id === 'bg-image');
375
+ expect(bg?.animation?.type).toBe('slowZoom');
376
+ // Standard theme (ambientMotion: false) leaves the image static.
377
+ const plain = expandDocBlocks(blocks());
378
+ const plainBg = plain[1].layers?.find((l) => l.id === 'bg-image');
379
+ expect(plainBg?.animation).toBeUndefined();
380
+ });
381
+
382
+ it('themedEntrance: theme default text animation overrides entrance type, keeps timing', () => {
383
+ const theme = {
384
+ ...DEFAULT_THEME,
385
+ renderStyle: { ...DEFAULT_THEME.renderStyle, defaultTextAnimation: 'zoomIn' as const },
386
+ };
387
+ const result = expandDocBlocks(blocks(), { theme });
388
+ const title = result[0].layers?.find((l) => l.id === 'title');
389
+ expect(title?.animation?.type).toBe('zoomIn');
390
+ // Duration authored by the template is preserved (title uses 2s)
391
+ expect(title?.animation?.duration).toBe(2);
392
+ });
393
+
394
+ it('templateHints: dramatic entrance switches statHighlight timing', () => {
395
+ const statBlock: TemplateBlock[] = [
396
+ {
397
+ template: 'statHighlight',
398
+ id: 's-1',
399
+ duration: 5,
400
+ audioSegment: 0,
401
+ stat: '42%',
402
+ description: 'described',
403
+ },
404
+ ];
405
+ const theme = {
406
+ ...DEFAULT_THEME,
407
+ renderStyle: {
408
+ ...DEFAULT_THEME.renderStyle,
409
+ templateHints: { statHighlight: { entrance: 'dramatic' } },
410
+ },
411
+ };
412
+ const hinted = expandDocBlocks(statBlock, { theme });
413
+ const plain = expandDocBlocks(statBlock);
414
+ const hintedStat = hinted[0].layers?.find((l) => l.id === 'stat');
415
+ const plainStat = plain[0].layers?.find((l) => l.id === 'stat');
416
+ expect(hintedStat?.animation?.duration).toBe(0.4);
417
+ expect(plainStat?.animation?.duration).toBe(0.6);
418
+ });
419
+
420
+ it('theme persistentLayers render for docs without their own (wholesale precedence)', () => {
421
+ const theme = {
422
+ ...DEFAULT_THEME,
423
+ persistentLayers: {
424
+ bottomLayers: [
425
+ {
426
+ template: 'solidBackground' as const,
427
+ config: { type: 'solidBackground' as const, color: '#112233' },
428
+ },
429
+ ],
430
+ },
431
+ };
432
+ const inherited = expandDocBlocks(blocks(), { theme });
433
+ expect(inherited[0].layers?.[0]?.id).toContain('solid');
434
+
435
+ // Doc's own persistent layers win wholesale
436
+ const docOwn = expandDocBlocks(blocks(), {
437
+ theme,
438
+ persistentLayers: {
439
+ bottomLayers: [
440
+ {
441
+ template: 'solidBackground' as const,
442
+ config: { type: 'solidBackground' as const, color: '#445566' },
443
+ },
444
+ ],
445
+ },
446
+ });
447
+ const first = docOwn[0].layers?.[0];
448
+ expect(first && 'content' in first && (first.content as { fill?: string }).fill).toBe(
449
+ '#445566',
450
+ );
451
+ });
452
+ });
312
453
  });
313
454
 
314
455
  describe('DEFAULT_THEME', () => {
@@ -71,6 +71,48 @@ describe('compileTheme', () => {
71
71
  });
72
72
  });
73
73
 
74
+ describe('compileTheme base inheritance', () => {
75
+ it('inherits renderStyle and colorSchemes from a chosen base', () => {
76
+ const base = resolveTheme('cinematic');
77
+ const theme = compileTheme(
78
+ { id: 'brand', name: 'Brand', seedColors: { primary: '#3182ce' } },
79
+ { base },
80
+ );
81
+ expect(theme.basedOn).toBe('cinematic');
82
+ // renderStyle inherited from the base (not the starter's 'standard')
83
+ expect(theme.renderStyle.name).toBe(base.renderStyle.name);
84
+ // colorSchemes inherited wholesale from the base
85
+ expect(theme.colorSchemes).toEqual(base.colorSchemes);
86
+ // ...but colors are still derived from the user's seed
87
+ expect(theme.colors.primary).toBe('#3182ce');
88
+ });
89
+
90
+ it('replaces colorSchemes wholesale when the partial supplies them (removal sticks)', () => {
91
+ const base = resolveTheme('cinematic');
92
+ const theme = compileTheme(
93
+ {
94
+ id: 'brand',
95
+ name: 'Brand',
96
+ seedColors: { primary: '#3182ce' },
97
+ colorSchemes: { onlyOne: { bg: '#111111', text: '#eeeeee', accent: '#3182ce' } },
98
+ },
99
+ { base },
100
+ );
101
+ // Only the scheme the user kept — the base's blue/green/… are gone.
102
+ expect(Object.keys(theme.colorSchemes)).toEqual(['onlyOne']);
103
+ });
104
+
105
+ it('defaults to the neutral starter when no base is given (basedOn unset)', () => {
106
+ const theme = compileTheme({
107
+ id: 'brand',
108
+ name: 'Brand',
109
+ seedColors: { primary: '#3182ce' },
110
+ });
111
+ expect(theme.basedOn).toBeUndefined();
112
+ expect(theme.renderStyle.name).toBe('standard');
113
+ });
114
+ });
115
+
74
116
  describe('parseTheme / serializeTheme round-trip', () => {
75
117
  it('serialize → parse is lossless for built-ins', () => {
76
118
  for (const [id, theme] of Object.entries(THEMES)) {
@@ -105,6 +105,12 @@ describe('validateTheme', () => {
105
105
  expect(validateTheme(t).valid).toBe(true);
106
106
  });
107
107
 
108
+ it('accepts PowerPoint-style default transitions', () => {
109
+ const t = clone(DEFAULT_THEME);
110
+ t.renderStyle.defaultTransition = { type: 'checkerboard', duration: 1 };
111
+ expect(validateTheme(t).valid).toBe(true);
112
+ });
113
+
108
114
  it('rejects seedColors without primary', () => {
109
115
  const bad = clone(DEFAULT_THEME);
110
116
  bad.seedColors = { accent: '#63b3ed' } as never;
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import {
3
+ applyTransform,
4
+ resolveTransformStyle,
5
+ registerTransformStyle,
6
+ unregisterTransformStyle,
7
+ getTransformStyleIds,
8
+ } from '../transform/index.js';
9
+ import type { TransformStyleConfig } from '../transform/index.js';
10
+ import type { Doc, Block } from '../schemas/Doc.js';
11
+ import { isTemplateBlock } from '../schemas/BlockTemplates.js';
12
+
13
+ function paragraphBlock(id: string, title: string, text: string, duration = 20): Block {
14
+ return {
15
+ id,
16
+ startTime: 0,
17
+ duration,
18
+ audioSegment: 0,
19
+ title,
20
+ contents: [
21
+ {
22
+ type: 'paragraph',
23
+ children: [{ type: 'text', value: text }],
24
+ },
25
+ ],
26
+ };
27
+ }
28
+
29
+ /** Body with a strong quote extraction. */
30
+ const QUOTED =
31
+ 'The ranger paused at the overlook. "The mountain decides who climbs it," she said quietly. ' +
32
+ 'Every year thousands of visitors make the same journey up the winding road to Paradise.';
33
+
34
+ function sampleDoc(): Doc {
35
+ return {
36
+ articleId: 'transform-v2-test',
37
+ duration: 60,
38
+ blocks: [
39
+ paragraphBlock('b1', 'The Mountain', QUOTED),
40
+ paragraphBlock(
41
+ 'b2',
42
+ 'Visitors',
43
+ 'In 2024 the park recorded 2.5 million visitors, a 40% increase over the prior decade. ' +
44
+ 'Rangers attribute the growth to social media and improved road access.',
45
+ ),
46
+ ],
47
+ audio: { segments: [] },
48
+ };
49
+ }
50
+
51
+ describe('transform style contract v2', () => {
52
+ afterEach(() => unregisterTransformStyle('custom-test'));
53
+
54
+ it('suggestedThemeId applies only when the doc declares no theme', () => {
55
+ const { doc } = applyTransform(sampleDoc(), 'magazine');
56
+ expect(doc.themeId).toBe('magazine');
57
+
58
+ const themed = { ...sampleDoc(), themeId: 'gezellig' };
59
+ expect(applyTransform(themed, 'magazine').doc.themeId).toBe('gezellig');
60
+
61
+ const viaOption = applyTransform(sampleDoc(), 'magazine', { themeId: 'bold' });
62
+ expect(viaOption.doc.themeId).toBe('bold');
63
+ });
64
+
65
+ it('templateMap remaps quote extractions (magazine → pullQuote with accent image as background)', () => {
66
+ const images = [{ src: 'photo.jpg', alt: 'a photo' }];
67
+ const { doc } = applyTransform(sampleDoc(), 'magazine', { images });
68
+ const templates = doc.blocks.filter(isTemplateBlock).map((b) => b.template);
69
+ // The quote extraction maps to pullQuote (magazine's templateMap)
70
+ // because an image was available to serve as the background.
71
+ expect(templates).toContain('pullQuote');
72
+ expect(templates).not.toContain('quote');
73
+ });
74
+
75
+ it('templateMap remap falls back to the default when the target needs an unavailable image', () => {
76
+ const { doc } = applyTransform(sampleDoc(), 'magazine', { images: [] });
77
+ const templates = doc.blocks.filter(isTemplateBlock).map((b) => b.template);
78
+ expect(templates).toContain('quote');
79
+ expect(templates).not.toContain('pullQuote');
80
+ });
81
+
82
+ it('pacing adds intro/outro bookends (narrative)', () => {
83
+ const { doc } = applyTransform(sampleDoc(), 'narrative');
84
+ const first = doc.blocks[0];
85
+ const last = doc.blocks[doc.blocks.length - 1];
86
+ expect(first.template).toBe('title');
87
+ expect(first.title ?? (first as { title?: string }).title).toBeDefined();
88
+ expect(last.template).toBe('sectionHeader');
89
+ });
90
+
91
+ it('registerTransformStyle adds a resolvable style that wins over unknown fallback', () => {
92
+ const custom: TransformStyleConfig = {
93
+ ...resolveTransformStyle('minimal'),
94
+ id: 'custom-test',
95
+ name: 'Custom Test',
96
+ description: 'test style',
97
+ };
98
+ registerTransformStyle(custom);
99
+ expect(getTransformStyleIds()).toContain('custom-test');
100
+ expect(resolveTransformStyle('custom-test').name).toBe('Custom Test');
101
+ unregisterTransformStyle('custom-test');
102
+ // Unknown id falls back to the default (documentary)
103
+ expect(resolveTransformStyle('custom-test').id).toBe('documentary');
104
+ });
105
+
106
+ it("accepts 'dataDriven' as an alias for the hyphenated registry id", () => {
107
+ expect(resolveTransformStyle('dataDriven').id).toBe('data-driven');
108
+ });
109
+
110
+ it('budget.slidesPerMinute caps promotions on long docs', () => {
111
+ const manyBlocks: Doc = {
112
+ articleId: 'budget-test',
113
+ duration: 60,
114
+ blocks: Array.from({ length: 12 }, (_, i) =>
115
+ paragraphBlock(
116
+ `b${i}`,
117
+ `Section ${i}`,
118
+ `In 2024 the park recorded ${(i + 2) * 7}% growth across all visitor categories nationwide.`,
119
+ 5,
120
+ ),
121
+ ),
122
+ audio: { segments: [] },
123
+ };
124
+ const unbudgeted = resolveTransformStyle('data-driven');
125
+ const budgeted: TransformStyleConfig = {
126
+ ...unbudgeted,
127
+ id: 'custom-test',
128
+ budget: { slidesPerMinute: 2 },
129
+ };
130
+ registerTransformStyle(budgeted);
131
+ const loose = applyTransform(manyBlocks, 'data-driven');
132
+ const tight = applyTransform(manyBlocks, 'custom-test');
133
+ expect(tight.stats.transformedBlocks).toBeLessThan(loose.stats.transformedBlocks);
134
+ // 12 blocks x 5s = 60s → 2/minute → at most 2 promotions
135
+ expect(tight.stats.transformedBlocks).toBeLessThanOrEqual(2);
136
+ });
137
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ normalizeTransitionType,
4
+ normalizeTransitionDirection,
5
+ isTransitionType,
6
+ TRANSITION_TYPES,
7
+ } from '../schemas/Transitions';
8
+
9
+ describe('normalizeTransitionType', () => {
10
+ it('returns exact canonical types unchanged', () => {
11
+ expect(normalizeTransitionType('fade')).toBe('fade');
12
+ expect(normalizeTransitionType('checkerboard')).toBe('checkerboard');
13
+ expect(normalizeTransitionType('slideLeft')).toBe('slideLeft');
14
+ });
15
+
16
+ it('resolves case / punctuation variants of canonical types', () => {
17
+ // These have NO explicit alias entry — they round-trip through the
18
+ // case-insensitive `NORMALIZED_TRANSITION_TYPES` fallback. This guards the
19
+ // removal of the redundant self-aliases (checkerboard/shape/uncover).
20
+ expect(normalizeTransitionType('Checkerboard')).toBe('checkerboard');
21
+ expect(normalizeTransitionType('CHECKERBOARD')).toBe('checkerboard');
22
+ expect(normalizeTransitionType('Shape')).toBe('shape');
23
+ expect(normalizeTransitionType('Uncover')).toBe('uncover');
24
+ expect(normalizeTransitionType('fly-through')).toBe('flyThrough');
25
+ });
26
+
27
+ it('resolves explicit aliases (including plurals)', () => {
28
+ // Aliases only fire for spellings that are NOT themselves canonical types
29
+ // (an exact type returns early). `ferris` IS a canonical type, so its
30
+ // sameness with `ferrisWheel` comes from the visual map, not here.
31
+ expect(normalizeTransitionType('none')).toBe('cut');
32
+ expect(normalizeTransitionType('checkerboards')).toBe('checkerboard');
33
+ expect(normalizeTransitionType('shapes')).toBe('shape');
34
+ expect(normalizeTransitionType('flythru')).toBe('flyThrough');
35
+ });
36
+
37
+ it('returns null for unknown values', () => {
38
+ expect(normalizeTransitionType('definitely-not-a-transition')).toBeNull();
39
+ expect(normalizeTransitionType('')).toBeNull();
40
+ });
41
+
42
+ it('every canonical type normalizes to itself', () => {
43
+ for (const type of TRANSITION_TYPES) {
44
+ expect(normalizeTransitionType(type), type).toBe(type);
45
+ expect(isTransitionType(type), type).toBe(true);
46
+ }
47
+ });
48
+ });
49
+
50
+ describe('normalizeTransitionDirection', () => {
51
+ it('resolves cardinal directions and shorthands', () => {
52
+ expect(normalizeTransitionDirection('left')).toBe('left');
53
+ expect(normalizeTransitionDirection('R')).toBe('right');
54
+ expect(normalizeTransitionDirection('horizontal')).toBe('horizontal');
55
+ expect(normalizeTransitionDirection('v')).toBe('vertical');
56
+ });
57
+
58
+ it('returns null for unknown directions', () => {
59
+ expect(normalizeTransitionDirection('sideways')).toBeNull();
60
+ });
61
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateMarkdownSource } from '../doc/validate.js';
3
+
4
+ function codes(md: string, options?: Parameters<typeof validateMarkdownSource>[1]): string[] {
5
+ return validateMarkdownSource(md, options).diagnostics.map((d) => d.code);
6
+ }
7
+
8
+ describe('validateMarkdownSource — templates', () => {
9
+ it('passes a clean document', () => {
10
+ const result = validateMarkdownSource('# Title\n\nBody.\n\n## Section {[quote]}\n\nA quote.\n');
11
+ expect(result.diagnostics).toEqual([]);
12
+ expect(result.errorCount).toBe(0);
13
+ expect(result.warningCount).toBe(0);
14
+ });
15
+
16
+ it('flags unknown templates with a did-you-mean suggestion', () => {
17
+ const result = validateMarkdownSource('## Gallery {[photGrid columns=3]}');
18
+ expect(result.warningCount).toBe(1);
19
+ const d = result.diagnostics[0];
20
+ expect(d.code).toBe('unknown-template');
21
+ expect(d.message).toContain('photGrid');
22
+ expect(d.message).toContain('Did you mean "photoGrid"?');
23
+ expect(d.line).toBe(1);
24
+ });
25
+
26
+ it('accepts legacy template aliases', () => {
27
+ expect(codes('## X {[titleBlock]}')).toEqual([]);
28
+ });
29
+
30
+ it('accepts extra templates supplied by the host app', () => {
31
+ expect(codes('## X {[hero]}', { extraTemplates: ['hero'] })).toEqual([]);
32
+ expect(codes('## X {[hero]}')).toEqual(['unknown-template']);
33
+ });
34
+ });
35
+
36
+ describe('validateMarkdownSource — unparsed annotations', () => {
37
+ it('flags {[…]} text in body content (annotations are heading-only)', () => {
38
+ const result = validateMarkdownSource('## X\n\n- {[imageWithCaption src=photo.jpg]}\n');
39
+ const d = result.diagnostics.find((x) => x.code === 'unparsed-annotation');
40
+ expect(d).toBeDefined();
41
+ expect(d!.message).toContain('headings');
42
+ expect(d!.line).toBe(3);
43
+ });
44
+
45
+ it('flags non-trailing {[…]} text left in a heading', () => {
46
+ const result = validateMarkdownSource('## The {[chart]} section\n');
47
+ expect(result.diagnostics.map((d) => d.code)).toContain('unparsed-annotation');
48
+ });
49
+
50
+ it('does not flag resolved inline icons or code blocks', () => {
51
+ const md = '## X\n\nUse {[github]} for code.\n\n```\nliteral {[notathing]}\n```\n';
52
+ expect(codes(md)).toEqual([]);
53
+ });
54
+ });
55
+
56
+ describe('validateMarkdownSource — attributes and structure', () => {
57
+ it('surfaces malformed heading-attribute values', () => {
58
+ const result = validateMarkdownSource('## X {#a x=abc}');
59
+ const d = result.diagnostics.find((x) => x.code === 'invalid-attribute');
60
+ expect(d).toBeDefined();
61
+ expect(d!.message).toContain('x');
62
+ });
63
+
64
+ it('includes conversion diagnostics (duplicate ids, bad fences)', () => {
65
+ const md = '## A {#dup}\n\n## B {#dup}\n\n```json data\nnot json\n```\n';
66
+ const result = validateMarkdownSource(md);
67
+ const found = result.diagnostics.map((d) => d.code);
68
+ expect(found).toContain('duplicate-id');
69
+ expect(found).toContain('data-fence-parse');
70
+ expect(result.errorCount).toBe(2);
71
+ });
72
+ });
73
+
74
+ describe('validateMarkdownSource — assets', () => {
75
+ it('flags relative image references missing from the asset set', () => {
76
+ const result = validateMarkdownSource('## X\n\n![hero](images/hero.jpg)\n', {
77
+ assets: new Set<string>(),
78
+ });
79
+ const d = result.diagnostics.find((x) => x.code === 'missing-asset');
80
+ expect(d).toBeDefined();
81
+ expect(d!.message).toContain('images/hero.jpg');
82
+ });
83
+
84
+ it('passes when the asset exists, and skips absolute/external URLs', () => {
85
+ const md =
86
+ '## X\n\n![a](images/hero.jpg)\n\n![b](https://example.com/x.jpg)\n\n![c](data:image/png;base64,AA==)\n';
87
+ expect(codes(md, { assets: new Set(['images/hero.jpg']) })).toEqual([]);
88
+ });
89
+
90
+ it('checks media params from {[…]} annotations', () => {
91
+ const result = validateMarkdownSource('## X {[imageWithCaption src=missing.png]}', {
92
+ assets: new Set<string>(),
93
+ });
94
+ expect(result.diagnostics.map((d) => d.code)).toContain('missing-asset');
95
+ });
96
+
97
+ it('skips asset checks entirely when no asset set is provided', () => {
98
+ expect(codes('## X\n\n![hero](images/hero.jpg)\n')).toEqual([]);
99
+ });
100
+ });