@bendyline/squisq 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
  4. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +11 -4
  5. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  6. package/dist/chunk-2OIBZYKP.js.map +1 -0
  7. package/dist/chunk-2UQOHYE2.js +45 -0
  8. package/dist/chunk-2UQOHYE2.js.map +1 -0
  9. package/dist/chunk-6ATE2PSM.js +344 -0
  10. package/dist/chunk-6ATE2PSM.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
  12. package/dist/chunk-A4O7GIWE.js.map +1 -0
  13. package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
  14. package/dist/chunk-DLXZMS5K.js.map +1 -0
  15. package/dist/chunk-ET53IIEP.js +1 -0
  16. package/dist/chunk-ET53IIEP.js.map +1 -0
  17. package/dist/chunk-HWVFJAAH.js +362 -0
  18. package/dist/chunk-HWVFJAAH.js.map +1 -0
  19. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  20. package/dist/chunk-IMSCRLLV.js.map +1 -0
  21. package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
  22. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  23. package/dist/chunk-LDQ2HJIX.js +596 -0
  24. package/dist/chunk-LDQ2HJIX.js.map +1 -0
  25. package/dist/chunk-LH7I6SH7.js +7208 -0
  26. package/dist/chunk-LH7I6SH7.js.map +1 -0
  27. package/dist/chunk-PGGGKYWG.js +1 -0
  28. package/dist/chunk-PGGGKYWG.js.map +1 -0
  29. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  30. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  31. package/dist/chunk-QE7TWGVT.js +178 -0
  32. package/dist/chunk-QE7TWGVT.js.map +1 -0
  33. package/dist/chunk-RUDYOTA6.js +345 -0
  34. package/dist/chunk-RUDYOTA6.js.map +1 -0
  35. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  36. package/dist/chunk-T5UK6YOB.js.map +1 -0
  37. package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
  38. package/dist/chunk-TEEEILMP.js.map +1 -0
  39. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  40. package/dist/chunk-TQWLI6S2.js.map +1 -0
  41. package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
  42. package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
  43. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  44. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  45. package/dist/chunk-ZLQKNOZQ.js +1 -0
  46. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  47. package/dist/doc/index.d.ts +5 -5
  48. package/dist/doc/index.js +135 -8
  49. package/dist/generate/index.d.ts +3 -74
  50. package/dist/generate/index.js +2 -7
  51. package/dist/icons/index.d.ts +3 -26
  52. package/dist/icons/index.js +13 -0
  53. package/dist/icons/inlineIconMarker.d.ts +1 -0
  54. package/dist/icons/inlineIconMarker.js +15 -0
  55. package/dist/icons/inlineIconMarker.js.map +1 -0
  56. package/dist/imageEdit/index.d.ts +12 -6
  57. package/dist/imageEdit/index.js +4 -1
  58. package/dist/index.d.ts +14 -11
  59. package/dist/index.js +265 -37
  60. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  61. package/dist/jsonForm/index.d.ts +42 -1
  62. package/dist/jsonForm/index.js +9 -1
  63. package/dist/markdown/index.d.ts +228 -3
  64. package/dist/markdown/index.js +42 -7
  65. package/dist/recommend/index.d.ts +1 -1
  66. package/dist/recommend/index.js +3 -2
  67. package/dist/schemas/index.d.ts +28 -8
  68. package/dist/schemas/index.js +36 -8
  69. package/dist/spatial/index.d.ts +9 -4
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.d.ts +2 -2
  72. package/dist/storage/index.js +1 -1
  73. package/dist/story/index.d.ts +1021 -41
  74. package/dist/story/index.js +135 -8
  75. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  76. package/dist/timing/index.js +3 -5
  77. package/dist/transform/index.d.ts +48 -10
  78. package/dist/transform/index.js +17 -481
  79. package/dist/transform/index.js.map +1 -1
  80. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  81. package/dist/versions/index.d.ts +1 -1
  82. package/package.json +6 -1
  83. package/src/__tests__/animationUtils.test.ts +40 -0
  84. package/src/__tests__/annotationCoercion.test.ts +243 -0
  85. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  86. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  87. package/src/__tests__/attrTokens.test.ts +162 -0
  88. package/src/__tests__/contentExtractor.test.ts +60 -0
  89. package/src/__tests__/coverBlock.test.ts +46 -1
  90. package/src/__tests__/customTemplateSample.test.ts +144 -0
  91. package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
  92. package/src/__tests__/customThemesFrontmatter.test.ts +145 -0
  93. package/src/__tests__/diagramBlock.test.ts +122 -0
  94. package/src/__tests__/diagramLayout.test.ts +134 -0
  95. package/src/__tests__/drawing.test.ts +109 -0
  96. package/src/__tests__/drawingBlock.test.ts +124 -0
  97. package/src/__tests__/drawingLayout.test.ts +171 -0
  98. package/src/__tests__/geohash.test.ts +6 -5
  99. package/src/__tests__/getLayers.test.ts +6 -2
  100. package/src/__tests__/getLayersFallback.test.ts +103 -0
  101. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  102. package/src/__tests__/imageEditVersions.test.ts +72 -0
  103. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  104. package/src/__tests__/inputDescriptors.test.ts +162 -0
  105. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  106. package/src/__tests__/layoutBlock.test.ts +83 -0
  107. package/src/__tests__/layoutLayout.test.ts +169 -0
  108. package/src/__tests__/markdown.test.ts +87 -0
  109. package/src/__tests__/markdownSanitize.test.ts +79 -0
  110. package/src/__tests__/markdownToDoc.test.ts +391 -2
  111. package/src/__tests__/mediaAnnotations.test.ts +91 -0
  112. package/src/__tests__/mediaSchedule.test.ts +118 -0
  113. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  114. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  115. package/src/__tests__/rootExports.test.ts +43 -0
  116. package/src/__tests__/shapeGeometry.test.ts +89 -0
  117. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  118. package/src/__tests__/structuredData.test.ts +249 -0
  119. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  120. package/src/__tests__/templateMetadata.test.ts +31 -0
  121. package/src/__tests__/templates.test.ts +257 -4
  122. package/src/__tests__/themeCompile.test.ts +42 -0
  123. package/src/__tests__/themeValidator.test.ts +6 -0
  124. package/src/__tests__/transformV2.test.ts +137 -0
  125. package/src/__tests__/transitionNormalize.test.ts +61 -0
  126. package/src/__tests__/validateDoc.test.ts +260 -0
  127. package/src/doc/annotationBlocks.ts +117 -0
  128. package/src/doc/customTemplatesFrontmatter.ts +314 -0
  129. package/src/doc/customThemesFrontmatter.ts +95 -0
  130. package/src/doc/docToMarkdown.ts +104 -5
  131. package/src/doc/getLayers.ts +49 -17
  132. package/src/doc/index.ts +47 -1
  133. package/src/doc/markdownToDoc.ts +411 -13
  134. package/src/doc/mediaAnnotations.ts +100 -0
  135. package/src/doc/resolveDocTheme.ts +55 -0
  136. package/src/doc/standaloneAnnotation.ts +58 -0
  137. package/src/doc/structuredData.ts +301 -0
  138. package/src/doc/templateInputs.ts +240 -0
  139. package/src/doc/templates/__tests__/customTemplate.test.ts +170 -0
  140. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  141. package/src/doc/templates/accentImage.ts +30 -6
  142. package/src/doc/templates/captionUtils.ts +23 -0
  143. package/src/doc/templates/comparisonBar.ts +33 -18
  144. package/src/doc/templates/coverBlock.ts +35 -5
  145. package/src/doc/templates/customTemplate.ts +51 -0
  146. package/src/doc/templates/dataTable.ts +37 -19
  147. package/src/doc/templates/dateEvent.ts +41 -20
  148. package/src/doc/templates/definitionCard.ts +26 -18
  149. package/src/doc/templates/diagramBlock.ts +204 -0
  150. package/src/doc/templates/diagramLayout.ts +159 -0
  151. package/src/doc/templates/drawingBlock.ts +325 -0
  152. package/src/doc/templates/drawingLayout.ts +539 -0
  153. package/src/doc/templates/factCard.ts +54 -26
  154. package/src/doc/templates/fallbackBlock.ts +123 -0
  155. package/src/doc/templates/featureBlock.ts +20 -13
  156. package/src/doc/templates/fullBleedQuote.ts +18 -8
  157. package/src/doc/templates/imageWithCaption.ts +30 -15
  158. package/src/doc/templates/index.ts +225 -46
  159. package/src/doc/templates/inputDescriptors.ts +432 -0
  160. package/src/doc/templates/layoutBlock.ts +43 -0
  161. package/src/doc/templates/layoutLayout.ts +243 -0
  162. package/src/doc/templates/listBlock.ts +56 -30
  163. package/src/doc/templates/mapBlock.ts +15 -12
  164. package/src/doc/templates/metadata.ts +120 -0
  165. package/src/doc/templates/persistentLayers.ts +129 -11
  166. package/src/doc/templates/photoGrid.ts +22 -34
  167. package/src/doc/templates/pullQuote.ts +30 -12
  168. package/src/doc/templates/quoteBlock.ts +54 -29
  169. package/src/doc/templates/sectionHeader.ts +25 -11
  170. package/src/doc/templates/statHighlight.ts +39 -22
  171. package/src/doc/templates/titleBlock.ts +59 -21
  172. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +487 -0
  173. package/src/doc/templates/tokens/resolveTokens.ts +453 -0
  174. package/src/doc/templates/twoColumn.ts +48 -30
  175. package/src/doc/templates/videoPullQuote.ts +22 -12
  176. package/src/doc/templates/videoWithCaption.ts +21 -15
  177. package/src/doc/utils/animationUtils.ts +267 -3
  178. package/src/doc/utils/applyRenderStyle.ts +113 -0
  179. package/src/doc/utils/imageTreatment.ts +70 -0
  180. package/src/doc/utils/nearest.ts +59 -0
  181. package/src/doc/utils/shapeGeometry.ts +464 -0
  182. package/src/doc/utils/themeUtils.ts +75 -1
  183. package/src/doc/validate.ts +575 -0
  184. package/src/generate/contentExtractor.ts +148 -35
  185. package/src/generate/index.ts +0 -3
  186. package/src/icons/index.ts +8 -0
  187. package/src/icons/inlineIconMarker.ts +73 -0
  188. package/src/imageEdit/export.ts +85 -2
  189. package/src/imageEdit/index.ts +1 -0
  190. package/src/imageEdit/persistence.ts +51 -0
  191. package/src/index.ts +2 -0
  192. package/src/jsonForm/index.ts +2 -0
  193. package/src/jsonForm/tokens.ts +78 -0
  194. package/src/markdown/annotationCoercion.ts +295 -0
  195. package/src/markdown/attrTokens.ts +241 -0
  196. package/src/markdown/convert.ts +229 -26
  197. package/src/markdown/index.ts +37 -0
  198. package/src/markdown/sanitize.ts +318 -0
  199. package/src/markdown/stringify.ts +96 -6
  200. package/src/markdown/types.ts +66 -0
  201. package/src/markdown/utils.ts +111 -2
  202. package/src/recommend/templates.ts +24 -0
  203. package/src/schemas/BlockTemplates.ts +133 -4
  204. package/src/schemas/CustomTemplates.ts +126 -0
  205. package/src/schemas/Doc.ts +387 -65
  206. package/src/schemas/ImageEditDoc.ts +11 -4
  207. package/src/schemas/Media.ts +169 -0
  208. package/src/schemas/Theme.ts +23 -1
  209. package/src/schemas/Transitions.ts +224 -0
  210. package/src/schemas/colorUtils.ts +33 -0
  211. package/src/schemas/fontStacks.ts +5 -2
  212. package/src/schemas/index.ts +3 -0
  213. package/src/schemas/themeCompile.ts +24 -5
  214. package/src/schemas/themeValidator.ts +49 -11
  215. package/src/schemas/themes/cinematic.json +69 -12
  216. package/src/schemas/themes/documentary.json +58 -11
  217. package/src/schemas/themes/gezellig.json +60 -11
  218. package/src/schemas/themes/magazine.json +54 -9
  219. package/src/schemas/themes/morning-light.json +52 -9
  220. package/src/schemas/themes/standard-dark.json +48 -13
  221. package/src/schemas/themes/standard.json +44 -12
  222. package/src/schemas/themes/tech-dark.json +65 -12
  223. package/src/spatial/Geohash.ts +52 -21
  224. package/src/storage/ContentContainer.ts +6 -2
  225. package/src/transform/applyTransform.ts +43 -4
  226. package/src/transform/index.ts +1 -0
  227. package/src/transform/registry.ts +44 -13
  228. package/src/transform/styles/dataDriven.ts +2 -1
  229. package/src/transform/styles/documentary.ts +2 -1
  230. package/src/transform/styles/magazine.ts +3 -1
  231. package/src/transform/styles/minimal.ts +2 -1
  232. package/src/transform/styles/narrative.ts +3 -1
  233. package/src/transform/templateSelector.ts +87 -11
  234. package/src/transform/types.ts +35 -2
  235. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  236. package/dist/chunk-3E5F2XMR.js.map +0 -1
  237. package/dist/chunk-3K5OG6KI.js.map +0 -1
  238. package/dist/chunk-4X3JQXNM.js +0 -35
  239. package/dist/chunk-4X3JQXNM.js.map +0 -1
  240. package/dist/chunk-57CVQCFX.js.map +0 -1
  241. package/dist/chunk-5WFRKURX.js.map +0 -1
  242. package/dist/chunk-7UDSRZKG.js.map +0 -1
  243. package/dist/chunk-B4EEON3N.js.map +0 -1
  244. package/dist/chunk-FR2RBTKO.js.map +0 -1
  245. package/dist/chunk-H3AXU4MJ.js +0 -179
  246. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  247. package/dist/chunk-K32VJONL.js.map +0 -1
  248. package/dist/chunk-KSWJR66U.js +0 -3706
  249. package/dist/chunk-KSWJR66U.js.map +0 -1
  250. package/dist/chunk-MYIH7FWD.js +0 -145
  251. package/dist/chunk-MYIH7FWD.js.map +0 -1
  252. package/dist/chunk-ZTX4PFFT.js.map +0 -1
  253. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  254. package/src/generate/slideshowGenerator.ts +0 -273
@@ -0,0 +1,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
+ });
@@ -116,6 +116,36 @@ describe('extractContent — comparisons', () => {
116
116
  expect(comp.right.label).toBeTruthy();
117
117
  }
118
118
  });
119
+
120
+ it('does not let a comparison capture cross a sentence boundary', () => {
121
+ // Regression: greedy `.{2,30}` used to capture right="witness. Robertson"
122
+ // from the "from witness to witness." idiom bleeding into the next sentence.
123
+ const text =
124
+ 'Testimony about who carried guns and how many shots were fired ' +
125
+ 'contradicted from witness to witness. Robertson, who initially ' +
126
+ 'testified against the others, was later named as the ringleader.';
127
+ const result = extractContent(text, { types: ['comparison'] });
128
+ for (const el of result.elements) {
129
+ if (el.data.type === 'comparison') {
130
+ expect(el.data.left.label).not.toContain('.');
131
+ expect(el.data.right.label).not.toContain('.');
132
+ expect(el.data.right.label.toLowerCase()).not.toContain('robertson');
133
+ }
134
+ }
135
+ });
136
+
137
+ it('rejects idiomatic "from X to X" non-comparisons', () => {
138
+ // "from witness to witness", "from day to day" mean "it varied", not "A vs B".
139
+ for (const text of [
140
+ 'The testimony varied from witness to witness across the trial.',
141
+ 'Conditions changed from day to day during the expedition.',
142
+ 'The practice was handed down from generation to generation.',
143
+ ]) {
144
+ const result = extractContent(text, { types: ['comparison'] });
145
+ const comparisons = result.elements.filter((e) => e.data.type === 'comparison');
146
+ expect(comparisons.length).toBe(0);
147
+ }
148
+ });
119
149
  });
120
150
 
121
151
  describe('extractContent — impact lines', () => {
@@ -125,6 +155,36 @@ describe('extractContent — impact lines', () => {
125
155
  const result = extractContent(text, { types: ['impactLine'] });
126
156
  expect(result.elements.length).toBeGreaterThan(0);
127
157
  });
158
+
159
+ it('does not split sentences on abbreviations or initials', () => {
160
+ // Regression: "…at George W. Tibbetts' store…" was split at "W." into the
161
+ // fragment "They were met at George W" and shown as a standalone slide.
162
+ const text =
163
+ "They were met at George W. Tibbetts' store by a crowd of white and Native men " +
164
+ 'who intimidated them into turning back.';
165
+ const result = extractContent(text, { types: ['impactLine', 'fact'] });
166
+ for (const el of result.elements) {
167
+ if (el.data.type === 'impactLine') {
168
+ expect(el.data.text).not.toMatch(/George W$/);
169
+ }
170
+ if (el.data.type === 'fact') {
171
+ expect(el.data.fact).not.toMatch(/George W$/);
172
+ }
173
+ // No fragment should end on a bare single-letter initial.
174
+ expect(el.text).not.toMatch(/\b[A-Z]$/);
175
+ }
176
+ });
177
+
178
+ it('keeps initials-heavy name lists intact as one sentence', () => {
179
+ const text =
180
+ 'A jury named M. DeWitt Rumsey, Joseph Day, and David Hughes among the killers. ' +
181
+ 'The verdict shocked the town.';
182
+ const result = extractContent(text, { types: ['fact', 'impactLine'] });
183
+ // The "M." initial must not spawn a "A jury named M" fragment.
184
+ for (const el of result.elements) {
185
+ expect(el.text).not.toMatch(/named M$/);
186
+ }
187
+ });
128
188
  });
129
189
 
130
190
  describe('extractContent — lists', () => {
@@ -1,5 +1,10 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { coverBlock, expandCoverBlock, startBlockToCoverInput } from '../doc/templates/coverBlock';
2
+ import {
3
+ coverBlock,
4
+ expandCoverBlock,
5
+ startBlockToCoverInput,
6
+ fitCoverTitleSize,
7
+ } from '../doc/templates/coverBlock';
3
8
  import { createTemplateContext, DEFAULT_THEME, VIEWPORT_PRESETS } from '../doc/templates/index';
4
9
  import type { StartBlockConfig } from '../schemas/Doc';
5
10
  import type { CoverBlockInput } from '../doc/templates/coverBlock';
@@ -179,6 +184,46 @@ describe('coverBlock viewport scaling', () => {
179
184
  });
180
185
  });
181
186
 
187
+ // ── Long-title auto-fit ─────────────────────────────────────────────
188
+
189
+ describe('coverBlock long-title auto-fit', () => {
190
+ const LONG = 'Seattle: The Emerald City That Rebuilt Itself on Top of Itself';
191
+
192
+ it('fitCoverTitleSize returns full base for short titles', () => {
193
+ expect(fitCoverTitleSize('Kirkland')).toBe(120);
194
+ expect(fitCoverTitleSize('Test Title')).toBe(120);
195
+ });
196
+
197
+ it('fitCoverTitleSize shrinks long titles below the full base', () => {
198
+ const size = fitCoverTitleSize(LONG);
199
+ expect(size).toBeLessThan(120);
200
+ expect(size).toBeGreaterThanOrEqual(62); // never below the readability floor
201
+ });
202
+
203
+ it('fitCoverTitleSize never drops below the readability floor', () => {
204
+ const size = fitCoverTitleSize('x'.repeat(400));
205
+ expect(size).toBe(62);
206
+ });
207
+
208
+ it('fitCoverTitleSize is monotonic — longer titles are never larger', () => {
209
+ const a = fitCoverTitleSize('A moderately long cover title here');
210
+ const b = fitCoverTitleSize('A moderately long cover title here that keeps going and going');
211
+ expect(b).toBeLessThanOrEqual(a);
212
+ });
213
+
214
+ it('renders a long title with a smaller font than a short one', () => {
215
+ const shortTitle = findText(
216
+ coverBlock({ heroSrc: 'h.jpg', title: 'Short' }, landscapeContext),
217
+ 'cover-title',
218
+ );
219
+ const longTitle = findText(
220
+ coverBlock({ heroSrc: 'h.jpg', title: LONG }, landscapeContext),
221
+ 'cover-title',
222
+ );
223
+ expect(longTitle!.content.style.fontSize).toBeLessThan(shortTitle!.content.style.fontSize);
224
+ });
225
+ });
226
+
182
227
  // ── startBlockToCoverInput ──────────────────────────────────────────
183
228
 
184
229
  describe('startBlockToCoverInput', () => {
@@ -0,0 +1,144 @@
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 {
15
+ writeCustomTemplatesToFrontmatter,
16
+ readCustomTemplatesFromFrontmatter,
17
+ FRONTMATTER_CUSTOM_TEMPLATES_KEY,
18
+ } from '../doc/customTemplatesFrontmatter';
19
+ import type { CustomTemplateDefinition, LayerRepeat } from '../schemas/CustomTemplates.js';
20
+ import type { TextLayer } from '../schemas/Doc.js';
21
+ import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
22
+
23
+ const heroDef: CustomTemplateDefinition = {
24
+ name: 'hero',
25
+ label: 'Hero Section',
26
+ viewport: { width: 1920, height: 1080 },
27
+ layers: [
28
+ {
29
+ id: 'hero-title',
30
+ type: 'text',
31
+ position: { x: '6%', y: '20%', width: '88%' },
32
+ content: {
33
+ text: '{title}',
34
+ style: { fontSize: 96, color: '#0f172a' },
35
+ },
36
+ },
37
+ {
38
+ id: 'hero-body',
39
+ type: 'text',
40
+ position: { x: '6%', y: '52%', width: '88%' },
41
+ content: {
42
+ text: '{content}',
43
+ style: { fontSize: 36, color: '#475569' },
44
+ },
45
+ },
46
+ ],
47
+ };
48
+
49
+ function buildSampleMarkdown(): string {
50
+ const payload = writeCustomTemplatesToFrontmatter([heroDef]);
51
+ return `---
52
+ squisq-custom-templates: "${payload}"
53
+ ---
54
+
55
+ # Welcome to Squisq {[hero]}
56
+
57
+ A small editor for big ideas.
58
+ `;
59
+ }
60
+
61
+ describe('Custom template pipeline (markdown → expanded layers)', () => {
62
+ it('substitutes {title} and {content} when a doc uses a frontmatter-defined template', () => {
63
+ const doc = markdownToDoc(parseMarkdown(buildSampleMarkdown()));
64
+ const blocks = expandDocBlocks(doc.blocks, {
65
+ customTemplates: doc.customTemplates,
66
+ });
67
+ expect(blocks).toHaveLength(1);
68
+ const layers = blocks[0].layers ?? [];
69
+ expect(layers).toHaveLength(2);
70
+
71
+ expect((layers[0] as TextLayer).content.text).toBe('Welcome to Squisq');
72
+ expect((layers[1] as TextLayer).content.text).toBe('A small editor for big ideas.');
73
+ });
74
+
75
+ it('renders the same template against a portrait viewport without re-authoring', () => {
76
+ const doc = markdownToDoc(parseMarkdown(buildSampleMarkdown()));
77
+ const blocks = expandDocBlocks(doc.blocks, {
78
+ customTemplates: doc.customTemplates,
79
+ viewport: VIEWPORT_PRESETS.portrait,
80
+ });
81
+ const layers = blocks[0].layers ?? [];
82
+ // %-based positions survive the viewport switch — the SSR
83
+ // renderer resolves them against the actual viewport at draw time.
84
+ expect(layers[0].position.x).toBe('6%');
85
+ expect(layers[0].position.width).toBe('88%');
86
+ });
87
+
88
+ it('resolves {attr:key} against a block heading annotation through the pipeline', () => {
89
+ const attrDef: CustomTemplateDefinition = {
90
+ name: 'badge',
91
+ label: 'Badge',
92
+ viewport: { width: 1920, height: 1080 },
93
+ layers: [
94
+ {
95
+ id: 'badge-role',
96
+ type: 'text',
97
+ position: { x: '6%', y: '20%', width: '88%' },
98
+ content: { text: 'Role: {attr:role|none}', style: { fontSize: 48, color: '#000' } },
99
+ },
100
+ ],
101
+ };
102
+ const payload = writeCustomTemplatesToFrontmatter([attrDef]);
103
+ const md = `---
104
+ squisq-custom-templates: "${payload}"
105
+ ---
106
+
107
+ # Team {[badge role=lead]}
108
+
109
+ Body.
110
+ `;
111
+ const doc = markdownToDoc(parseMarkdown(md));
112
+ const blocks = expandDocBlocks(doc.blocks, { customTemplates: doc.customTemplates });
113
+ const layers = blocks[0].layers ?? [];
114
+ expect((layers[0] as TextLayer).content.text).toBe('Role: lead');
115
+ });
116
+ });
117
+
118
+ describe('Custom template codec — repeat round-trip', () => {
119
+ it('preserves a layer.repeat directive through write → read (passes through unmapped)', () => {
120
+ const repeat: LayerRepeat = { source: 'images', direction: 'row', gap: 12, max: 4 };
121
+ const def: CustomTemplateDefinition = {
122
+ name: 'gallery',
123
+ label: 'Gallery',
124
+ viewport: { width: 1920, height: 1080 },
125
+ layers: [
126
+ {
127
+ id: 'thumb',
128
+ type: 'image',
129
+ position: { x: '0%', y: '0%', width: '25%', height: '25%' },
130
+ content: { src: '{item:src}', alt: '{item:label}' },
131
+ repeat,
132
+ },
133
+ ],
134
+ };
135
+ const payload = writeCustomTemplatesToFrontmatter([def]);
136
+ expect(payload).toBeDefined();
137
+ const round = readCustomTemplatesFromFrontmatter({
138
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: JSON.parse(payload as string),
139
+ });
140
+ expect(round).toBeDefined();
141
+ const layer = round?.[0].layers[0] as (typeof def.layers)[number];
142
+ expect(layer.repeat).toEqual(repeat);
143
+ });
144
+ });