@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
@@ -46,6 +46,78 @@ describe('imageEdit/persistence', () => {
46
46
  );
47
47
  await expect(readImageEditDoc(sidecar)).rejects.toThrow(/unsupported schema version/);
48
48
  });
49
+
50
+ async function writeRaw(sidecar: ReturnType<typeof scopeContainer>, payload: unknown) {
51
+ await sidecar.writeFile('state.json', new TextEncoder().encode(JSON.stringify(payload)));
52
+ }
53
+
54
+ it('rejects layer that is not an object', async () => {
55
+ const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
56
+ await writeRaw(sidecar, { version: 1, canvas: { width: 1, height: 1 }, layers: [null] });
57
+ await expect(readImageEditDoc(sidecar)).rejects.toThrow(/layers\[0\] must be an object/);
58
+ });
59
+
60
+ it('rejects layer with missing id', async () => {
61
+ const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
62
+ await writeRaw(sidecar, {
63
+ version: 1,
64
+ canvas: { width: 1, height: 1 },
65
+ layers: [{ type: 'text', position: { x: 0, y: 0 }, content: { text: 'hi', style: {} } }],
66
+ });
67
+ await expect(readImageEditDoc(sidecar)).rejects.toThrow(/must have a string id/);
68
+ });
69
+
70
+ it('rejects layer with invalid type', async () => {
71
+ const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
72
+ await writeRaw(sidecar, {
73
+ version: 1,
74
+ canvas: { width: 1, height: 1 },
75
+ layers: [{ id: 'x', type: 'video', position: { x: 0, y: 0 }, content: {} }],
76
+ });
77
+ await expect(readImageEditDoc(sidecar)).rejects.toThrow(
78
+ /type must be one of image\|text\|shape/,
79
+ );
80
+ });
81
+
82
+ it('rejects image layer with missing content.src', async () => {
83
+ const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
84
+ await writeRaw(sidecar, {
85
+ version: 1,
86
+ canvas: { width: 1, height: 1 },
87
+ layers: [{ id: 'x', type: 'image', position: { x: 0, y: 0 }, content: { alt: 'test' } }],
88
+ });
89
+ await expect(readImageEditDoc(sidecar)).rejects.toThrow(/content\.src must be a string/);
90
+ });
91
+
92
+ it('rejects text layer with missing content.text', async () => {
93
+ const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
94
+ await writeRaw(sidecar, {
95
+ version: 1,
96
+ canvas: { width: 1, height: 1 },
97
+ layers: [{ id: 'x', type: 'text', position: { x: 0, y: 0 }, content: { style: {} } }],
98
+ });
99
+ await expect(readImageEditDoc(sidecar)).rejects.toThrow(/content\.text must be a string/);
100
+ });
101
+
102
+ it('rejects shape layer with missing content.shape', async () => {
103
+ const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
104
+ await writeRaw(sidecar, {
105
+ version: 1,
106
+ canvas: { width: 1, height: 1 },
107
+ layers: [{ id: 'x', type: 'shape', position: { x: 0, y: 0 }, content: {} }],
108
+ });
109
+ await expect(readImageEditDoc(sidecar)).rejects.toThrow(/content\.shape must be a string/);
110
+ });
111
+
112
+ it('rejects layer with missing position', async () => {
113
+ const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
114
+ await writeRaw(sidecar, {
115
+ version: 1,
116
+ canvas: { width: 1, height: 1 },
117
+ layers: [{ id: 'x', type: 'shape', content: { shape: 'rect' } }],
118
+ });
119
+ await expect(readImageEditDoc(sidecar)).rejects.toThrow(/position must have/);
120
+ });
49
121
  });
50
122
 
51
123
  describe('imageEdit/versions', () => {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ hasIconMarker,
4
+ iconClass,
5
+ iconMarker,
6
+ splitIconMarkers,
7
+ stripIconMarkers,
8
+ } from '../icons/inlineIconMarker';
9
+
10
+ describe('inline icon markers', () => {
11
+ it('encodes icons with a private-use sentinel', () => {
12
+ expect(iconMarker('solid', 'rocket')).toBe('\uE000solid:rocket\uE000');
13
+ });
14
+
15
+ it('detects and strips encoded markers from plain-text projections', () => {
16
+ const text = `Launch ${iconMarker('solid', 'rocket')} now`;
17
+
18
+ expect(hasIconMarker(text)).toBe(true);
19
+ expect(stripIconMarkers(text)).toBe('Launch now');
20
+ });
21
+
22
+ it('splits marker-bearing text into text and icon runs', () => {
23
+ const text = `A ${iconMarker('regular', 'star')} and ${iconMarker('brands', 'github')}`;
24
+
25
+ expect(splitIconMarkers(text)).toEqual([
26
+ { type: 'text', text: 'A ' },
27
+ { type: 'icon', family: 'regular', name: 'star' },
28
+ { type: 'text', text: ' and ' },
29
+ { type: 'icon', family: 'brands', name: 'github' },
30
+ ]);
31
+ });
32
+
33
+ it('builds FontAwesome class names', () => {
34
+ expect(iconClass('solid', 'rocket')).toBe('fa-solid fa-rocket');
35
+ });
36
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ BASE_INPUT_DESCRIPTORS,
4
+ TEMPLATE_INPUT_DESCRIPTORS,
5
+ coerceTemplateParams,
6
+ lintTemplateParams,
7
+ } from '../doc/templates/inputDescriptors';
8
+ import { templateRegistry } from '../doc/templates/index';
9
+
10
+ describe('coerceTemplateParams', () => {
11
+ it('coerces number keys, leaving the raw string on failure', () => {
12
+ expect(coerceTemplateParams('map', { zoom: '9' }).input).toEqual({ zoom: 9 });
13
+
14
+ const bad = coerceTemplateParams('map', { zoom: 'abc' });
15
+ expect(bad.input).toEqual({ zoom: 'abc' });
16
+ expect(bad.warnings).toHaveLength(1);
17
+ expect(bad.warnings[0]).toContain('zoom');
18
+ });
19
+
20
+ it('coerces boolean keys (true/empty → true, false → false)', () => {
21
+ const { input } = coerceTemplateParams('imageWithCaption', { isTitle: 'true' });
22
+ expect(input.isTitle).toBe(true);
23
+ // A bare flag `{[… isTitle]}` parses to an empty value → true.
24
+ expect(coerceTemplateParams('imageWithCaption', { isTitle: '' }).input.isTitle).toBe(true);
25
+ expect(coerceTemplateParams('imageWithCaption', { isTitle: 'false' }).input.isTitle).toBe(
26
+ false,
27
+ );
28
+
29
+ const bad = coerceTemplateParams('imageWithCaption', { isTitle: 'maybe' });
30
+ expect(bad.input.isTitle).toBe('maybe');
31
+ expect(bad.warnings).toHaveLength(1);
32
+ });
33
+
34
+ it('coerces latLng into { lat, lng }, warning on malformed input', () => {
35
+ expect(coerceTemplateParams('map', { center: '47.6,-122.3' }).input).toEqual({
36
+ center: { lat: 47.6, lng: -122.3 },
37
+ });
38
+
39
+ const bad = coerceTemplateParams('map', { center: 'nope' });
40
+ expect(bad.input).toEqual({ center: 'nope' });
41
+ expect(bad.warnings[0]).toContain('center');
42
+ });
43
+
44
+ it('coerces labeledPair into { label, sublabel? }', () => {
45
+ expect(coerceTemplateParams('twoColumn', { left: 'Espresso|Bold' }).input).toEqual({
46
+ left: { label: 'Espresso', sublabel: 'Bold' },
47
+ });
48
+ // No separator → label only, no sublabel key.
49
+ expect(coerceTemplateParams('twoColumn', { right: 'Filter' }).input).toEqual({
50
+ right: { label: 'Filter' },
51
+ });
52
+ });
53
+
54
+ it('coerces stringList by splitting on commas and trimming', () => {
55
+ expect(coerceTemplateParams('photoGrid', { images: 'a.jpg, b.jpg ,c.jpg' }).input).toEqual({
56
+ images: ['a.jpg', 'b.jpg', 'c.jpg'],
57
+ });
58
+ });
59
+
60
+ it('passes unknown keys through unchanged as strings (never lossy)', () => {
61
+ const { input, warnings } = coerceTemplateParams('map', { wobble: 'yes', center: '1,2' });
62
+ expect(input.wobble).toBe('yes');
63
+ expect(input.center).toEqual({ lat: 1, lng: 2 });
64
+ expect(warnings).toEqual([]);
65
+ });
66
+
67
+ it('passes everything through for a template with no descriptors', () => {
68
+ expect(coerceTemplateParams('sectionHeader', { anything: '5' }).input).toEqual({
69
+ anything: '5',
70
+ });
71
+ });
72
+
73
+ it('resolves legacy template aliases before coercing', () => {
74
+ expect(coerceTemplateParams('mapBlock', { zoom: '7' }).input).toEqual({ zoom: 7 });
75
+ });
76
+
77
+ it('coerces shared base inputs (useTopLayer boolean)', () => {
78
+ expect(coerceTemplateParams('title', { useTopLayer: 'false' }).input.useTopLayer).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('lintTemplateParams', () => {
83
+ it('returns [] for a template without descriptors', () => {
84
+ expect(lintTemplateParams('sectionHeader', { foo: 'bar' })).toEqual([]);
85
+ });
86
+
87
+ it('flags an unknown input with a did-you-mean suggestion', () => {
88
+ const findings = lintTemplateParams('map', { centre: '1,2' });
89
+ const unknown = findings.find((f) => f.kind === 'unknown-input');
90
+ expect(unknown).toBeDefined();
91
+ expect(unknown!.key).toBe('centre');
92
+ expect(unknown!.suggestion).toBe('center');
93
+ expect(unknown!.message).toContain('Did you mean "center"?');
94
+ });
95
+
96
+ it('does not flag block-meta keys carried on the same annotation', () => {
97
+ const findings = lintTemplateParams('title', { duration: '8', transition: 'fade' });
98
+ expect(findings.filter((f) => f.kind === 'unknown-input')).toEqual([]);
99
+ });
100
+
101
+ it('flags a value outside a closed enum', () => {
102
+ const findings = lintTemplateParams('map', { center: '1,2', mapStyle: 'nope' });
103
+ const invalid = findings.find((f) => f.kind === 'invalid-input-value');
104
+ expect(invalid).toBeDefined();
105
+ expect(invalid!.key).toBe('mapStyle');
106
+ expect(invalid!.message).toContain('terrain');
107
+ });
108
+
109
+ it('flags a value that fails coercion (bad latLng)', () => {
110
+ const findings = lintTemplateParams('map', { center: 'garbage' });
111
+ expect(findings.some((f) => f.kind === 'invalid-input-value' && f.key === 'center')).toBe(true);
112
+ });
113
+
114
+ it('flags missing required inputs', () => {
115
+ const findings = lintTemplateParams('twoColumn', {});
116
+ const missing = findings
117
+ .filter((f) => f.kind === 'missing-input')
118
+ .map((f) => f.key)
119
+ .sort();
120
+ expect(missing).toEqual(['left', 'right']);
121
+ });
122
+
123
+ it('does not flag a required input that is present', () => {
124
+ const findings = lintTemplateParams('map', { center: '1,2' });
125
+ expect(findings.filter((f) => f.kind === 'missing-input')).toEqual([]);
126
+ });
127
+
128
+ it('accepts a valid enum value', () => {
129
+ const findings = lintTemplateParams('map', { center: '1,2', mapStyle: 'satellite' });
130
+ expect(findings).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe('descriptor registry shape', () => {
135
+ it('base descriptors are declared with unique keys', () => {
136
+ const keys = BASE_INPUT_DESCRIPTORS.map((d) => d.key);
137
+ expect(new Set(keys).size).toBe(keys.length);
138
+ });
139
+
140
+ it('has no orphan descriptors — every keyed template exists in the registry', () => {
141
+ for (const template of Object.keys(TEMPLATE_INPUT_DESCRIPTORS)) {
142
+ expect(templateRegistry, `orphan descriptor for "${template}"`).toHaveProperty(template);
143
+ }
144
+ });
145
+
146
+ it('every template descriptor has unique keys and a description', () => {
147
+ for (const [template, descriptors] of Object.entries(TEMPLATE_INPUT_DESCRIPTORS)) {
148
+ const keys = descriptors.map((d) => d.key);
149
+ expect(new Set(keys).size, `duplicate key in ${template}`).toBe(keys.length);
150
+ for (const d of descriptors) {
151
+ expect(d.description.length, `empty description for ${template}.${d.key}`).toBeGreaterThan(
152
+ 0,
153
+ );
154
+ // values / valueHint are mutually exclusive.
155
+ expect(
156
+ !(d.values && d.valueHint),
157
+ `${template}.${d.key} has both values and valueHint`,
158
+ ).toBe(true);
159
+ }
160
+ }
161
+ });
162
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildJsonFormTokens, resolveJsonFormTheme } from '../jsonForm/index.js';
3
+ import { DARK_SURFACE, LIGHT_SURFACE, DEFAULT_THEME, applySurface } from '../schemas/index.js';
4
+
5
+ const EXPECTED_SUFFIXES = [
6
+ 'bg',
7
+ 'text',
8
+ 'muted',
9
+ 'primary',
10
+ 'accent',
11
+ 'warning',
12
+ 'border',
13
+ 'input-bg',
14
+ 'title-font',
15
+ 'body-font',
16
+ 'mono-font',
17
+ 'radius',
18
+ ];
19
+
20
+ describe('buildJsonFormTokens', () => {
21
+ it('emits the full token set for the viewer prefix', () => {
22
+ const tokens = buildJsonFormTokens(DEFAULT_THEME, LIGHT_SURFACE, { prefix: '--squisq-json' });
23
+ for (const suffix of EXPECTED_SUFFIXES) {
24
+ expect(tokens).toHaveProperty(`--squisq-json-${suffix}`);
25
+ }
26
+ expect(Object.keys(tokens)).toHaveLength(EXPECTED_SUFFIXES.length);
27
+ });
28
+
29
+ it('emits the full token set for the editor prefix', () => {
30
+ const tokens = buildJsonFormTokens(DEFAULT_THEME, LIGHT_SURFACE, {
31
+ prefix: '--squisq-jsonform',
32
+ });
33
+ for (const suffix of EXPECTED_SUFFIXES) {
34
+ expect(tokens).toHaveProperty(`--squisq-jsonform-${suffix}`);
35
+ }
36
+ expect(Object.keys(tokens)).toHaveLength(EXPECTED_SUFFIXES.length);
37
+ });
38
+
39
+ it('resolves colors from the applied surface (light vs dark differ)', () => {
40
+ const light = buildJsonFormTokens(DEFAULT_THEME, LIGHT_SURFACE, { prefix: '--squisq-json' });
41
+ const dark = buildJsonFormTokens(DEFAULT_THEME, DARK_SURFACE, { prefix: '--squisq-json' });
42
+ const lightTheme = applySurface(DEFAULT_THEME, LIGHT_SURFACE);
43
+ const darkTheme = applySurface(DEFAULT_THEME, DARK_SURFACE);
44
+ expect(light['--squisq-json-bg']).toBe(lightTheme.colors.background);
45
+ expect(dark['--squisq-json-bg']).toBe(darkTheme.colors.background);
46
+ // The two surfaces should not produce an identical background.
47
+ expect(light['--squisq-json-bg']).not.toBe(dark['--squisq-json-bg']);
48
+ });
49
+
50
+ it('falls back to DEFAULT_THEME when theme is undefined', () => {
51
+ const tokens = buildJsonFormTokens(undefined, undefined, { prefix: '--squisq-json' });
52
+ expect(tokens['--squisq-json-bg']).toBe(DEFAULT_THEME.colors.background);
53
+ });
54
+
55
+ it('does not apply surface when surface is undefined', () => {
56
+ const tokens = buildJsonFormTokens(DEFAULT_THEME, undefined, { prefix: '--squisq-json' });
57
+ expect(tokens['--squisq-json-bg']).toBe(DEFAULT_THEME.colors.background);
58
+ expect(tokens['--squisq-json-radius']).toBe(`${DEFAULT_THEME.style.borderRadius ?? 8}px`);
59
+ });
60
+
61
+ it('is pure — no window/matchMedia access required', () => {
62
+ // Runs fine even if window is momentarily undefined-like; the function
63
+ // never touches globals.
64
+ expect(() =>
65
+ buildJsonFormTokens(DEFAULT_THEME, DARK_SURFACE, { prefix: '--squisq-json' }),
66
+ ).not.toThrow();
67
+ });
68
+ });
69
+
70
+ describe('resolveJsonFormTheme', () => {
71
+ it('applies the surface to the theme', () => {
72
+ expect(resolveJsonFormTheme(DEFAULT_THEME, DARK_SURFACE)).toEqual(
73
+ applySurface(DEFAULT_THEME, DARK_SURFACE),
74
+ );
75
+ });
76
+
77
+ it('returns the base theme untouched when surface is undefined', () => {
78
+ expect(resolveJsonFormTheme(DEFAULT_THEME, undefined)).toBe(DEFAULT_THEME);
79
+ });
80
+
81
+ it('falls back to DEFAULT_THEME when theme is undefined', () => {
82
+ expect(resolveJsonFormTheme(undefined, undefined)).toBe(DEFAULT_THEME);
83
+ });
84
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { layoutBlock } from '../doc/templates/layoutBlock.js';
3
+ import { createTemplateContext } from '../schemas/BlockTemplates.js';
4
+ import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
5
+ import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
6
+ import { isContainerTemplate } from '../doc/templates/index.js';
7
+ import { parseMarkdown } from '../markdown/parse.js';
8
+ import type { Block, Layer, TextLayer } from '../schemas/Doc.js';
9
+ import type { RawLayersInput } from '../schemas/BlockTemplates.js';
10
+
11
+ function child(
12
+ id: string,
13
+ template: string,
14
+ overrides: Record<string, string> = {},
15
+ bodyMd = '',
16
+ ): Block {
17
+ return {
18
+ id,
19
+ startTime: 0,
20
+ duration: 0,
21
+ audioSegment: 0,
22
+ template,
23
+ templateOverrides: overrides,
24
+ ...(bodyMd ? { contents: parseMarkdown(bodyMd).children } : {}),
25
+ } as Block;
26
+ }
27
+
28
+ function ctxWith(children: Block[]) {
29
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
30
+ ctx.children = children;
31
+ return ctx;
32
+ }
33
+
34
+ const INPUT: RawLayersInput = { template: 'layout', id: 'lay', duration: 0, audioSegment: 0 };
35
+
36
+ describe('layoutBlock template', () => {
37
+ it('returns a single hint layer for an empty layout', () => {
38
+ const layers = layoutBlock(INPUT, ctxWith([]));
39
+ expect(layers).toHaveLength(1);
40
+ expect(layers[0].type).toBe('text');
41
+ expect((layers[0] as TextLayer).content.text).toBe('Empty layout');
42
+ });
43
+
44
+ it('emits one layer per child, in document order, at absolute positions', () => {
45
+ const layers = layoutBlock(
46
+ INPUT,
47
+ ctxWith([
48
+ child('box-1', 'rectangle', { x: '760', y: '390', width: '400', height: '300' }),
49
+ child('text-1', 'text', { x: '360', y: '380', width: '1200', height: '320' }, 'Welcome'),
50
+ ]),
51
+ );
52
+ expect(layers.map((l) => l.id)).toEqual(['box-1', 'text-1']);
53
+ expect(layers[0].type).toBe('shape');
54
+ expect(layers[1].type).toBe('text');
55
+ expect((layers[1] as TextLayer).content.text).toBe('Welcome');
56
+ expect(layers[0].position).toMatchObject({ x: 760, y: 390 });
57
+ });
58
+ });
59
+
60
+ describe('layout container behavior', () => {
61
+ it('marks layout as a children-consuming container template', () => {
62
+ expect(isContainerTemplate('layout')).toBe(true);
63
+ });
64
+
65
+ it('renders a layout block through the template registry (getLayers)', async () => {
66
+ const { getLayers } = await import('../doc/getLayers.js');
67
+ const parent: Block = {
68
+ id: 'lay',
69
+ startTime: 0,
70
+ duration: 0,
71
+ audioSegment: 0,
72
+ template: 'layout',
73
+ children: [
74
+ child('text-1', 'text', { x: '360', y: '380', width: '1200', height: '320' }, 'Title'),
75
+ child('box-1', 'rectangle', { x: '760', y: '390', width: '400', height: '300' }),
76
+ ],
77
+ };
78
+ const layers = getLayers(parent, {});
79
+ const types = new Set(layers.map((l: Layer) => l.type));
80
+ expect(types.has('text')).toBe(true);
81
+ expect(types.has('shape')).toBe(true);
82
+ });
83
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeLayoutLayers } from '../doc/templates/layoutLayout.js';
3
+ import { parseMarkdown } from '../markdown/parse.js';
4
+ import type { ShapeLayer, TextLayer, ImageLayer, PathLayer } from '../schemas/Doc.js';
5
+
6
+ const VP = { width: 1920, height: 1080 };
7
+
8
+ function child(id: string, template: string, params: Record<string, string> = {}, bodyMd = '') {
9
+ return {
10
+ id,
11
+ template,
12
+ templateOverrides: params,
13
+ ...(bodyMd ? { contents: parseMarkdown(bodyMd).children } : {}),
14
+ };
15
+ }
16
+
17
+ describe('computeLayoutLayers', () => {
18
+ it('maps a text child to an absolutely-positioned TextLayer (body text + style)', () => {
19
+ const { layers } = computeLayoutLayers(
20
+ [
21
+ child(
22
+ 'text-1',
23
+ 'text',
24
+ {
25
+ x: '100',
26
+ y: '200',
27
+ width: '600',
28
+ height: '120',
29
+ fontSize: '48',
30
+ align: 'center',
31
+ valign: 'middle',
32
+ color: '#1e293b',
33
+ },
34
+ 'Hello **world**',
35
+ ),
36
+ ],
37
+ VP,
38
+ );
39
+ expect(layers).toHaveLength(1);
40
+ const t = layers[0] as TextLayer;
41
+ expect(t.type).toBe('text');
42
+ expect(t.id).toBe('text-1');
43
+ expect(t.position).toMatchObject({ x: 100, y: 200, width: 600, height: 120 });
44
+ expect(t.content.text).toBe('Hello world'); // plain projection of the body
45
+ expect(t.content.style.fontSize).toBe(48);
46
+ expect(t.content.style.textAlign).toBe('center');
47
+ expect(t.content.style.verticalAlign).toBe('middle');
48
+ expect(t.content.style.color).toBe('#1e293b');
49
+ });
50
+
51
+ it('maps rectangle/circle children to ShapeLayers', () => {
52
+ const { layers } = computeLayoutLayers(
53
+ [
54
+ child('box-1', 'rectangle', {
55
+ x: '10',
56
+ y: '10',
57
+ width: '200',
58
+ height: '100',
59
+ fill: '#e0e7ff',
60
+ stroke: '#6366f1',
61
+ strokeWidth: '2',
62
+ borderRadius: '8',
63
+ }),
64
+ child('c-1', 'circle', { x: '300', y: '10', width: '120', height: '120' }),
65
+ ],
66
+ VP,
67
+ );
68
+ const shapes = layers.filter((l): l is ShapeLayer => l.type === 'shape');
69
+ expect(shapes.map((s) => s.content.shape)).toEqual(['rect', 'circle']);
70
+ expect(shapes[0].content.fill).toBe('#e0e7ff');
71
+ expect(shapes[0].content.borderRadius).toBe(8);
72
+ expect(shapes[0].position).toMatchObject({ x: 10, y: 10, width: 200, height: 100 });
73
+ });
74
+
75
+ it('maps an image child to an ImageLayer and skips one missing src', () => {
76
+ const { layers, warnings } = computeLayoutLayers(
77
+ [
78
+ child('img-1', 'image', {
79
+ x: '0',
80
+ y: '0',
81
+ width: '320',
82
+ height: '240',
83
+ src: 'pic.png',
84
+ alt: 'A pic',
85
+ fit: 'cover',
86
+ }),
87
+ child('img-2', 'image', { x: '0', y: '0' }),
88
+ ],
89
+ VP,
90
+ );
91
+ const imgs = layers.filter((l): l is ImageLayer => l.type === 'image');
92
+ expect(imgs).toHaveLength(1);
93
+ expect(imgs[0].content.src).toBe('pic.png');
94
+ expect(imgs[0].content.alt).toBe('A pic');
95
+ expect(imgs[0].content.fit).toBe('cover');
96
+ expect(warnings.some((w) => w.includes('img-2'))).toBe(true);
97
+ });
98
+
99
+ it('maps a named polygon to a PathLayer via shapePath', () => {
100
+ const { layers } = computeLayoutLayers(
101
+ [child('star-1', 'star', { x: '0', y: '0', width: '100', height: '100' })],
102
+ VP,
103
+ );
104
+ const p = layers[0] as PathLayer;
105
+ expect(p.type).toBe('path');
106
+ expect(p.content.d.startsWith('M')).toBe(true);
107
+ });
108
+
109
+ it('uses absolute positions (no fit-scaling) regardless of viewport', () => {
110
+ const children = [
111
+ child('text-1', 'text', { x: '100', y: '100', width: '200', height: '50' }, 'x'),
112
+ ];
113
+ const small = computeLayoutLayers(children, { width: 200, height: 200 }).layers[0];
114
+ const large = computeLayoutLayers(children, { width: 4000, height: 4000 }).layers[0];
115
+ expect(small.position.x).toBe(100);
116
+ expect(large.position.x).toBe(100);
117
+ });
118
+
119
+ it('preserves child document order as z-order', () => {
120
+ const { layers } = computeLayoutLayers(
121
+ [child('a', 'rectangle', { x: '0', y: '0' }), child('b', 'text', {}, 'top')],
122
+ VP,
123
+ );
124
+ expect(layers.map((l) => l.id)).toEqual(['a', 'b']);
125
+ });
126
+
127
+ it('warns and skips a child whose annotation is not a layer kind', () => {
128
+ const { layers, warnings } = computeLayoutLayers(
129
+ [child('weird', 'banana', { x: '0', y: '0' })],
130
+ VP,
131
+ );
132
+ expect(layers).toHaveLength(0);
133
+ expect(warnings.some((w) => w.includes('weird'))).toBe(true);
134
+ });
135
+
136
+ it('uses the supplied ink default for un-styled text and strokes', () => {
137
+ const { layers } = computeLayoutLayers(
138
+ [
139
+ child('t', 'text', { x: '0', y: '0' }, 'plain'),
140
+ child('l', 'line', { x: '0', y: '0', width: '100', height: '0' }),
141
+ ],
142
+ VP,
143
+ { ink: '#e0e0e0' },
144
+ );
145
+ const t = layers[0] as TextLayer;
146
+ const l = layers[1] as PathLayer;
147
+ expect(t.content.style.color).toBe('#e0e0e0');
148
+ expect(l.content.stroke).toBe('#e0e0e0');
149
+ });
150
+
151
+ it('keeps the dark-slate ink default when no defaults are supplied (editor scene canvas)', () => {
152
+ const { layers } = computeLayoutLayers([child('t', 'text', { x: '0', y: '0' }, 'plain')], VP);
153
+ const t = layers[0] as TextLayer;
154
+ expect(t.content.style.color).toBe('#1e293b');
155
+ });
156
+
157
+ it('never overrides author-specified color/stroke with the ink default', () => {
158
+ const { layers } = computeLayoutLayers(
159
+ [
160
+ child('t', 'text', { x: '0', y: '0', color: '#112233' }, 'styled'),
161
+ child('l', 'line', { x: '0', y: '0', width: '100', height: '0', stroke: '#445566' }),
162
+ ],
163
+ VP,
164
+ { ink: '#e0e0e0' },
165
+ );
166
+ expect((layers[0] as TextLayer).content.style.color).toBe('#112233');
167
+ expect((layers[1] as PathLayer).content.stroke).toBe('#445566');
168
+ });
169
+ });