@bendyline/squisq 1.5.1 → 2.0.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 (268) hide show
  1. package/README.md +63 -10
  2. package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
  3. package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
  4. package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
  5. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  6. package/dist/chunk-3E5F2XMR.js.map +1 -0
  7. package/dist/chunk-4X3JQXNM.js +35 -0
  8. package/dist/chunk-4X3JQXNM.js.map +1 -0
  9. package/dist/{chunk-2OIBZYKP.js → chunk-5DKBFXPY.js} +31 -121
  10. package/dist/chunk-5DKBFXPY.js.map +1 -0
  11. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  12. package/dist/chunk-5J4HKRJF.js.map +1 -0
  13. package/dist/chunk-5UYI3RJB.js +2503 -0
  14. package/dist/chunk-5UYI3RJB.js.map +1 -0
  15. package/dist/{chunk-TEEEILMP.js → chunk-66QO7XFB.js} +19 -292
  16. package/dist/chunk-66QO7XFB.js.map +1 -0
  17. package/dist/{chunk-KKNUBQ6Y.js → chunk-6PJRB2CF.js} +156 -91
  18. package/dist/chunk-6PJRB2CF.js.map +1 -0
  19. package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
  20. package/dist/chunk-AMIR72JP.js.map +1 -0
  21. package/dist/chunk-FVJUUTEM.js +74 -0
  22. package/dist/chunk-FVJUUTEM.js.map +1 -0
  23. package/dist/{chunk-LH7I6SH7.js → chunk-GF3BVNLU.js} +6360 -5723
  24. package/dist/chunk-GF3BVNLU.js.map +1 -0
  25. package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
  26. package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
  27. package/dist/chunk-JOQYPRDZ.js +127 -0
  28. package/dist/chunk-JOQYPRDZ.js.map +1 -0
  29. package/dist/{chunk-A4O7GIWE.js → chunk-LYNJEOAC.js} +10 -73
  30. package/dist/chunk-LYNJEOAC.js.map +1 -0
  31. package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
  32. package/dist/chunk-PXLVXTLW.js.map +1 -0
  33. package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
  34. package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
  35. package/dist/{chunk-T5UK6YOB.js → chunk-QTSJOY54.js} +60 -17
  36. package/dist/chunk-QTSJOY54.js.map +1 -0
  37. package/dist/chunk-QVANWDPH.js +2358 -0
  38. package/dist/chunk-QVANWDPH.js.map +1 -0
  39. package/dist/{chunk-6ATE2PSM.js → chunk-R2GC6RLD.js} +7 -7
  40. package/dist/chunk-R2GC6RLD.js.map +1 -0
  41. package/dist/chunk-R2YZTCLO.js +292 -0
  42. package/dist/chunk-R2YZTCLO.js.map +1 -0
  43. package/dist/chunk-S7A5D7ZJ.js +174 -0
  44. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  45. package/dist/chunk-T3TI7GSZ.js +884 -0
  46. package/dist/chunk-T3TI7GSZ.js.map +1 -0
  47. package/dist/{chunk-RUDYOTA6.js → chunk-TUWMK62B.js} +110 -4
  48. package/dist/chunk-TUWMK62B.js.map +1 -0
  49. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  50. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  51. package/dist/doc/index.d.ts +2334 -5
  52. package/dist/doc/index.js +134 -34
  53. package/dist/generate/index.d.ts +7 -2
  54. package/dist/generate/index.js +1 -1
  55. package/dist/imageEdit/index.d.ts +9 -6
  56. package/dist/imageEdit/index.js +3 -3
  57. package/dist/index.d.ts +12 -10
  58. package/dist/index.js +253 -81
  59. package/dist/jsonForm/index.d.ts +7 -3
  60. package/dist/jsonForm/index.js +9 -4
  61. package/dist/markdown/index.d.ts +6 -70
  62. package/dist/markdown/index.js +13 -9
  63. package/dist/narration/index.d.ts +618 -0
  64. package/dist/narration/index.js +82 -0
  65. package/dist/recommend/index.d.ts +15 -1
  66. package/dist/recommend/index.js +2 -2
  67. package/dist/schemas/index.d.ts +28 -6
  68. package/dist/schemas/index.js +15 -13
  69. package/dist/spatial/index.d.ts +0 -1
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.js +1 -1
  72. package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
  73. package/dist/timing/index.js +5 -3
  74. package/dist/transform/index.d.ts +35 -24
  75. package/dist/transform/index.js +9 -10
  76. package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
  77. package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
  78. package/dist/versions/index.d.ts +2 -1
  79. package/dist/versions/index.js +2 -2
  80. package/package.json +6 -6
  81. package/src/__tests__/applyNarrationTiming.test.ts +190 -0
  82. package/src/__tests__/applyRenderStyle.test.ts +3 -3
  83. package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
  84. package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
  85. package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
  86. package/src/__tests__/asciiDiagramParse.test.ts +233 -0
  87. package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
  88. package/src/__tests__/asciiDiagramRender.test.ts +162 -0
  89. package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
  90. package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
  91. package/src/__tests__/asciiTimelineParse.test.ts +205 -0
  92. package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
  93. package/src/__tests__/asciiTimelineRender.test.ts +376 -0
  94. package/src/__tests__/attrTokens.test.ts +5 -0
  95. package/src/__tests__/contentContainer.test.ts +43 -0
  96. package/src/__tests__/coverBlock.test.ts +33 -2
  97. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  98. package/src/__tests__/dataTable.test.ts +25 -15
  99. package/src/__tests__/diagramBlock.test.ts +3 -3
  100. package/src/__tests__/diagramText.test.ts +26 -0
  101. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  102. package/src/__tests__/drawingBlock.test.ts +3 -3
  103. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  104. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  105. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  106. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  107. package/src/__tests__/getLayers.test.ts +32 -18
  108. package/src/__tests__/getLayersFallback.test.ts +8 -8
  109. package/src/__tests__/imageEditVersions.test.ts +2 -0
  110. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  111. package/src/__tests__/layoutBlock.test.ts +3 -3
  112. package/src/__tests__/majorApiTypes.test.ts +18 -0
  113. package/src/__tests__/markdown.test.ts +41 -0
  114. package/src/__tests__/markdownToDoc.test.ts +5 -2
  115. package/src/__tests__/matchFontFamily.test.ts +114 -0
  116. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  117. package/src/__tests__/materializeTestUtils.ts +17 -0
  118. package/src/__tests__/narrationAlign.test.ts +150 -0
  119. package/src/__tests__/narrationFeatures.test.ts +71 -0
  120. package/src/__tests__/narrationNuclei.test.ts +45 -0
  121. package/src/__tests__/narrationPacing.test.ts +189 -0
  122. package/src/__tests__/narrationScript.test.ts +149 -0
  123. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  124. package/src/__tests__/narrationSidecar.test.ts +88 -0
  125. package/src/__tests__/narrationSyllables.test.ts +65 -0
  126. package/src/__tests__/narrationTestSignals.ts +167 -0
  127. package/src/__tests__/narrationVad.test.ts +66 -0
  128. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  129. package/src/__tests__/rootExports.test.ts +18 -0
  130. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  131. package/src/__tests__/shapeGeometry.test.ts +49 -0
  132. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  133. package/src/__tests__/templateInputs.test.ts +69 -0
  134. package/src/__tests__/templateMetadata.test.ts +2 -1
  135. package/src/__tests__/templates.test.ts +143 -17
  136. package/src/__tests__/themeCompile.test.ts +49 -22
  137. package/src/__tests__/transformProvenance.test.ts +160 -0
  138. package/src/__tests__/transformV2.test.ts +40 -14
  139. package/src/__tests__/treeviewBattle.test.ts +76 -0
  140. package/src/__tests__/treeviewDetect.test.ts +168 -0
  141. package/src/__tests__/treeviewParse.test.ts +118 -0
  142. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  143. package/src/__tests__/treeviewRender.test.ts +91 -0
  144. package/src/__tests__/versions.test.ts +44 -0
  145. package/src/doc/applyNarrationTiming.ts +244 -0
  146. package/src/doc/asciiDiagram/chars.ts +210 -0
  147. package/src/doc/asciiDiagram/detect.ts +175 -0
  148. package/src/doc/asciiDiagram/index.ts +38 -0
  149. package/src/doc/asciiDiagram/mapping.ts +225 -0
  150. package/src/doc/asciiDiagram/parse.ts +1095 -0
  151. package/src/doc/asciiDiagram/render.ts +963 -0
  152. package/src/doc/asciiDiagram/repair.ts +400 -0
  153. package/src/doc/asciiDiagram/types.ts +65 -0
  154. package/src/doc/asciiTimeline/detect.ts +138 -0
  155. package/src/doc/asciiTimeline/index.ts +26 -0
  156. package/src/doc/asciiTimeline/mapping.ts +43 -0
  157. package/src/doc/asciiTimeline/parse.ts +870 -0
  158. package/src/doc/asciiTimeline/render.ts +427 -0
  159. package/src/doc/asciiTimeline/types.ts +73 -0
  160. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  161. package/src/doc/audioMapping.ts +60 -46
  162. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  163. package/src/doc/docToMarkdown.ts +126 -39
  164. package/src/doc/index.ts +76 -4
  165. package/src/doc/markdownToDoc.ts +135 -1
  166. package/src/doc/materializeBlockLayers.ts +304 -0
  167. package/src/doc/mediaAnnotations.ts +20 -0
  168. package/src/doc/resolveDocTheme.ts +8 -7
  169. package/src/doc/templateInputs.ts +185 -2
  170. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  171. package/src/doc/templates/coverBlock.ts +49 -13
  172. package/src/doc/templates/diagramBlock.ts +237 -38
  173. package/src/doc/templates/drawingBlock.ts +2 -2
  174. package/src/doc/templates/fallbackBlock.ts +1 -1
  175. package/src/doc/templates/index.ts +134 -279
  176. package/src/doc/templates/inputDescriptors.ts +20 -1
  177. package/src/doc/templates/metadata.ts +10 -0
  178. package/src/doc/templates/persistentLayers.ts +12 -126
  179. package/src/doc/templates/registry.ts +88 -0
  180. package/src/doc/templates/templateNames.ts +22 -0
  181. package/src/doc/templates/timelineBlock.ts +613 -0
  182. package/src/doc/templates/treeBlock.ts +131 -0
  183. package/src/doc/treeview/chars.ts +72 -0
  184. package/src/doc/treeview/detect.ts +145 -0
  185. package/src/doc/treeview/index.ts +30 -0
  186. package/src/doc/treeview/mapping.ts +95 -0
  187. package/src/doc/treeview/parse.ts +201 -0
  188. package/src/doc/treeview/render.ts +41 -0
  189. package/src/doc/treeview/types.ts +43 -0
  190. package/src/doc/utils/applyRenderStyle.ts +2 -9
  191. package/src/doc/utils/diagramText.ts +78 -0
  192. package/src/doc/utils/shapeGeometry.ts +157 -7
  193. package/src/doc/validate.ts +1 -1
  194. package/src/generate/templateMapper.ts +7 -0
  195. package/src/imageEdit/export.ts +6 -5
  196. package/src/imageEdit/versionPaths.ts +13 -26
  197. package/src/imageEdit/versions.ts +21 -100
  198. package/src/index.ts +1 -0
  199. package/src/internal/immutable.ts +58 -0
  200. package/src/jsonForm/index.ts +9 -1
  201. package/src/jsonForm/pointer.ts +68 -23
  202. package/src/markdown/attrTokens.ts +7 -5
  203. package/src/markdown/convert.ts +11 -1
  204. package/src/markdown/index.ts +1 -0
  205. package/src/markdown/stringify.ts +4 -9
  206. package/src/markdown/utils.ts +36 -14
  207. package/src/narration/align.ts +514 -0
  208. package/src/narration/features.ts +231 -0
  209. package/src/narration/index.ts +86 -0
  210. package/src/narration/nuclei.ts +118 -0
  211. package/src/narration/pacing.ts +211 -0
  212. package/src/narration/script.ts +239 -0
  213. package/src/narration/session.ts +100 -0
  214. package/src/narration/sidecar.ts +187 -0
  215. package/src/narration/syllables.ts +85 -0
  216. package/src/narration/trace.ts +50 -0
  217. package/src/narration/types.ts +299 -0
  218. package/src/narration/vad.ts +131 -0
  219. package/src/recommend/numberHighlight.ts +25 -0
  220. package/src/recommend/templates.ts +108 -12
  221. package/src/schemas/BlockTemplates.ts +156 -7
  222. package/src/schemas/Doc.ts +72 -9
  223. package/src/schemas/Media.ts +12 -2
  224. package/src/schemas/Theme.ts +45 -25
  225. package/src/schemas/Viewport.ts +2 -2
  226. package/src/schemas/fontStacks.ts +79 -0
  227. package/src/schemas/themeCompile.ts +15 -0
  228. package/src/schemas/themeConstants.ts +2 -0
  229. package/src/schemas/themeLibrary.ts +25 -19
  230. package/src/schemas/themeValidator.ts +1 -1
  231. package/src/spatial/Geohash.ts +0 -3
  232. package/src/storage/ContentContainer.ts +17 -9
  233. package/src/storage/ScopedContentContainer.ts +45 -15
  234. package/src/transform/applyTransform.ts +5 -5
  235. package/src/transform/index.ts +3 -1
  236. package/src/transform/registry.ts +169 -49
  237. package/src/transform/templateSelector.ts +21 -2
  238. package/src/transform/timingAllocator.ts +152 -21
  239. package/src/transform/types.ts +14 -1
  240. package/src/versions/gitignore.ts +34 -0
  241. package/src/versions/index.ts +2 -0
  242. package/src/versions/operations.ts +18 -104
  243. package/src/versions/paths.ts +13 -28
  244. package/src/versions/snapshotStore.ts +202 -0
  245. package/src/versions/types.ts +3 -1
  246. package/dist/chunk-2OIBZYKP.js.map +0 -1
  247. package/dist/chunk-6ATE2PSM.js.map +0 -1
  248. package/dist/chunk-A4O7GIWE.js.map +0 -1
  249. package/dist/chunk-DLXZMS5K.js.map +0 -1
  250. package/dist/chunk-HWVFJAAH.js.map +0 -1
  251. package/dist/chunk-IMSCRLLV.js.map +0 -1
  252. package/dist/chunk-JELIRVDP.js +0 -24
  253. package/dist/chunk-JELIRVDP.js.map +0 -1
  254. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  255. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  256. package/dist/chunk-LH7I6SH7.js.map +0 -1
  257. package/dist/chunk-RUDYOTA6.js.map +0 -1
  258. package/dist/chunk-T5UK6YOB.js.map +0 -1
  259. package/dist/chunk-TEEEILMP.js.map +0 -1
  260. package/dist/chunk-UY7KGQ5R.js +0 -192
  261. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  262. package/dist/chunk-VLJJHDAC.js +0 -218
  263. package/dist/chunk-VLJJHDAC.js.map +0 -1
  264. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  265. package/dist/story/index.d.ts +0 -1777
  266. package/dist/story/index.js +0 -273
  267. package/src/doc/getLayers.ts +0 -195
  268. /package/dist/{story → narration}/index.js.map +0 -0
@@ -73,6 +73,26 @@ describe('ScopedContentContainer', () => {
73
73
  expect(() => scopeContainer(parent, '/')).toThrow();
74
74
  expect(() => scopeContainer(parent, 'a/../b')).toThrow();
75
75
  expect(() => scopeContainer(parent, './a')).toThrow();
76
+ expect(() => scopeContainer(parent, 'a\\b')).toThrow(/forward slashes/);
77
+ });
78
+
79
+ it('rejects traversal, dot segments, and backslashes on every file operation', async () => {
80
+ const scoped = scopeContainer(parent, 'sidecar');
81
+ for (const badPath of ['../escape.txt', 'a/../../escape.txt', './same.txt', 'a\\b.txt']) {
82
+ expect(() => scoped.readFile(badPath)).toThrow();
83
+ expect(() => scoped.writeFile(badPath, enc('x'))).toThrow();
84
+ expect(() => scoped.removeFile(badPath)).toThrow();
85
+ expect(() => scoped.exists(badPath)).toThrow();
86
+ await expect(scoped.listFiles(badPath)).rejects.toThrow();
87
+ }
88
+ expect(await parent.listFiles()).toEqual([]);
89
+ });
90
+
91
+ it('normalizes repeated and leading slashes without changing scope', async () => {
92
+ const scoped = scopeContainer(parent, '//sidecar//nested//');
93
+ expect(scoped.prefix).toBe('sidecar/nested');
94
+ await scoped.writeFile('//assets//x.txt', enc('x'));
95
+ expect(dec(await parent.readFile('sidecar/nested/assets/x.txt'))).toBe('x');
76
96
  });
77
97
 
78
98
  it('getDocumentPath / readDocument always return null', async () => {
@@ -4,8 +4,11 @@ import {
4
4
  markerPath,
5
5
  connectorPath,
6
6
  clipEndpoints,
7
+ nearestSnapPoint,
7
8
  lineStyleDasharray,
8
9
  PATH_SHAPE_KINDS,
10
+ snapEndpoints,
11
+ snapPoints,
9
12
  } from '../doc/utils/shapeGeometry.js';
10
13
 
11
14
  describe('shapePath', () => {
@@ -66,9 +69,21 @@ describe('connectorPath', () => {
66
69
  expect(connectorPath('orthogonal', a, b)).toBe('M 0 0 L 50 0 L 50 50 L 100 50');
67
70
  });
68
71
 
72
+ it('orthogonal routing approaches top/bottom ports vertically', () => {
73
+ expect(
74
+ connectorPath('orthogonal', { port: 'bottom', x: 80, y: 60 }, { port: 'top', x: 20, y: 140 }),
75
+ ).toBe('M 80 60 L 80 100 L 20 100 L 20 140');
76
+ });
77
+
69
78
  it('curved is a cubic bezier', () => {
70
79
  expect(connectorPath('curved', a, b).startsWith('M 0 0 C')).toBe(true);
71
80
  });
81
+
82
+ it('curved routing follows the endpoint port directions', () => {
83
+ expect(
84
+ connectorPath('curved', { port: 'bottom', x: 80, y: 60 }, { port: 'top', x: 20, y: 140 }),
85
+ ).toBe('M 80 60 C 80 110, 20 90, 20 140');
86
+ });
72
87
  });
73
88
 
74
89
  describe('clipEndpoints', () => {
@@ -79,6 +94,40 @@ describe('clipEndpoints', () => {
79
94
  });
80
95
  });
81
96
 
97
+ describe('snapEndpoints', () => {
98
+ const a = { cx: 50, cy: 50, rx: 30, ry: 20 };
99
+ const b = { cx: 180, cy: 50, rx: 30, ry: 20 };
100
+
101
+ it('exposes side and corner ports around a box', () => {
102
+ expect(snapPoints(a).map((p) => p.port)).toEqual([
103
+ 'top',
104
+ 'right',
105
+ 'bottom',
106
+ 'left',
107
+ 'top-left',
108
+ 'top-right',
109
+ 'bottom-right',
110
+ 'bottom-left',
111
+ ]);
112
+ });
113
+
114
+ it('snaps horizontal connections to side ports', () => {
115
+ const r = snapEndpoints(a, b);
116
+ expect(r.start).toMatchObject({ port: 'right', x: 80, y: 50 });
117
+ expect(r.end).toMatchObject({ port: 'left', x: 150, y: 50 });
118
+ });
119
+
120
+ it('snaps diagonal connections to nearest corner ports', () => {
121
+ const r = snapEndpoints(a, { cx: 180, cy: 140, rx: 30, ry: 20 });
122
+ expect(r.start).toMatchObject({ port: 'bottom-right', x: 80, y: 70 });
123
+ expect(r.end).toMatchObject({ port: 'top-left', x: 150, y: 120 });
124
+ });
125
+
126
+ it('finds the nearest port toward a free pointer', () => {
127
+ expect(nearestSnapPoint(a, { x: 100, y: 10 }).port).toBe('top-right');
128
+ });
129
+ });
130
+
82
131
  describe('lineStyleDasharray', () => {
83
132
  it('maps line styles to dash patterns', () => {
84
133
  expect(lineStyleDasharray('dashed')).toBe('8 6');
@@ -11,6 +11,16 @@ function getHeading(md: string) {
11
11
  }
12
12
 
13
13
  describe('Template annotation in Heading parse path', () => {
14
+ it('extracts and round-trips an empty annotation without leaving literal heading text', () => {
15
+ const source = '## Getting Started {[]}';
16
+ const heading = getHeading(source);
17
+
18
+ expect(heading.templateAnnotation).toEqual({});
19
+ expect(heading.children).toHaveLength(1);
20
+ expect(heading.children[0]).toMatchObject({ type: 'text', value: 'Getting Started' });
21
+ expect(stringifyMarkdown(parseMarkdown(source)).trim()).toBe(source);
22
+ });
23
+
14
24
  it('parseMarkdown extracts {[template]} from a heading', () => {
15
25
  const md = '## Getting Started {[comparisonBar]}';
16
26
  const doc = parseMarkdown(md);
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseMarkdown } from '../markdown/parse.js';
3
+ import { deriveTemplateInputs } from '../doc/templateInputs.js';
4
+
5
+ function statInputs(heading: string, body: string): Record<string, unknown> | null {
6
+ return deriveTemplateInputs('statHighlight', heading, parseMarkdown(body).children);
7
+ }
8
+
9
+ describe('statHighlight template input derivation', () => {
10
+ it('uses a leading bold metric as the hero stat', () => {
11
+ expect(
12
+ statInputs('The Big Number', '**42%** of teams prefer visual blocks over raw slides.'),
13
+ ).toEqual({
14
+ stat: '42%',
15
+ description: 'of teams prefer visual blocks over raw slides.',
16
+ });
17
+ });
18
+
19
+ it('accepts an explicitly bold small integer', () => {
20
+ expect(statInputs('Answer Count', '**42** answers were submitted')).toEqual({
21
+ stat: '42',
22
+ description: 'answers were submitted',
23
+ });
24
+ });
25
+
26
+ it('promotes only the metric when the bold run contains a longer phrase', () => {
27
+ expect(statInputs('Revenue', '**Revenue grew 42%** year over year')).toEqual({
28
+ stat: '42%',
29
+ description: 'Revenue grew year over year',
30
+ });
31
+ });
32
+
33
+ it('drops an inline separator between the metric and description', () => {
34
+ expect(
35
+ statInputs('The Big Number', '**42%** — The percentage of developers who prefer it.'),
36
+ ).toEqual({
37
+ stat: '42%',
38
+ description: 'The percentage of developers who prefer it.',
39
+ });
40
+ });
41
+
42
+ it('preserves a stat-looking heading for existing documents', () => {
43
+ expect(statInputs('89%', 'of customers recommend it')).toEqual({
44
+ stat: '89%',
45
+ description: 'of customers recommend it',
46
+ });
47
+ });
48
+
49
+ it('extracts an unformatted body metric for automatic templates', () => {
50
+ expect(statInputs('Funding', '$2.3M raised')).toEqual({
51
+ stat: '$2.3M',
52
+ description: 'raised',
53
+ });
54
+ });
55
+
56
+ it('does not let a year in a structural heading eclipse the body metric', () => {
57
+ expect(statInputs('2024 Results', '42% of teams reached their goal')).toEqual({
58
+ stat: '42%',
59
+ description: 'of teams reached their goal',
60
+ });
61
+ });
62
+
63
+ it('retains the historical fallback when no metric can be found', () => {
64
+ expect(statInputs('A Result', 'Supporting context')).toEqual({
65
+ stat: 'A Result',
66
+ description: 'Supporting context',
67
+ });
68
+ });
69
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { templateRegistry, TEMPLATE_METADATA, TEMPLATE_ALIASES } from '../doc/templates/index';
2
+ import { templateRegistry, TEMPLATE_METADATA } from '../doc/templates/index';
3
+ import { TEMPLATE_ALIASES } from '../doc/templates/templateNames';
3
4
 
4
5
  describe('TEMPLATE_METADATA', () => {
5
6
  it('stays exactly 1:1 with templateRegistry (same ids)', () => {
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
- expandTemplateBlock,
4
3
  expandDocBlocks,
5
4
  getAvailableTemplates,
6
5
  hasTemplate,
@@ -10,11 +9,41 @@ import {
10
9
  VIEWPORT_PRESETS,
11
10
  } from '../doc/templates/index';
12
11
  import type { TemplateBlock } from '../schemas/BlockTemplates';
13
- import type { TextLayer } from '../schemas/Doc';
14
- import { getLayers } from '../doc/getLayers';
12
+ import type { Block, Layer, TextLayer } from '../schemas/Doc';
13
+ import type { RuntimeTemplateRegistry } from '../doc/templates/index';
14
+ import { materializeBlockLayersWithRuntime } from '../doc/materializeBlockLayers';
15
+ import { materializeLayers } from './materializeTestUtils';
15
16
  import { markdownToDoc } from '../doc/markdownToDoc';
16
17
  import { parseMarkdown } from '../markdown/parse';
17
18
 
19
+ function materializeTemplateForTest(
20
+ block: TemplateBlock,
21
+ context: ReturnType<typeof createTemplateContext>,
22
+ registry: RuntimeTemplateRegistry = templateRegistry as unknown as RuntimeTemplateRegistry,
23
+ ): Block {
24
+ const { layers } = materializeBlockLayersWithRuntime(
25
+ block,
26
+ {
27
+ theme: context.theme,
28
+ viewport: context.viewport,
29
+ persistentLayers: false,
30
+ blockIndex: context.blockIndex,
31
+ totalBlocks: context.totalBlocks,
32
+ failureMode: 'empty',
33
+ },
34
+ { registry, templateContext: context, applyRenderStyle: false },
35
+ );
36
+ return {
37
+ id: block.id,
38
+ startTime: 0,
39
+ duration: block.duration,
40
+ audioSegment: block.audioSegment,
41
+ ...(layers.length > 0 ? { layers } : {}),
42
+ transition: block.transition,
43
+ template: block.template,
44
+ };
45
+ }
46
+
18
47
  describe('templateRegistry', () => {
19
48
  it('contains all expected templates', () => {
20
49
  const expected = [
@@ -66,7 +95,7 @@ describe('hasTemplate', () => {
66
95
  });
67
96
  });
68
97
 
69
- describe('expandTemplateBlock', () => {
98
+ describe('canonical template materialization', () => {
70
99
  it('expands titleSlide template into layers', () => {
71
100
  const block: TemplateBlock = {
72
101
  template: 'title',
@@ -77,7 +106,7 @@ describe('expandTemplateBlock', () => {
77
106
  subtitle: 'Test Subtitle',
78
107
  };
79
108
  const context = createTemplateContext(DEFAULT_THEME, 0, 5, VIEWPORT_PRESETS.landscape);
80
- const result = expandTemplateBlock(block, context);
109
+ const result = materializeTemplateForTest(block, context);
81
110
 
82
111
  expect(result.id).toBe('title-1');
83
112
  expect(result.duration).toBe(10);
@@ -97,7 +126,7 @@ describe('expandTemplateBlock', () => {
97
126
  body: 'I love building software platforms.',
98
127
  };
99
128
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
100
- const result = expandTemplateBlock(block, context);
129
+ const result = materializeTemplateForTest(block, context);
101
130
  const layers = result.layers ?? [];
102
131
  const imageLayer = layers.find((l) => l.type === 'image');
103
132
  expect(imageLayer).toBeDefined();
@@ -130,7 +159,7 @@ describe('expandTemplateBlock', () => {
130
159
  title: 'Sized',
131
160
  };
132
161
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
133
- const result = expandTemplateBlock(block, context);
162
+ const result = materializeTemplateForTest(block, context);
134
163
  const imageLayer = (result.layers ?? []).find((l) => l.type === 'image');
135
164
  expect(imageLayer).toBeDefined();
136
165
  expect((imageLayer!.content as { fit?: string }).fit).toBe('contain');
@@ -153,7 +182,7 @@ describe('expandTemplateBlock', () => {
153
182
  body: 'A list of things.',
154
183
  };
155
184
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
156
- const result = expandTemplateBlock(block, context);
185
+ const result = materializeTemplateForTest(block, context);
157
186
  const layers = result.layers ?? [];
158
187
  const imageLayer = layers.find((l) => l.type === 'image');
159
188
  expect(imageLayer).toBeDefined();
@@ -179,7 +208,7 @@ describe('expandTemplateBlock', () => {
179
208
  description: 'drop in salmon',
180
209
  };
181
210
  const context = createTemplateContext(DEFAULT_THEME, 0, 5, VIEWPORT_PRESETS.landscape);
182
- const result = expandTemplateBlock(block, context);
211
+ const result = materializeTemplateForTest(block, context);
183
212
 
184
213
  expect((result.layers ?? []).length).toBeGreaterThan(0);
185
214
  // Should have at least a shape background + text layers
@@ -195,7 +224,7 @@ describe('expandTemplateBlock', () => {
195
224
  audioSegment: 0,
196
225
  } as unknown as TemplateBlock;
197
226
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
198
- const result = expandTemplateBlock(block, context);
227
+ const result = materializeTemplateForTest(block, context);
199
228
  expect(result.layers ?? []).toEqual([]);
200
229
  });
201
230
 
@@ -241,7 +270,7 @@ describe('expandTemplateBlock', () => {
241
270
  leftValue: 50,
242
271
  rightValue: 50,
243
272
  } as unknown as TemplateBlock;
244
- const result = expandTemplateBlock(block, context);
273
+ const result = materializeTemplateForTest(block, context);
245
274
  expect(result.id).toBe(`test-${name}`);
246
275
  expect(result.layers ?? []).toBeInstanceOf(Array);
247
276
  // Each template should produce at least 1 layer
@@ -265,7 +294,7 @@ describe('expandTemplateBlock', () => {
265
294
  };
266
295
 
267
296
  const context = createTemplateContext(DEFAULT_THEME, 0, 10, VIEWPORT_PRESETS.landscape);
268
- const result = expandTemplateBlock(block, context);
297
+ const result = materializeTemplateForTest(block, context);
269
298
  const layers = result.layers ?? [];
270
299
  const captionLayer = layers.find(
271
300
  (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
@@ -301,10 +330,10 @@ describe('expandTemplateBlock', () => {
301
330
  };
302
331
 
303
332
  const context = createTemplateContext(DEFAULT_THEME, 0, 10, VIEWPORT_PRESETS.landscape);
304
- const imageCaption = (expandTemplateBlock(imageBlock, context).layers ?? []).find(
333
+ const imageCaption = (materializeTemplateForTest(imageBlock, context).layers ?? []).find(
305
334
  (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
306
335
  );
307
- const videoCaption = (expandTemplateBlock(videoBlock, context).layers ?? []).find(
336
+ const videoCaption = (materializeTemplateForTest(videoBlock, context).layers ?? []).find(
308
337
  (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
309
338
  );
310
339
 
@@ -370,6 +399,85 @@ describe('expandDocBlocks', () => {
370
399
  expect(result[0].startTime).toBe(0);
371
400
  });
372
401
 
402
+ it('never mutates or aliases raw blocks and persistent layers', () => {
403
+ const rawLayer: Layer = {
404
+ type: 'shape',
405
+ id: 'raw-shape',
406
+ content: { shape: 'rect', fill: '#112233' },
407
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
408
+ };
409
+ const rawBlock: Block = {
410
+ id: 'raw',
411
+ startTime: 99,
412
+ duration: 6,
413
+ audioSegment: 0,
414
+ layers: [rawLayer],
415
+ };
416
+ const persistentLayer: Layer = {
417
+ type: 'shape',
418
+ id: 'persistent',
419
+ content: { shape: 'rect', fill: '#445566' },
420
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
421
+ };
422
+ const beforeBlock = JSON.stringify(rawBlock);
423
+ const beforePersistent = JSON.stringify(persistentLayer);
424
+
425
+ const [expanded] = expandDocBlocks([rawBlock], {
426
+ persistentLayers: { bottomLayers: [persistentLayer] },
427
+ audioSegments: [{ startTime: 10, duration: 6 }],
428
+ });
429
+
430
+ expect(JSON.stringify(rawBlock)).toBe(beforeBlock);
431
+ expect(JSON.stringify(persistentLayer)).toBe(beforePersistent);
432
+ expect(expanded).not.toBe(rawBlock);
433
+ expect(expanded.layers?.[0]).not.toBe(persistentLayer);
434
+ expect(expanded.layers?.[1]).not.toBe(rawLayer);
435
+
436
+ (expanded.layers?.[0] as Layer & { content: { fill: string } }).content.fill = '#ffffff';
437
+ expect(JSON.stringify(persistentLayer)).toBe(beforePersistent);
438
+ expect(expandDocBlocks([rawBlock])[0].layers).toHaveLength(1);
439
+ });
440
+
441
+ it('protects template inputs from mutating registry implementations', () => {
442
+ const source: TemplateBlock = {
443
+ template: 'title',
444
+ id: 'title-safe',
445
+ duration: 5,
446
+ audioSegment: 0,
447
+ title: 'Original',
448
+ };
449
+ const sharedLayer: Layer = {
450
+ type: 'shape',
451
+ id: 'shared-layer',
452
+ content: { shape: 'rect', fill: '#112233' },
453
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
454
+ };
455
+ const registry: RuntimeTemplateRegistry = {
456
+ title: (input) => {
457
+ (input as { title: string }).title = 'Mutated';
458
+ return [sharedLayer];
459
+ },
460
+ };
461
+ const first = materializeTemplateForTest(
462
+ source,
463
+ createTemplateContext(DEFAULT_THEME, 0, 1),
464
+ registry,
465
+ );
466
+ const second = materializeTemplateForTest(
467
+ source,
468
+ createTemplateContext(DEFAULT_THEME, 0, 1),
469
+ registry,
470
+ );
471
+ expect(source.title).toBe('Original');
472
+ expect(first.layers?.[0]).not.toBe(sharedLayer);
473
+ expect(first.layers?.[0]).not.toBe(second.layers?.[0]);
474
+ (first.layers?.[0] as Layer & { content: { fill: string } }).content.fill = '#ffffff';
475
+ expect((sharedLayer as Layer & { content: { fill: string } }).content.fill).toBe('#112233');
476
+ expect((second.layers?.[0] as Layer & { content: { fill: string } }).content.fill).toBe(
477
+ '#112233',
478
+ );
479
+ });
480
+
373
481
  it('supports landscape and portrait viewports', () => {
374
482
  const blocks: TemplateBlock[] = [
375
483
  { template: 'title', id: 'title-1', duration: 5, audioSegment: 0, title: 'Test' },
@@ -533,7 +641,7 @@ describe('DEFAULT_THEME', () => {
533
641
  });
534
642
  });
535
643
 
536
- describe('inline-param coercion → getLayers', () => {
644
+ describe('inline-param coercion → materializeBlockLayers', () => {
537
645
  function firstBlock(md: string) {
538
646
  return markdownToDoc(parseMarkdown(md), { generateCoverBlock: false }).blocks[0];
539
647
  }
@@ -547,7 +655,7 @@ describe('inline-param coercion → getLayers', () => {
547
655
  mapStyle: 'road',
548
656
  });
549
657
 
550
- const layers = getLayers(block, {});
658
+ const layers = materializeLayers(block, {});
551
659
  const mapLayer = layers.find((l) => l.type === 'map');
552
660
  expect(mapLayer).toBeDefined();
553
661
  // Coerced center/zoom flow into the map layer's content — not empty strings.
@@ -558,7 +666,7 @@ describe('inline-param coercion → getLayers', () => {
558
666
 
559
667
  it('renders a twoColumn from pure-inline labeled-pair annotation', () => {
560
668
  const block = firstBlock('# Coffee {[twoColumn left="Espresso|Bold" right="Filter|Smooth"]}\n');
561
- const layers = getLayers(block, {});
669
+ const layers = materializeLayers(block, {});
562
670
  // Guard requires both labels — a non-empty layer set proves coercion worked.
563
671
  expect(layers.length).toBeGreaterThan(0);
564
672
  const texts = layers
@@ -572,6 +680,24 @@ describe('inline-param coercion → getLayers', () => {
572
680
  });
573
681
  });
574
682
 
683
+ describe('shared template materialization', () => {
684
+ it('keeps on-demand and timed expansion layer output in lockstep', () => {
685
+ const block: TemplateBlock = {
686
+ template: 'title',
687
+ id: 'shared-title',
688
+ duration: 7,
689
+ audioSegment: 0,
690
+ title: 'One materializer',
691
+ subtitle: 'Two orchestration modes',
692
+ };
693
+
694
+ const onDemand = materializeLayers(block, { theme: DEFAULT_THEME });
695
+ const [expanded] = expandDocBlocks([block], { theme: DEFAULT_THEME });
696
+
697
+ expect(expanded.layers).toEqual(onDemand);
698
+ });
699
+ });
700
+
575
701
  describe('VIEWPORT_PRESETS', () => {
576
702
  it('has landscape and portrait presets', () => {
577
703
  expect(VIEWPORT_PRESETS).toHaveProperty('landscape');
@@ -6,7 +6,8 @@ import {
6
6
  STARTER_THEME,
7
7
  deriveColorPalette,
8
8
  } from '../schemas/themeCompile.js';
9
- import { registerTheme, unregisterTheme, getRegisteredThemes } from '../schemas/Theme.js';
9
+ import { createThemeRegistry } from '../schemas/Theme.js';
10
+ import type { Theme } from '../schemas/Theme.js';
10
11
  import { resolveTheme, DEFAULT_THEME, THEMES } from '../schemas/themeLibrary.js';
11
12
  import { resolveFontFamily } from '../schemas/fontStacks.js';
12
13
 
@@ -132,46 +133,72 @@ describe('parseTheme / serializeTheme round-trip', () => {
132
133
  });
133
134
  });
134
135
 
135
- describe('registerTheme / resolveTheme', () => {
136
- it('registered theme takes precedence over built-in with same id', () => {
136
+ describe('caller-owned theme registries', () => {
137
+ it('an explicit registry can override a built-in without process-global leakage', () => {
137
138
  const overlay = compileTheme({
138
139
  id: 'standard',
139
140
  name: 'Standard Override',
140
141
  seedColors: { primary: '#ff0000' },
141
142
  });
142
- registerTheme(overlay);
143
- try {
144
- const resolved = resolveTheme('standard');
145
- expect(resolved.name).toBe('Standard Override');
146
- expect(resolved.colors.primary).toBe('#ff0000');
147
- } finally {
148
- unregisterTheme('standard');
149
- }
150
- // After cleanup, built-in is restored
143
+ const registry = createThemeRegistry([overlay]);
144
+ const resolved = resolveTheme('standard', registry);
145
+ expect(resolved.name).toBe('Standard Override');
146
+ expect(resolved.colors.primary).toBe('#ff0000');
151
147
  expect(resolveTheme('standard').name).toBe('Standard Light');
152
148
  });
153
149
 
154
- it('registered custom-id theme is resolvable via Doc.themeId', () => {
150
+ it('resolves a custom id only through the registry that owns it', () => {
155
151
  const custom = compileTheme({
156
152
  id: 'custom-test-1',
157
153
  name: 'Custom Test One',
158
154
  seedColors: { primary: '#00aabb' },
159
155
  });
160
- registerTheme(custom);
161
- try {
162
- const resolved = resolveTheme('custom-test-1');
163
- expect(resolved.id).toBe('custom-test-1');
164
- expect(resolved.name).toBe('Custom Test One');
165
- expect(getRegisteredThemes().some((t) => t.id === 'custom-test-1')).toBe(true);
166
- } finally {
167
- unregisterTheme('custom-test-1');
168
- }
156
+ const registry = createThemeRegistry([custom]);
157
+ expect(resolveTheme('custom-test-1', registry).name).toBe('Custom Test One');
158
+ expect(resolveTheme('custom-test-1')).toBe(DEFAULT_THEME);
159
+ expect(registry.list().map((theme) => theme.id)).toContain('custom-test-1');
169
160
  });
170
161
 
171
162
  it('resolveTheme falls back to default for unknown ids', () => {
172
163
  expect(resolveTheme('nonexistent-theme')).toBe(DEFAULT_THEME);
173
164
  expect(resolveTheme(undefined)).toBe(DEFAULT_THEME);
174
165
  });
166
+
167
+ it('validates and stores an immutable snapshot', () => {
168
+ const registry = createThemeRegistry();
169
+ const source = compileTheme({
170
+ id: 'isolated-theme',
171
+ name: 'Isolated',
172
+ seedColors: { primary: '#336699' },
173
+ });
174
+ registry.register(source);
175
+ source.name = 'Changed later';
176
+ source.colors.primary = '#ffffff';
177
+
178
+ const stored = registry.get('isolated-theme')!;
179
+ expect(stored.name).toBe('Isolated');
180
+ expect(stored.colors.primary).toBe('#336699');
181
+ expect(Object.isFrozen(stored)).toBe(true);
182
+ expect(Object.isFrozen(stored.colors)).toBe(true);
183
+ expect(() => registry.register({ id: 'invalid' } as Theme)).toThrow(/invalid theme/i);
184
+ });
185
+
186
+ it('supports caller-owned registries with isolated state', () => {
187
+ const source = compileTheme({
188
+ id: 'tenant-theme',
189
+ name: 'Tenant',
190
+ seedColors: { primary: '#123456' },
191
+ });
192
+ const tenantA = createThemeRegistry([source]);
193
+ const tenantB = createThemeRegistry();
194
+ expect(tenantA.get('tenant-theme')?.name).toBe('Tenant');
195
+ expect(tenantB.get('tenant-theme')).toBeUndefined();
196
+ });
197
+
198
+ it('freezes built-ins so lookup ergonomics do not expose mutable global state', () => {
199
+ expect(Object.isFrozen(THEMES)).toBe(true);
200
+ expect(Object.isFrozen(resolveTheme('cinematic'))).toBe(true);
201
+ });
175
202
  });
176
203
 
177
204
  describe('FontFamily resolution', () => {