@bendyline/squisq 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BsMkmzH8.d.ts} +838 -268
  2. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-DvoeHMP9.d.ts} +11 -4
  3. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  4. package/dist/chunk-2OIBZYKP.js.map +1 -0
  5. package/dist/chunk-2UQOHYE2.js +45 -0
  6. package/dist/chunk-2UQOHYE2.js.map +1 -0
  7. package/dist/{chunk-B4EEON3N.js → chunk-4AKUQPEE.js} +536 -103
  8. package/dist/chunk-4AKUQPEE.js.map +1 -0
  9. package/dist/chunk-4ZFRZBU2.js +6582 -0
  10. package/dist/chunk-4ZFRZBU2.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-7MQGNR76.js} +79 -3
  12. package/dist/chunk-7MQGNR76.js.map +1 -0
  13. package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
  14. package/dist/chunk-DLXZMS5K.js.map +1 -0
  15. package/dist/{chunk-MYIH7FWD.js → chunk-FBKP5CAO.js} +1 -1
  16. package/dist/chunk-FBKP5CAO.js.map +1 -0
  17. package/dist/chunk-KZNXZ5SM.js +333 -0
  18. package/dist/chunk-KZNXZ5SM.js.map +1 -0
  19. package/dist/{chunk-WMOI7FN3.js → chunk-LRCV7N7F.js} +12 -2
  20. package/dist/{chunk-WMOI7FN3.js.map → chunk-LRCV7N7F.js.map} +1 -1
  21. package/dist/{chunk-3K5OG6KI.js → chunk-OH43RPTP.js} +403 -187
  22. package/dist/chunk-OH43RPTP.js.map +1 -0
  23. package/dist/{chunk-H3AXU4MJ.js → chunk-OKJOQXK4.js} +149 -2
  24. package/dist/chunk-OKJOQXK4.js.map +1 -0
  25. package/dist/chunk-PGGGKYWG.js +1 -0
  26. package/dist/chunk-PGGGKYWG.js.map +1 -0
  27. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  28. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  29. package/dist/chunk-QE7TWGVT.js +178 -0
  30. package/dist/chunk-QE7TWGVT.js.map +1 -0
  31. package/dist/chunk-RUDYOTA6.js +345 -0
  32. package/dist/chunk-RUDYOTA6.js.map +1 -0
  33. package/dist/chunk-TVSGLUID.js +596 -0
  34. package/dist/chunk-TVSGLUID.js.map +1 -0
  35. package/dist/chunk-ZLQKNOZQ.js +1 -0
  36. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  37. package/dist/doc/index.d.ts +4 -4
  38. package/dist/doc/index.js +124 -6
  39. package/dist/generate/index.d.ts +9 -2
  40. package/dist/generate/index.js +1 -1
  41. package/dist/icons/index.d.ts +3 -26
  42. package/dist/icons/index.js +13 -0
  43. package/dist/icons/inlineIconMarker.d.ts +1 -0
  44. package/dist/icons/inlineIconMarker.js +15 -0
  45. package/dist/icons/inlineIconMarker.js.map +1 -0
  46. package/dist/imageEdit/index.d.ts +11 -5
  47. package/dist/imageEdit/index.js +4 -1
  48. package/dist/index.d.ts +11 -8
  49. package/dist/index.js +243 -24
  50. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  51. package/dist/markdown/index.d.ts +212 -3
  52. package/dist/markdown/index.js +40 -7
  53. package/dist/recommend/index.d.ts +1 -1
  54. package/dist/recommend/index.js +3 -2
  55. package/dist/schemas/index.d.ts +28 -8
  56. package/dist/schemas/index.js +35 -5
  57. package/dist/spatial/index.d.ts +9 -4
  58. package/dist/spatial/index.js +1 -1
  59. package/dist/story/index.d.ts +906 -40
  60. package/dist/story/index.js +124 -6
  61. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-DQQKuRMx.d.ts} +1 -1
  62. package/dist/transform/index.d.ts +48 -10
  63. package/dist/transform/index.js +17 -481
  64. package/dist/transform/index.js.map +1 -1
  65. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  66. package/package.json +6 -1
  67. package/src/__tests__/animationUtils.test.ts +40 -0
  68. package/src/__tests__/annotationCoercion.test.ts +243 -0
  69. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  70. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  71. package/src/__tests__/attrTokens.test.ts +162 -0
  72. package/src/__tests__/customTemplateSample.test.ts +83 -0
  73. package/src/__tests__/customTemplatesFrontmatter.test.ts +205 -0
  74. package/src/__tests__/customThemesFrontmatter.test.ts +118 -0
  75. package/src/__tests__/diagramBlock.test.ts +122 -0
  76. package/src/__tests__/diagramLayout.test.ts +134 -0
  77. package/src/__tests__/drawing.test.ts +109 -0
  78. package/src/__tests__/drawingBlock.test.ts +124 -0
  79. package/src/__tests__/drawingLayout.test.ts +171 -0
  80. package/src/__tests__/geohash.test.ts +6 -5
  81. package/src/__tests__/getLayers.test.ts +6 -2
  82. package/src/__tests__/getLayersFallback.test.ts +103 -0
  83. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  84. package/src/__tests__/imageEditVersions.test.ts +72 -0
  85. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  86. package/src/__tests__/layoutBlock.test.ts +83 -0
  87. package/src/__tests__/layoutLayout.test.ts +169 -0
  88. package/src/__tests__/markdown.test.ts +23 -0
  89. package/src/__tests__/markdownSanitize.test.ts +64 -0
  90. package/src/__tests__/markdownToDoc.test.ts +223 -2
  91. package/src/__tests__/mediaAnnotations.test.ts +64 -0
  92. package/src/__tests__/mediaSchedule.test.ts +118 -0
  93. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  94. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  95. package/src/__tests__/rootExports.test.ts +43 -0
  96. package/src/__tests__/shapeGeometry.test.ts +89 -0
  97. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  98. package/src/__tests__/structuredData.test.ts +179 -0
  99. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  100. package/src/__tests__/templateMetadata.test.ts +21 -0
  101. package/src/__tests__/templates.test.ts +145 -4
  102. package/src/__tests__/themeCompile.test.ts +42 -0
  103. package/src/__tests__/themeValidator.test.ts +6 -0
  104. package/src/__tests__/transformV2.test.ts +137 -0
  105. package/src/__tests__/transitionNormalize.test.ts +61 -0
  106. package/src/__tests__/validateDoc.test.ts +100 -0
  107. package/src/doc/customTemplatesFrontmatter.ts +308 -0
  108. package/src/doc/customThemesFrontmatter.ts +90 -0
  109. package/src/doc/docToMarkdown.ts +73 -4
  110. package/src/doc/getLayers.ts +40 -17
  111. package/src/doc/index.ts +35 -1
  112. package/src/doc/markdownToDoc.ts +333 -12
  113. package/src/doc/mediaAnnotations.ts +118 -0
  114. package/src/doc/resolveDocTheme.ts +55 -0
  115. package/src/doc/structuredData.ts +230 -0
  116. package/src/doc/templateInputs.ts +240 -0
  117. package/src/doc/templates/__tests__/customTemplate.test.ts +151 -0
  118. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  119. package/src/doc/templates/accentImage.ts +30 -6
  120. package/src/doc/templates/captionUtils.ts +23 -0
  121. package/src/doc/templates/comparisonBar.ts +26 -18
  122. package/src/doc/templates/coverBlock.ts +7 -4
  123. package/src/doc/templates/customTemplate.ts +45 -0
  124. package/src/doc/templates/dataTable.ts +37 -19
  125. package/src/doc/templates/dateEvent.ts +41 -20
  126. package/src/doc/templates/definitionCard.ts +26 -18
  127. package/src/doc/templates/diagramBlock.ts +204 -0
  128. package/src/doc/templates/diagramLayout.ts +159 -0
  129. package/src/doc/templates/drawingBlock.ts +325 -0
  130. package/src/doc/templates/drawingLayout.ts +539 -0
  131. package/src/doc/templates/factCard.ts +51 -25
  132. package/src/doc/templates/fallbackBlock.ts +123 -0
  133. package/src/doc/templates/featureBlock.ts +20 -13
  134. package/src/doc/templates/fullBleedQuote.ts +18 -8
  135. package/src/doc/templates/imageWithCaption.ts +22 -9
  136. package/src/doc/templates/index.ts +215 -46
  137. package/src/doc/templates/layoutBlock.ts +43 -0
  138. package/src/doc/templates/layoutLayout.ts +243 -0
  139. package/src/doc/templates/listBlock.ts +56 -30
  140. package/src/doc/templates/mapBlock.ts +15 -12
  141. package/src/doc/templates/metadata.ts +120 -0
  142. package/src/doc/templates/persistentLayers.ts +129 -11
  143. package/src/doc/templates/photoGrid.ts +23 -12
  144. package/src/doc/templates/pullQuote.ts +30 -12
  145. package/src/doc/templates/quoteBlock.ts +54 -29
  146. package/src/doc/templates/sectionHeader.ts +25 -11
  147. package/src/doc/templates/statHighlight.ts +39 -22
  148. package/src/doc/templates/titleBlock.ts +59 -21
  149. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +221 -0
  150. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  151. package/src/doc/templates/twoColumn.ts +48 -30
  152. package/src/doc/templates/videoPullQuote.ts +22 -12
  153. package/src/doc/templates/videoWithCaption.ts +13 -8
  154. package/src/doc/utils/animationUtils.ts +267 -3
  155. package/src/doc/utils/applyRenderStyle.ts +113 -0
  156. package/src/doc/utils/imageTreatment.ts +70 -0
  157. package/src/doc/utils/shapeGeometry.ts +464 -0
  158. package/src/doc/utils/themeUtils.ts +75 -1
  159. package/src/doc/validate.ts +476 -0
  160. package/src/generate/slideshowGenerator.ts +7 -0
  161. package/src/icons/index.ts +8 -0
  162. package/src/icons/inlineIconMarker.ts +73 -0
  163. package/src/imageEdit/export.ts +85 -2
  164. package/src/imageEdit/index.ts +1 -0
  165. package/src/imageEdit/persistence.ts +51 -0
  166. package/src/index.ts +2 -0
  167. package/src/markdown/annotationCoercion.ts +295 -0
  168. package/src/markdown/attrTokens.ts +222 -0
  169. package/src/markdown/convert.ts +229 -26
  170. package/src/markdown/index.ts +36 -0
  171. package/src/markdown/sanitize.ts +298 -0
  172. package/src/markdown/stringify.ts +84 -3
  173. package/src/markdown/types.ts +66 -0
  174. package/src/markdown/utils.ts +49 -1
  175. package/src/recommend/templates.ts +24 -0
  176. package/src/schemas/BlockTemplates.ts +133 -4
  177. package/src/schemas/CustomTemplates.ts +74 -0
  178. package/src/schemas/Doc.ts +353 -29
  179. package/src/schemas/ImageEditDoc.ts +11 -4
  180. package/src/schemas/Media.ts +169 -0
  181. package/src/schemas/Theme.ts +23 -1
  182. package/src/schemas/Transitions.ts +224 -0
  183. package/src/schemas/colorUtils.ts +33 -0
  184. package/src/schemas/fontStacks.ts +5 -2
  185. package/src/schemas/index.ts +3 -0
  186. package/src/schemas/themeCompile.ts +23 -4
  187. package/src/schemas/themeValidator.ts +49 -11
  188. package/src/schemas/themes/cinematic.json +69 -12
  189. package/src/schemas/themes/documentary.json +58 -11
  190. package/src/schemas/themes/gezellig.json +60 -11
  191. package/src/schemas/themes/magazine.json +54 -9
  192. package/src/schemas/themes/morning-light.json +52 -9
  193. package/src/schemas/themes/standard-dark.json +48 -13
  194. package/src/schemas/themes/standard.json +44 -12
  195. package/src/schemas/themes/tech-dark.json +65 -12
  196. package/src/spatial/Geohash.ts +52 -21
  197. package/src/transform/applyTransform.ts +43 -4
  198. package/src/transform/index.ts +1 -0
  199. package/src/transform/registry.ts +44 -13
  200. package/src/transform/styles/dataDriven.ts +2 -1
  201. package/src/transform/styles/documentary.ts +2 -1
  202. package/src/transform/styles/magazine.ts +3 -1
  203. package/src/transform/styles/minimal.ts +2 -1
  204. package/src/transform/styles/narrative.ts +3 -1
  205. package/src/transform/templateSelector.ts +87 -11
  206. package/src/transform/types.ts +35 -2
  207. package/dist/chunk-3K5OG6KI.js.map +0 -1
  208. package/dist/chunk-57CVQCFX.js.map +0 -1
  209. package/dist/chunk-5WFRKURX.js.map +0 -1
  210. package/dist/chunk-7UDSRZKG.js.map +0 -1
  211. package/dist/chunk-B4EEON3N.js.map +0 -1
  212. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  213. package/dist/chunk-KSWJR66U.js +0 -3706
  214. package/dist/chunk-KSWJR66U.js.map +0 -1
  215. package/dist/chunk-MYIH7FWD.js.map +0 -1
  216. package/dist/chunk-ZTX4PFFT.js.map +0 -1
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { expandPersistentLayer } from '../doc/templates/persistentLayers.js';
3
+ import { cssFilterForTreatment } from '../doc/utils/imageTreatment.js';
4
+ import { themedImageTreatment } from '../doc/utils/themeUtils.js';
5
+ import { createTemplateContext } from '../schemas/BlockTemplates.js';
6
+ import { DEFAULT_THEME, resolveTheme } from '../schemas/themeLibrary.js';
7
+ import { validateTheme } from '../schemas/themeValidator.js';
8
+ import { THEMES } from '../schemas/themeLibrary.js';
9
+ import type { ShapeLayer, ImageLayer } from '../schemas/Doc.js';
10
+
11
+ describe('atmosphere persistent layers', () => {
12
+ it('patternBackground expands dots/grid/diagonal to an SVG pattern fill', () => {
13
+ const [layer] = expandPersistentLayer({
14
+ template: 'patternBackground',
15
+ config: { type: 'patternBackground', pattern: 'grid', color: '#00e5ff', opacity: 0.04 },
16
+ }) as ShapeLayer[];
17
+ expect(layer.type).toBe('shape');
18
+ expect(layer.content.pattern).toEqual({
19
+ kind: 'grid',
20
+ color: '#00e5ff',
21
+ size: 24,
22
+ opacity: 0.04,
23
+ });
24
+ });
25
+
26
+ it('patternBackground noise expands to a grain filter rect', () => {
27
+ const [layer] = expandPersistentLayer({
28
+ template: 'patternBackground',
29
+ config: { type: 'patternBackground', pattern: 'noise', opacity: 0.05 },
30
+ }) as ShapeLayer[];
31
+ expect(layer.content.filter).toEqual({ type: 'noise', opacity: 0.05 });
32
+ });
33
+
34
+ it('vignette expands to a radial gradient rect with strength-scaled edge', () => {
35
+ const [layer] = expandPersistentLayer({
36
+ template: 'vignette',
37
+ config: { type: 'vignette', strength: 0.4 },
38
+ }) as ShapeLayer[];
39
+ expect(layer.content.fill).toContain('radial-gradient');
40
+ expect(layer.content.fill).toContain('rgba(0, 0, 0, 0.4)');
41
+ });
42
+
43
+ it('ambientGradient derives colors from the theme and drifts slowly', () => {
44
+ const theme = resolveTheme('gezellig');
45
+ const [layer] = expandPersistentLayer(
46
+ { template: 'ambientGradient', config: { type: 'ambientGradient' } },
47
+ theme,
48
+ ) as ShapeLayer[];
49
+ expect(layer.content.fill).toContain(theme.colors.backgroundLight);
50
+ expect(layer.animation?.type).toBe('slowZoom');
51
+ expect(layer.animation?.duration).toBe(40);
52
+ // Oversized so the pan never reveals the frame edge
53
+ expect(layer.position.width).toBe('110%');
54
+ });
55
+
56
+ it('imageBackground passes blur through to the image layer', () => {
57
+ const [image] = expandPersistentLayer({
58
+ template: 'imageBackground',
59
+ config: { type: 'imageBackground', src: 'bg.jpg', blur: 12 },
60
+ }) as ImageLayer[];
61
+ expect(image.content.blur).toBe(12);
62
+ });
63
+
64
+ it('every built-in theme still validates with its atmosphere layers', () => {
65
+ for (const [id, theme] of Object.entries(THEMES)) {
66
+ const result = validateTheme(theme);
67
+ expect(result.valid, `theme ${id}: ${JSON.stringify(result.errors)}`).toBe(true);
68
+ }
69
+ });
70
+ });
71
+
72
+ describe('image treatments', () => {
73
+ it('mono produces a grayscale filter scaled by strength', () => {
74
+ expect(cssFilterForTreatment({ type: 'mono', strength: 1 })).toContain('grayscale(1)');
75
+ expect(cssFilterForTreatment({ type: 'mono', strength: 0.5 })).toContain('grayscale(0.5)');
76
+ });
77
+
78
+ it('duotone rotates hue toward the tint color', () => {
79
+ // #ff0000 has hue 0 → rotation 0 - 40 = -40deg
80
+ const f = cssFilterForTreatment({ type: 'duotone', color: '#ff0000' });
81
+ expect(f).toContain('grayscale(1)');
82
+ expect(f).toContain('hue-rotate(-40deg)');
83
+ });
84
+
85
+ it('none / absent treatments produce no filter', () => {
86
+ expect(cssFilterForTreatment({ type: 'none' })).toBeUndefined();
87
+ expect(cssFilterForTreatment(undefined)).toBeUndefined();
88
+ });
89
+
90
+ it('blur composes with (or without) a treatment', () => {
91
+ expect(cssFilterForTreatment(undefined, 12)).toBe('blur(12px)');
92
+ expect(cssFilterForTreatment({ type: 'mono' }, 8)).toMatch(/^blur\(8px\) grayscale/);
93
+ });
94
+
95
+ it('themedImageTreatment: block override wins, none opts out, duotone tints from primary', () => {
96
+ const documentary = resolveTheme('documentary');
97
+ const ctx = createTemplateContext(documentary, 0, 1);
98
+ // Theme authors mono
99
+ expect(themedImageTreatment(ctx)?.type).toBe('mono');
100
+ // Block opts out
101
+ expect(themedImageTreatment(ctx, 'none')).toBeUndefined();
102
+ // Block forces duotone → tint defaults to theme primary
103
+ expect(themedImageTreatment(ctx, 'duotone')?.color).toBe(documentary.colors.primary);
104
+ // Standard theme has no treatment
105
+ const plainCtx = createTemplateContext(DEFAULT_THEME, 0, 1);
106
+ expect(themedImageTreatment(plainCtx)).toBeUndefined();
107
+ });
108
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ tokenizeAttrTokens,
4
+ splitKeyValueToken,
5
+ unquoteAttrValue,
6
+ needsQuoting,
7
+ quoteAttrValue,
8
+ matchTrailingTemplateAnnotation,
9
+ matchTrailingPandocAttr,
10
+ } from '../markdown/attrTokens.js';
11
+
12
+ describe('tokenizeAttrTokens', () => {
13
+ it('splits bare tokens on whitespace', () => {
14
+ expect(tokenizeAttrTokens('chart colorScheme=blue size=large')).toEqual([
15
+ 'chart',
16
+ 'colorScheme=blue',
17
+ 'size=large',
18
+ ]);
19
+ });
20
+
21
+ it('keeps a double-quoted value as one token', () => {
22
+ expect(tokenizeAttrTokens('caption="Beach at sunset" columns=3')).toEqual([
23
+ 'caption="Beach at sunset"',
24
+ 'columns=3',
25
+ ]);
26
+ });
27
+
28
+ it('keeps a single-quoted value as one token', () => {
29
+ expect(tokenizeAttrTokens("caption='Beach at sunset'")).toEqual(["caption='Beach at sunset'"]);
30
+ });
31
+
32
+ it('treats a quote mid-value as a literal character', () => {
33
+ // The quote in O'Brien is not at a value start, so it must not open a
34
+ // quoted run and swallow the following token.
35
+ expect(tokenizeAttrTokens("attribution=O'Brien x=4")).toEqual(["attribution=O'Brien", 'x=4']);
36
+ });
37
+
38
+ it('preserves escape pairs inside a quoted run', () => {
39
+ expect(tokenizeAttrTokens('label="he said \\"hi\\""')).toEqual(['label="he said \\"hi\\""']);
40
+ });
41
+
42
+ it('tolerates an unbalanced opening quote (run extends to end of input)', () => {
43
+ expect(tokenizeAttrTokens('caption="oops trailing')).toEqual(['caption="oops trailing']);
44
+ });
45
+
46
+ it('keeps a bare quoted token verbatim', () => {
47
+ expect(tokenizeAttrTokens('"just a string"')).toEqual(['"just a string"']);
48
+ });
49
+ });
50
+
51
+ describe('splitKeyValueToken / unquoteAttrValue', () => {
52
+ it('splits on the first =', () => {
53
+ expect(splitKeyValueToken('a=b=c')).toEqual({ key: 'a', value: 'b=c' });
54
+ });
55
+
56
+ it('returns null for tokens without = past the first character', () => {
57
+ expect(splitKeyValueToken('chart')).toBeNull();
58
+ expect(splitKeyValueToken('#id')).toBeNull();
59
+ expect(splitKeyValueToken('=leading')).toBeNull();
60
+ });
61
+
62
+ it('strips matching double quotes and unescapes', () => {
63
+ expect(splitKeyValueToken('label="he said \\"hi\\""')).toEqual({
64
+ key: 'label',
65
+ value: 'he said "hi"',
66
+ });
67
+ });
68
+
69
+ it('strips matching single quotes and unescapes', () => {
70
+ expect(splitKeyValueToken("label='it\\'s here'")).toEqual({ key: 'label', value: "it's here" });
71
+ });
72
+
73
+ it('leaves unmatched quotes verbatim', () => {
74
+ expect(unquoteAttrValue('"unbalanced')).toBe('"unbalanced');
75
+ expect(unquoteAttrValue('"')).toBe('"');
76
+ });
77
+
78
+ it('leaves backslashes before other characters alone', () => {
79
+ expect(unquoteAttrValue('"C:\\temp"')).toBe('C:\\temp');
80
+ });
81
+ });
82
+
83
+ describe('needsQuoting / quoteAttrValue', () => {
84
+ it('passes simple values through bare', () => {
85
+ expect(quoteAttrValue('blue')).toBe('blue');
86
+ expect(quoteAttrValue('a,b,c')).toBe('a,b,c');
87
+ expect(quoteAttrValue('foo:flow')).toBe('foo:flow');
88
+ });
89
+
90
+ it('double-quotes values with spaces', () => {
91
+ expect(quoteAttrValue('Beach at sunset')).toBe('"Beach at sunset"');
92
+ });
93
+
94
+ it('quotes empty values', () => {
95
+ expect(quoteAttrValue('')).toBe('""');
96
+ });
97
+
98
+ it('quotes values containing brackets or braces', () => {
99
+ expect(needsQuoting('a]b')).toBe(true);
100
+ expect(quoteAttrValue('a]b')).toBe('"a]b"');
101
+ expect(quoteAttrValue('a}b')).toBe('"a}b"');
102
+ });
103
+
104
+ it('switches to single quotes when the value contains a double quote', () => {
105
+ expect(quoteAttrValue('She said "hi"')).toBe('\'She said "hi"\'');
106
+ });
107
+
108
+ it('falls back to double quotes with escapes when both quote chars appear', () => {
109
+ expect(quoteAttrValue('it\'s a "thing"')).toBe('"it\'s a \\"thing\\""');
110
+ });
111
+
112
+ it('round-trips through tokenize + split', () => {
113
+ const values = ['Beach at sunset', 'a ] b', 'She said "hi"', "it's fine", 'C:\\temp', ''];
114
+ for (const value of values) {
115
+ const tokens = tokenizeAttrTokens(`key=${quoteAttrValue(value)}`);
116
+ expect(tokens).toHaveLength(1);
117
+ expect(splitKeyValueToken(tokens[0])).toEqual({ key: 'key', value });
118
+ }
119
+ });
120
+ });
121
+
122
+ describe('matchTrailingTemplateAnnotation', () => {
123
+ it('matches a simple trailing annotation', () => {
124
+ const m = matchTrailingTemplateAnnotation('Getting Started {[comparisonBar]}');
125
+ expect(m?.inner).toBe('comparisonBar');
126
+ });
127
+
128
+ it('allows ] inside a quoted value', () => {
129
+ const m = matchTrailingTemplateAnnotation('X {[quote text="a ] b"]}');
130
+ expect(m?.inner).toBe('quote text="a ] b"');
131
+ });
132
+
133
+ it('falls back to the legacy grammar for unbalanced quotes', () => {
134
+ const m = matchTrailingTemplateAnnotation('X {[quote text="oops]}');
135
+ expect(m?.inner).toBe('quote text="oops');
136
+ });
137
+
138
+ it('still tolerates an accidentally doubled trailing ]}', () => {
139
+ const m = matchTrailingTemplateAnnotation('X {[comparisonBar]}]}');
140
+ expect(m?.inner).toBe('comparisonBar');
141
+ });
142
+
143
+ it('rejects non-trailing annotations', () => {
144
+ expect(matchTrailingTemplateAnnotation('The {[chart]} section')).toBeNull();
145
+ });
146
+ });
147
+
148
+ describe('matchTrailingPandocAttr', () => {
149
+ it('matches a simple trailing block', () => {
150
+ const m = matchTrailingPandocAttr('Section {#intro .wide x=4}');
151
+ expect(m?.inner).toBe('#intro .wide x=4');
152
+ });
153
+
154
+ it('allows } inside a quoted value', () => {
155
+ const m = matchTrailingPandocAttr('X {caption="a } b"}');
156
+ expect(m?.inner).toBe('caption="a } b"');
157
+ });
158
+
159
+ it('does not match the {[…]} template form', () => {
160
+ expect(matchTrailingPandocAttr('X {[chart]}')).toBeNull();
161
+ });
162
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * End-to-end sanity check: the kind of doc a user might compose by
3
+ * hand (or via the upcoming TemplateDesigner) renders through the
4
+ * markdown → Doc → expanded layers pipeline with tokens substituted.
5
+ *
6
+ * Build-the-payload-here so the test doesn't depend on the site
7
+ * package — it's verifying the pure pipeline.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import { markdownToDoc } from '../doc/markdownToDoc';
12
+ import { expandDocBlocks } from '../doc/templates/index';
13
+ import { parseMarkdown } from '../markdown/parse';
14
+ import { writeCustomTemplatesToFrontmatter } from '../doc/customTemplatesFrontmatter';
15
+ import type { CustomTemplateDefinition } from '../schemas/CustomTemplates.js';
16
+ import type { TextLayer } from '../schemas/Doc.js';
17
+ import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
18
+
19
+ const heroDef: CustomTemplateDefinition = {
20
+ name: 'hero',
21
+ label: 'Hero Section',
22
+ viewport: { width: 1920, height: 1080 },
23
+ layers: [
24
+ {
25
+ id: 'hero-title',
26
+ type: 'text',
27
+ position: { x: '6%', y: '20%', width: '88%' },
28
+ content: {
29
+ text: '{title}',
30
+ style: { fontSize: 96, color: '#0f172a' },
31
+ },
32
+ },
33
+ {
34
+ id: 'hero-body',
35
+ type: 'text',
36
+ position: { x: '6%', y: '52%', width: '88%' },
37
+ content: {
38
+ text: '{content}',
39
+ style: { fontSize: 36, color: '#475569' },
40
+ },
41
+ },
42
+ ],
43
+ };
44
+
45
+ function buildSampleMarkdown(): string {
46
+ const payload = writeCustomTemplatesToFrontmatter([heroDef]);
47
+ return `---
48
+ squisq-custom-templates: "${payload}"
49
+ ---
50
+
51
+ # Welcome to Squisq {[hero]}
52
+
53
+ A small editor for big ideas.
54
+ `;
55
+ }
56
+
57
+ describe('Custom template pipeline (markdown → expanded layers)', () => {
58
+ it('substitutes {title} and {content} when a doc uses a frontmatter-defined template', () => {
59
+ const doc = markdownToDoc(parseMarkdown(buildSampleMarkdown()));
60
+ const blocks = expandDocBlocks(doc.blocks, {
61
+ customTemplates: doc.customTemplates,
62
+ });
63
+ expect(blocks).toHaveLength(1);
64
+ const layers = blocks[0].layers ?? [];
65
+ expect(layers).toHaveLength(2);
66
+
67
+ expect((layers[0] as TextLayer).content.text).toBe('Welcome to Squisq');
68
+ expect((layers[1] as TextLayer).content.text).toBe('A small editor for big ideas.');
69
+ });
70
+
71
+ it('renders the same template against a portrait viewport without re-authoring', () => {
72
+ const doc = markdownToDoc(parseMarkdown(buildSampleMarkdown()));
73
+ const blocks = expandDocBlocks(doc.blocks, {
74
+ customTemplates: doc.customTemplates,
75
+ viewport: VIEWPORT_PRESETS.portrait,
76
+ });
77
+ const layers = blocks[0].layers ?? [];
78
+ // %-based positions survive the viewport switch — the SSR
79
+ // renderer resolves them against the actual viewport at draw time.
80
+ expect(layers[0].position.x).toBe('6%');
81
+ expect(layers[0].position.width).toBe('88%');
82
+ });
83
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Custom templates round-trip through frontmatter.
3
+ *
4
+ * Verifies that a CustomTemplateDefinition stored in markdown
5
+ * frontmatter is faithfully reconstructed by `markdownToDoc` and
6
+ * serialized back by `docToMarkdown` without loss.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { markdownToDoc } from '../doc/markdownToDoc';
11
+ import { docToMarkdown } from '../doc/docToMarkdown';
12
+ import {
13
+ encodeLayersForFrontmatter,
14
+ decodeLayersFromFrontmatter,
15
+ readCustomTemplatesFromFrontmatter,
16
+ writeCustomTemplatesToFrontmatter,
17
+ FRONTMATTER_CUSTOM_TEMPLATES_KEY,
18
+ } from '../doc/customTemplatesFrontmatter';
19
+ import { parseMarkdown } from '../markdown/parse';
20
+ import type { Layer } from '../schemas/Doc.js';
21
+ import type { CustomTemplateDefinition } from '../schemas/CustomTemplates.js';
22
+
23
+ const sampleLayers: Layer[] = [
24
+ {
25
+ id: 'title',
26
+ type: 'text',
27
+ position: { x: '5%', y: '10%', width: '90%' },
28
+ content: { text: '{title}', style: { fontSize: 72, color: '#000' } },
29
+ },
30
+ {
31
+ id: 'body',
32
+ type: 'text',
33
+ position: { x: '5%', y: '40%', width: '90%' },
34
+ content: { text: '{content}', style: { fontSize: 32, color: '#333' } },
35
+ },
36
+ ];
37
+
38
+ const heroDef: CustomTemplateDefinition = {
39
+ name: 'hero',
40
+ label: 'Hero',
41
+ description: 'Large title with body below.',
42
+ viewport: { width: 1920, height: 1080 },
43
+ layers: sampleLayers,
44
+ };
45
+
46
+ describe('encodeLayersForFrontmatter ↔ decodeLayersFromFrontmatter', () => {
47
+ it('round-trips a single Layer array verbatim', () => {
48
+ const encoded = encodeLayersForFrontmatter(sampleLayers);
49
+ const decoded = decodeLayersFromFrontmatter(encoded);
50
+ expect(decoded).toEqual(sampleLayers);
51
+ });
52
+
53
+ it("returns [] when the base64 isn't valid JSON", () => {
54
+ const garbage = encodeLayersForFrontmatter(sampleLayers).slice(0, 4);
55
+ expect(decodeLayersFromFrontmatter(garbage)).toEqual([]);
56
+ });
57
+ });
58
+
59
+ describe('writeCustomTemplatesToFrontmatter → readCustomTemplatesFromFrontmatter', () => {
60
+ it('round-trips a single definition through the compact JSON wire format', () => {
61
+ const payload = writeCustomTemplatesToFrontmatter([heroDef]);
62
+ expect(typeof payload).toBe('string');
63
+ // Compact form: a readable JSON object keyed by name, short keys, no
64
+ // base64, and the default 1920×1080 viewport omitted.
65
+ expect(payload!.startsWith('{"hero":')).toBe(true);
66
+ expect(payload).toContain('"lb":"Hero"');
67
+ expect(payload).not.toContain('"viewport"');
68
+ const fm = { [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: payload };
69
+ const out = readCustomTemplatesFromFrontmatter(fm);
70
+ expect(out).toHaveLength(1);
71
+ expect(out![0]).toEqual(heroDef);
72
+ });
73
+
74
+ it('is markedly smaller than the legacy base64 form', () => {
75
+ const compact = writeCustomTemplatesToFrontmatter([heroDef])!;
76
+ const legacyBase64 = btoa(unescape(encodeURIComponent(JSON.stringify([{ ...heroDef }]))));
77
+ expect(compact.length).toBeLessThan(legacyBase64.length);
78
+ });
79
+
80
+ it('still reads the legacy base64-JSON payload (back-compat)', () => {
81
+ // What older documents have on disk: base64 of the full-keyed array.
82
+ const legacy = btoa(unescape(encodeURIComponent(JSON.stringify([heroDef]))));
83
+ const out = readCustomTemplatesFromFrontmatter({
84
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: legacy,
85
+ });
86
+ expect(out).toHaveLength(1);
87
+ expect(out![0]).toEqual(heroDef);
88
+ });
89
+
90
+ it('round-trips layers carrying the full styling field set without loss', () => {
91
+ const rich: CustomTemplateDefinition = {
92
+ name: 'rich',
93
+ label: 'Rich',
94
+ viewport: { width: 1080, height: 1080 },
95
+ layers: [
96
+ {
97
+ id: 'p',
98
+ type: 'path',
99
+ position: { x: '1%', y: '2%', width: '3%', height: '4%', anchor: 'center' },
100
+ content: {
101
+ d: 'M0 0',
102
+ shapeKind: 'star',
103
+ fill: '#fff',
104
+ fillOpacity: 0.5,
105
+ gradient: { from: '#000', to: '#fff', angle: 90 },
106
+ stroke: '#111',
107
+ strokeWidth: 3,
108
+ borderStyle: 'dashed',
109
+ dasharray: '4 2',
110
+ },
111
+ } as Layer,
112
+ ],
113
+ };
114
+ const payload = writeCustomTemplatesToFrontmatter([rich])!;
115
+ const out = readCustomTemplatesFromFrontmatter({ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: payload });
116
+ expect(out![0]).toEqual(rich);
117
+ });
118
+
119
+ it('round-trips multiple definitions in declaration order', () => {
120
+ const second: CustomTemplateDefinition = { ...heroDef, name: 'second', label: 'Second' };
121
+ const payload = writeCustomTemplatesToFrontmatter([heroDef, second]);
122
+ const out = readCustomTemplatesFromFrontmatter({
123
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: payload,
124
+ });
125
+ expect(out!.map((d) => d.name)).toEqual(['hero', 'second']);
126
+ });
127
+
128
+ it('also accepts an already-decoded array shape (forward-compatible)', () => {
129
+ const out = readCustomTemplatesFromFrontmatter({
130
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: [
131
+ {
132
+ name: 'hero',
133
+ label: 'Hero',
134
+ viewport: { width: 1920, height: 1080 },
135
+ layers: sampleLayers,
136
+ },
137
+ ],
138
+ });
139
+ expect(out).toHaveLength(1);
140
+ expect(out![0].name).toBe('hero');
141
+ });
142
+
143
+ it('returns undefined when the key is absent', () => {
144
+ expect(readCustomTemplatesFromFrontmatter({})).toBeUndefined();
145
+ expect(readCustomTemplatesFromFrontmatter(undefined)).toBeUndefined();
146
+ });
147
+
148
+ it("returns undefined when the encoded string isn't a valid array", () => {
149
+ const fm = { [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: 'not-base64-of-an-array' };
150
+ expect(readCustomTemplatesFromFrontmatter(fm)).toBeUndefined();
151
+ });
152
+ });
153
+
154
+ describe('Doc.customTemplates round-trip via markdownToDoc + docToMarkdown', () => {
155
+ it('survives a markdownToDoc → docToMarkdown cycle', () => {
156
+ const encoded = writeCustomTemplatesToFrontmatter([heroDef])!;
157
+ // Compact JSON is stored unquoted — it round-trips verbatim through
158
+ // the line-based frontmatter parser.
159
+ const sourceMd = `---
160
+ title: My Doc
161
+ squisq-custom-templates: ${encoded}
162
+ ---
163
+
164
+ # Welcome {[hero]}
165
+
166
+ A small editor for big ideas.
167
+ `;
168
+ const doc = markdownToDoc(parseMarkdown(sourceMd));
169
+ expect(doc.customTemplates).toBeDefined();
170
+ expect(doc.customTemplates).toHaveLength(1);
171
+ expect(doc.customTemplates![0].name).toBe('hero');
172
+ expect(doc.customTemplates![0].layers).toEqual(sampleLayers);
173
+
174
+ // Round-trip back — docToMarkdown must restore the frontmatter
175
+ // entry with an equivalent encoded payload.
176
+ const mdOut = docToMarkdown(doc);
177
+ expect(mdOut.frontmatter).toBeDefined();
178
+ const payload = mdOut.frontmatter![FRONTMATTER_CUSTOM_TEMPLATES_KEY];
179
+ expect(typeof payload).toBe('string');
180
+ const reread = readCustomTemplatesFromFrontmatter({
181
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: payload,
182
+ });
183
+ expect(reread).toEqual(doc.customTemplates);
184
+ });
185
+
186
+ it('drops the frontmatter key when the doc has no custom templates anymore', () => {
187
+ void vi; // imported for parity; not used here
188
+ const doc = {
189
+ articleId: 'd',
190
+ duration: 0,
191
+ blocks: [],
192
+ audio: { segments: [] },
193
+ // Stale frontmatter from an earlier save when the doc DID have
194
+ // custom templates — but doc.customTemplates is now empty.
195
+ frontmatter: {
196
+ title: 'Doc',
197
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: 'stale-base64-blob',
198
+ },
199
+ };
200
+ const md = docToMarkdown(doc);
201
+ expect(md.frontmatter).toBeDefined();
202
+ expect(md.frontmatter).not.toHaveProperty(FRONTMATTER_CUSTOM_TEMPLATES_KEY);
203
+ expect(md.frontmatter!.title).toBe('Doc');
204
+ });
205
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Custom themes round-trip through frontmatter.
3
+ *
4
+ * The theme analog of customTemplatesFrontmatter.test.ts: verifies that a
5
+ * Theme stored in markdown frontmatter under `squisq-custom-themes` is
6
+ * faithfully reconstructed by `markdownToDoc` and serialized back by
7
+ * `docToMarkdown` without loss, and that the reader is tolerant of malformed
8
+ * entries.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { markdownToDoc } from '../doc/markdownToDoc';
13
+ import { docToMarkdown } from '../doc/docToMarkdown';
14
+ import {
15
+ readCustomThemesFromFrontmatter,
16
+ writeCustomThemesToFrontmatter,
17
+ FRONTMATTER_CUSTOM_THEMES_KEY,
18
+ } from '../doc/customThemesFrontmatter';
19
+ import { parseMarkdown } from '../markdown/parse';
20
+ import { compileTheme } from '../schemas/themeCompile';
21
+ import type { Doc } from '../schemas/Doc.js';
22
+ import type { Theme } from '../schemas/Theme.js';
23
+
24
+ const brand: Theme = compileTheme({
25
+ id: 'my-brand',
26
+ name: 'My Brand',
27
+ seedColors: { primary: '#3182ce', secondary: '#805ad5' },
28
+ });
29
+
30
+ describe('writeCustomThemesToFrontmatter → readCustomThemesFromFrontmatter', () => {
31
+ it('round-trips a single theme through the compact JSON wire format', () => {
32
+ const payload = writeCustomThemesToFrontmatter([brand]);
33
+ expect(typeof payload).toBe('string');
34
+ expect(payload!.startsWith('{"my-brand":')).toBe(true);
35
+ const out = readCustomThemesFromFrontmatter({ [FRONTMATTER_CUSTOM_THEMES_KEY]: payload });
36
+ expect(out).toHaveLength(1);
37
+ expect(out![0]).toEqual(brand);
38
+ });
39
+
40
+ it('round-trips multiple themes keyed by id', () => {
41
+ const second = compileTheme({
42
+ id: 'second',
43
+ name: 'Second',
44
+ seedColors: { primary: '#e53e3e' },
45
+ });
46
+ const payload = writeCustomThemesToFrontmatter([brand, second]);
47
+ const out = readCustomThemesFromFrontmatter({ [FRONTMATTER_CUSTOM_THEMES_KEY]: payload });
48
+ expect(out!.map((t) => t.id).sort()).toEqual(['my-brand', 'second']);
49
+ });
50
+
51
+ it('also accepts an already-decoded object map (forward-compatible)', () => {
52
+ const out = readCustomThemesFromFrontmatter({
53
+ [FRONTMATTER_CUSTOM_THEMES_KEY]: { 'my-brand': brand },
54
+ });
55
+ expect(out).toHaveLength(1);
56
+ expect(out![0].id).toBe('my-brand');
57
+ });
58
+
59
+ it('drops invalid themes rather than failing the whole load', () => {
60
+ const payload = JSON.stringify({ 'my-brand': brand, junk: { id: 'junk' } });
61
+ const out = readCustomThemesFromFrontmatter({ [FRONTMATTER_CUSTOM_THEMES_KEY]: payload });
62
+ expect(out).toHaveLength(1);
63
+ expect(out![0].id).toBe('my-brand');
64
+ });
65
+
66
+ it('returns undefined when the key is absent or unparseable', () => {
67
+ expect(readCustomThemesFromFrontmatter({})).toBeUndefined();
68
+ expect(readCustomThemesFromFrontmatter(undefined)).toBeUndefined();
69
+ expect(
70
+ readCustomThemesFromFrontmatter({ [FRONTMATTER_CUSTOM_THEMES_KEY]: 'not json' }),
71
+ ).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe('Doc.customThemes round-trip via markdownToDoc + docToMarkdown', () => {
76
+ it('survives a markdownToDoc → docToMarkdown cycle and keeps the selection', () => {
77
+ const encoded = writeCustomThemesToFrontmatter([brand])!;
78
+ // Compact JSON is stored unquoted — it round-trips verbatim through the
79
+ // line-based frontmatter parser, exactly like custom templates.
80
+ const sourceMd = `---
81
+ title: My Doc
82
+ squisq-theme: my-brand
83
+ squisq-custom-themes: ${encoded}
84
+ ---
85
+
86
+ # Welcome
87
+
88
+ A small editor for big ideas.
89
+ `;
90
+ const doc = markdownToDoc(parseMarkdown(sourceMd));
91
+ expect(doc.customThemes).toHaveLength(1);
92
+ expect(doc.customThemes![0]).toEqual(brand);
93
+
94
+ const mdOut = docToMarkdown(doc);
95
+ const payload = mdOut.frontmatter![FRONTMATTER_CUSTOM_THEMES_KEY];
96
+ expect(typeof payload).toBe('string');
97
+ const reread = readCustomThemesFromFrontmatter({ [FRONTMATTER_CUSTOM_THEMES_KEY]: payload });
98
+ expect(reread).toEqual(doc.customThemes);
99
+ // The active selection is preserved untouched.
100
+ expect(mdOut.frontmatter!['squisq-theme']).toBe('my-brand');
101
+ });
102
+
103
+ it('drops the frontmatter key when the doc has no custom themes anymore', () => {
104
+ const doc: Doc = {
105
+ articleId: 'd',
106
+ duration: 0,
107
+ blocks: [],
108
+ audio: { segments: [] },
109
+ frontmatter: {
110
+ title: 'Doc',
111
+ [FRONTMATTER_CUSTOM_THEMES_KEY]: 'stale-blob',
112
+ },
113
+ };
114
+ const md = docToMarkdown(doc);
115
+ expect(md.frontmatter).not.toHaveProperty(FRONTMATTER_CUSTOM_THEMES_KEY);
116
+ expect(md.frontmatter!.title).toBe('Doc');
117
+ });
118
+ });