@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,122 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { diagramBlock } from '../doc/templates/diagramBlock.js';
3
+ import { createTemplateContext } from '../schemas/BlockTemplates.js';
4
+ import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
5
+ import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
6
+ import type { Block, Layer, ShapeLayer, PathLayer, TextLayer } from '../schemas/Doc.js';
7
+ import type { DiagramBlockInput } from '../schemas/BlockTemplates.js';
8
+
9
+ function makeBlock(overrides: Partial<Block> & { id: string }): Block {
10
+ return {
11
+ startTime: 0,
12
+ duration: 0,
13
+ audioSegment: 0,
14
+ ...overrides,
15
+ } as Block;
16
+ }
17
+
18
+ function makeInput(overrides: Partial<DiagramBlockInput> = {}): DiagramBlockInput {
19
+ return {
20
+ template: 'diagram',
21
+ id: 'family-tree',
22
+ duration: 0,
23
+ audioSegment: 0,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe('diagramBlock template', () => {
29
+ it('emits node card + label layers for each child', () => {
30
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
31
+ ctx.children = [
32
+ makeBlock({ id: 'a', title: 'Alpha', x: 100, y: 100 }),
33
+ makeBlock({ id: 'b', title: 'Beta', x: 400, y: 100 }),
34
+ ];
35
+ const layers = diagramBlock(makeInput(), ctx);
36
+ const cards = layers.filter((l) => l.type === 'shape') as ShapeLayer[];
37
+ const texts = layers.filter((l) => l.type === 'text') as TextLayer[];
38
+ expect(cards.length).toBe(2);
39
+ expect(texts.length).toBe(2);
40
+ expect(texts[0].content.text).toBe('Alpha');
41
+ expect(texts[1].content.text).toBe('Beta');
42
+ });
43
+
44
+ it('emits a path layer with arrow for each connectsTo entry', () => {
45
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
46
+ ctx.children = [
47
+ makeBlock({ id: 'a', x: 100, y: 100, connectsTo: [{ target: 'b' }] }),
48
+ makeBlock({ id: 'b', x: 400, y: 100 }),
49
+ ];
50
+ const layers = diagramBlock(makeInput(), ctx);
51
+ const paths = layers.filter((l) => l.type === 'path') as PathLayer[];
52
+ expect(paths.length).toBe(1);
53
+ expect(paths[0].content.endMarker).toBe('arrow');
54
+ expect(paths[0].content.d.startsWith('M ')).toBe(true);
55
+ });
56
+
57
+ it('emits a typed edge label when connection has a type', () => {
58
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
59
+ ctx.children = [
60
+ makeBlock({ id: 'a', x: 100, y: 100, connectsTo: [{ target: 'b', type: 'requires' }] }),
61
+ makeBlock({ id: 'b', x: 400, y: 100 }),
62
+ ];
63
+ const layers = diagramBlock(makeInput(), ctx);
64
+ const labels = layers.filter(
65
+ (l): l is TextLayer => l.type === 'text' && l.id.startsWith('edge-label-'),
66
+ );
67
+ expect(labels.length).toBe(1);
68
+ expect(labels[0].content.text).toBe('requires');
69
+ });
70
+
71
+ it('returns a single hint text layer when there are no children', () => {
72
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
73
+ ctx.children = [];
74
+ const layers = diagramBlock(makeInput({ title: 'Empty' }), ctx);
75
+ expect(layers.length).toBe(1);
76
+ expect(layers[0].type).toBe('text');
77
+ });
78
+
79
+ it('emits a title layer when input.title is set', () => {
80
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
81
+ ctx.children = [makeBlock({ id: 'a', title: 'A', x: 100, y: 100 })];
82
+ const layers = diagramBlock(makeInput({ title: 'My Diagram' }), ctx);
83
+ const titleLayer = layers.find((l): l is TextLayer => l.id === 'diagram-title');
84
+ expect(titleLayer).toBeDefined();
85
+ expect(titleLayer?.content.text).toBe('My Diagram');
86
+ });
87
+
88
+ it('handles children without explicit positions via auto-layout', () => {
89
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
90
+ ctx.children = [
91
+ makeBlock({ id: 'a', title: 'A' }),
92
+ makeBlock({ id: 'b', title: 'B' }),
93
+ makeBlock({ id: 'c', title: 'C' }),
94
+ ];
95
+ const layers = diagramBlock(makeInput(), ctx);
96
+ const cards = layers.filter((l) => l.type === 'shape');
97
+ expect(cards.length).toBe(3);
98
+ });
99
+ });
100
+
101
+ describe('diagramBlock integration with getLayers', () => {
102
+ it('renders through the full template registry', async () => {
103
+ const { getLayers } = await import('../doc/getLayers.js');
104
+ const parentBlock: Block = {
105
+ id: 'family',
106
+ startTime: 0,
107
+ duration: 0,
108
+ audioSegment: 0,
109
+ template: 'diagram',
110
+ title: 'Family Tree',
111
+ children: [
112
+ makeBlock({ id: 'a', title: 'A', x: 100, y: 100 }),
113
+ makeBlock({ id: 'b', title: 'B', x: 400, y: 100, connectsTo: [{ target: 'a' }] }),
114
+ ],
115
+ };
116
+ const layers = getLayers(parentBlock, {});
117
+ // Should contain at least one shape (node card) and one path (edge).
118
+ const types = new Set(layers.map((l: Layer) => l.type));
119
+ expect(types.has('shape')).toBe(true);
120
+ expect(types.has('path')).toBe(true);
121
+ });
122
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeDiagramLayout } from '../doc/templates/diagramLayout.js';
3
+ import type { Block } from '../schemas/Doc.js';
4
+
5
+ function makeBlock(overrides: Partial<Block> & { id: string }): Block {
6
+ return {
7
+ startTime: 0,
8
+ duration: 0,
9
+ audioSegment: 0,
10
+ ...overrides,
11
+ } as Block;
12
+ }
13
+
14
+ describe('computeDiagramLayout', () => {
15
+ it('keeps explicit x/y positions intact', () => {
16
+ const layout = computeDiagramLayout([
17
+ makeBlock({ id: 'a', x: 100, y: 200 }),
18
+ makeBlock({ id: 'b', x: 400, y: 300 }),
19
+ ]);
20
+ expect(layout.nodes).toHaveLength(2);
21
+ expect(layout.nodes[0]).toMatchObject({ id: 'a', x: 100, y: 200, pinned: true });
22
+ expect(layout.nodes[1]).toMatchObject({ id: 'b', x: 400, y: 300, pinned: true });
23
+ });
24
+
25
+ it('auto-positions children missing x or y in a grid below pinned nodes', () => {
26
+ const layout = computeDiagramLayout([
27
+ makeBlock({ id: 'pinned', x: 200, y: 100 }),
28
+ makeBlock({ id: 'a' }),
29
+ makeBlock({ id: 'b' }),
30
+ makeBlock({ id: 'c' }),
31
+ makeBlock({ id: 'd' }),
32
+ ]);
33
+ expect(layout.nodes.find((n) => n.id === 'pinned')?.pinned).toBe(true);
34
+ const auto = layout.nodes.filter((n) => n.id !== 'pinned');
35
+ for (const node of auto) {
36
+ expect(node.pinned).toBe(false);
37
+ // Should sit below the pinned node's y.
38
+ expect(node.y).toBeGreaterThan(100);
39
+ }
40
+ });
41
+
42
+ it('places fully-unpositioned children starting at the default origin', () => {
43
+ const layout = computeDiagramLayout([
44
+ makeBlock({ id: 'a', title: 'A' }),
45
+ makeBlock({ id: 'b', title: 'B' }),
46
+ ]);
47
+ expect(layout.nodes[0].pinned).toBe(false);
48
+ expect(layout.nodes[1].pinned).toBe(false);
49
+ expect(layout.nodes[0].x).toBeGreaterThanOrEqual(0);
50
+ expect(layout.nodes[0].y).toBeGreaterThanOrEqual(0);
51
+ });
52
+
53
+ it('extracts edges from connectsTo, including connection types', () => {
54
+ const layout = computeDiagramLayout([
55
+ makeBlock({
56
+ id: 'a',
57
+ x: 0,
58
+ y: 0,
59
+ connectsTo: [{ target: 'b', type: 'flow' }, { target: 'c' }],
60
+ }),
61
+ makeBlock({ id: 'b', x: 100, y: 0 }),
62
+ makeBlock({ id: 'c', x: 200, y: 0 }),
63
+ ]);
64
+ expect(layout.edges).toHaveLength(2);
65
+ expect(layout.edges[0]).toMatchObject({ source: 'a', target: 'b', type: 'flow' });
66
+ expect(layout.edges[1]).toMatchObject({ source: 'a', target: 'c' });
67
+ expect(layout.edges[1].type).toBeUndefined();
68
+ });
69
+
70
+ it('drops edges with unknown targets and reports a warning', () => {
71
+ const layout = computeDiagramLayout([
72
+ makeBlock({
73
+ id: 'a',
74
+ x: 0,
75
+ y: 0,
76
+ connectsTo: [{ target: 'ghost' }, { target: 'b' }],
77
+ }),
78
+ makeBlock({ id: 'b', x: 100, y: 0 }),
79
+ ]);
80
+ expect(layout.edges).toHaveLength(1);
81
+ expect(layout.edges[0].target).toBe('b');
82
+ expect(layout.warnings.length).toBe(1);
83
+ expect(layout.warnings[0]).toContain('ghost');
84
+ });
85
+
86
+ it('dedupes edges with identical source/target/type', () => {
87
+ const layout = computeDiagramLayout([
88
+ makeBlock({
89
+ id: 'a',
90
+ x: 0,
91
+ y: 0,
92
+ connectsTo: [
93
+ { target: 'b', type: 'flow' },
94
+ { target: 'b', type: 'flow' },
95
+ ],
96
+ }),
97
+ makeBlock({ id: 'b', x: 100, y: 0 }),
98
+ ]);
99
+ expect(layout.edges).toHaveLength(1);
100
+ });
101
+
102
+ it('distinguishes edges with different types', () => {
103
+ const layout = computeDiagramLayout([
104
+ makeBlock({
105
+ id: 'a',
106
+ x: 0,
107
+ y: 0,
108
+ connectsTo: [
109
+ { target: 'b', type: 'flow' },
110
+ { target: 'b', type: 'requires' },
111
+ ],
112
+ }),
113
+ makeBlock({ id: 'b', x: 100, y: 0 }),
114
+ ]);
115
+ expect(layout.edges).toHaveLength(2);
116
+ });
117
+
118
+ it('returns an empty layout for no children', () => {
119
+ const layout = computeDiagramLayout([]);
120
+ expect(layout.nodes).toEqual([]);
121
+ expect(layout.edges).toEqual([]);
122
+ expect(layout.warnings).toEqual([]);
123
+ });
124
+
125
+ it('falls back to the block id for the node label when title is missing', () => {
126
+ const layout = computeDiagramLayout([makeBlock({ id: 'lonely', x: 0, y: 0 })]);
127
+ expect(layout.nodes[0].label).toBe('lonely');
128
+ });
129
+
130
+ it('uses block.title for the node label when present', () => {
131
+ const layout = computeDiagramLayout([makeBlock({ id: 'a', x: 0, y: 0, title: 'Alpha' })]);
132
+ expect(layout.nodes[0].label).toBe('Alpha');
133
+ });
134
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMarkdown } from '../markdown/parse.js';
3
+ import { stringifyMarkdown } from '../markdown/stringify.js';
4
+ import { markdownToDoc, flattenBlocks, flattenRenderableBlocks } from '../doc/markdownToDoc.js';
5
+ import { validateMarkdownSource } from '../doc/validate.js';
6
+ import { isContainerTemplate } from '../doc/templates/index.js';
7
+
8
+ const ORG_CHART = `## Org chart {[drawing]}
9
+
10
+ ### CEO {#ceo} {[rectangle x=21 y=25 width=100 height=100]}
11
+
12
+ The CEO is the chief executive.
13
+
14
+ ### reports to {[line from=ceo to=dev1]}
15
+
16
+ ### Developer {#dev1} {[rectangle x=21 y=190 width=100 height=100]}
17
+
18
+ The lead developer.
19
+ `;
20
+
21
+ describe('drawing markdown → doc', () => {
22
+ it('turns child headings into shape blocks with geometry overrides', () => {
23
+ const doc = markdownToDoc(parseMarkdown(ORG_CHART));
24
+ const drawing = doc.blocks[0];
25
+ expect(drawing.template).toBe('drawing');
26
+ expect(drawing.children).toHaveLength(3);
27
+
28
+ const ceo = drawing.children!.find((c) => c.id === 'ceo')!;
29
+ expect(ceo.template).toBe('rectangle');
30
+ expect(ceo.title).toBe('CEO');
31
+ expect(ceo.templateOverrides).toEqual({
32
+ x: '21',
33
+ y: '25',
34
+ width: '100',
35
+ height: '100',
36
+ });
37
+
38
+ const line = drawing.children!.find((c) => c.id === 'reports-to')!;
39
+ expect(line.template).toBe('line');
40
+ expect(line.templateOverrides).toEqual({ from: 'ceo', to: 'dev1' });
41
+ });
42
+
43
+ it('round-trips losslessly (shape annotations + ids survive)', () => {
44
+ const once = stringifyMarkdown(parseMarkdown(ORG_CHART));
45
+ // Canonical form is stable: a second pass is identical to the first.
46
+ const twice = stringifyMarkdown(parseMarkdown(once));
47
+ expect(twice).toBe(once);
48
+ expect(once).toContain('{#ceo} {[rectangle x=21 y=25 width=100 height=100]}');
49
+ expect(once).toContain('{[line from=ceo to=dev1]}');
50
+ });
51
+ });
52
+
53
+ describe('drawing validation', () => {
54
+ it('reports no diagnostics for a well-formed drawing', () => {
55
+ const { diagnostics, errorCount } = validateMarkdownSource(ORG_CHART);
56
+ expect(errorCount).toBe(0);
57
+ expect(diagnostics).toEqual([]);
58
+ });
59
+
60
+ it('flags an unknown shape with a did-you-mean suggestion', () => {
61
+ const md = `## D {[drawing]}\n\n### A {[rectanlge x=0 y=0]}\n`;
62
+ const { diagnostics } = validateMarkdownSource(md);
63
+ const d = diagnostics.find((x) => x.code === 'unknown-shape');
64
+ expect(d).toBeDefined();
65
+ expect(d!.message).toContain('rectangle');
66
+ });
67
+
68
+ it('flags a shape annotation used outside a drawing', () => {
69
+ const md = `## Not a drawing\n\n### A {[rectangle x=0 y=0]}\n`;
70
+ const { diagnostics } = validateMarkdownSource(md);
71
+ expect(diagnostics.some((x) => x.code === 'shape-outside-drawing')).toBe(true);
72
+ });
73
+
74
+ it('flags a connector whose from/to does not resolve', () => {
75
+ const md = `## D {[drawing]}\n\n### A {#a} {[rect x=0 y=0]}\n\n### L {[line from=a to=ghost]}\n`;
76
+ const { diagnostics } = validateMarkdownSource(md);
77
+ const d = diagnostics.find((x) => x.code === 'unresolved-connection');
78
+ expect(d).toBeDefined();
79
+ expect(d!.message).toContain('ghost');
80
+ });
81
+
82
+ it('flags non-numeric shape geometry', () => {
83
+ const md = `## D {[drawing]}\n\n### A {[rectangle x=abc y=0]}\n`;
84
+ const { diagnostics } = validateMarkdownSource(md);
85
+ expect(diagnostics.some((x) => x.code === 'invalid-attribute' && x.message.includes('x'))).toBe(
86
+ true,
87
+ );
88
+ });
89
+ });
90
+
91
+ describe('container-template suppression', () => {
92
+ it('isContainerTemplate covers diagram and drawing only', () => {
93
+ expect(isContainerTemplate('drawing')).toBe(true);
94
+ expect(isContainerTemplate('diagram')).toBe(true);
95
+ expect(isContainerTemplate('sectionHeader')).toBe(false);
96
+ expect(isContainerTemplate(undefined)).toBe(false);
97
+ });
98
+
99
+ it('flattenRenderableBlocks skips a drawing’s child shapes', () => {
100
+ const doc = markdownToDoc(parseMarkdown(ORG_CHART));
101
+ const all = flattenBlocks(doc.blocks);
102
+ const renderable = flattenRenderableBlocks(doc.blocks);
103
+ // Full flatten includes the drawing + its 3 shapes; renderable has only
104
+ // the drawing itself (its shapes are consumed by the drawing's render).
105
+ expect(all.length).toBe(4);
106
+ expect(renderable.length).toBe(1);
107
+ expect(renderable[0].template).toBe('drawing');
108
+ });
109
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { drawingBlock } from '../doc/templates/drawingBlock.js';
3
+ import { createTemplateContext } from '../schemas/BlockTemplates.js';
4
+ import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
5
+ import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
6
+ import type { Block, Layer, ShapeLayer, PathLayer, TextLayer } from '../schemas/Doc.js';
7
+ import type { DrawingBlockInput } from '../schemas/BlockTemplates.js';
8
+
9
+ function shape(
10
+ id: string,
11
+ template: string,
12
+ overrides: Record<string, string> = {},
13
+ extra: Partial<Block> = {},
14
+ ): Block {
15
+ return {
16
+ id,
17
+ startTime: 0,
18
+ duration: 0,
19
+ audioSegment: 0,
20
+ template,
21
+ templateOverrides: overrides,
22
+ ...extra,
23
+ } as Block;
24
+ }
25
+
26
+ function makeInput(overrides: Partial<DrawingBlockInput> = {}): DrawingBlockInput {
27
+ return { template: 'drawing', id: 'sketch', duration: 0, audioSegment: 0, ...overrides };
28
+ }
29
+
30
+ function ctxWith(children: Block[]) {
31
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
32
+ ctx.children = children;
33
+ return ctx;
34
+ }
35
+
36
+ describe('drawingBlock template', () => {
37
+ it('emits a shape + label layer for each rect/circle child', () => {
38
+ const layers = drawingBlock(
39
+ makeInput(),
40
+ ctxWith([
41
+ shape(
42
+ 'ceo',
43
+ 'rectangle',
44
+ { x: '20', y: '20', width: '120', height: '80' },
45
+ { title: 'CEO' },
46
+ ),
47
+ shape(
48
+ 'dev',
49
+ 'circle',
50
+ { x: '20', y: '200', width: '100', height: '100' },
51
+ { title: 'Dev' },
52
+ ),
53
+ ]),
54
+ );
55
+ const shapes = layers.filter((l): l is ShapeLayer => l.type === 'shape');
56
+ const texts = layers.filter((l): l is TextLayer => l.type === 'text');
57
+ expect(shapes.map((s) => s.content.shape).sort()).toEqual(['circle', 'rect']);
58
+ expect(texts.map((t) => t.content.text).sort()).toEqual(['CEO', 'Dev']);
59
+ });
60
+
61
+ it('renders a from/to line as a path connector behind the shapes', () => {
62
+ const layers = drawingBlock(
63
+ makeInput(),
64
+ ctxWith([
65
+ shape('a', 'rect', { x: '0', y: '0', width: '80', height: '60' }, { title: 'A' }),
66
+ shape('b', 'rect', { x: '0', y: '200', width: '80', height: '60' }, { title: 'B' }),
67
+ shape('l', 'arrow', { from: 'a', to: 'b' }),
68
+ ]),
69
+ );
70
+ const paths = layers.filter((l): l is PathLayer => l.type === 'path');
71
+ expect(paths).toHaveLength(1);
72
+ expect(paths[0].content.endMarker).toBe('arrow');
73
+ expect(paths[0].content.d.startsWith('M ')).toBe(true);
74
+ // Connector emitted before the shapes (so it sits behind them).
75
+ expect(layers.indexOf(paths[0])).toBeLessThan(layers.findIndex((l) => l.type === 'shape'));
76
+ });
77
+
78
+ it('emits a title layer when input.title is set', () => {
79
+ const layers = drawingBlock(
80
+ makeInput({ title: 'Org chart' }),
81
+ ctxWith([shape('a', 'rect', { x: '0', y: '0', width: '80', height: '60' })]),
82
+ );
83
+ expect(layers.find((l) => l.id === 'drawing-title')).toBeDefined();
84
+ });
85
+
86
+ it('returns a single hint layer for an empty drawing', () => {
87
+ const layers = drawingBlock(makeInput({ title: 'Nothing yet' }), ctxWith([]));
88
+ expect(layers).toHaveLength(1);
89
+ expect(layers[0].type).toBe('text');
90
+ });
91
+ });
92
+
93
+ describe('drawingBlock integration with getLayers', () => {
94
+ it('renders a drawing block through the template registry', async () => {
95
+ const { getLayers } = await import('../doc/getLayers.js');
96
+ const parent: Block = {
97
+ id: 'sketch',
98
+ startTime: 0,
99
+ duration: 0,
100
+ audioSegment: 0,
101
+ template: 'drawing',
102
+ title: 'Org chart',
103
+ children: [
104
+ shape(
105
+ 'ceo',
106
+ 'rectangle',
107
+ { x: '20', y: '20', width: '120', height: '80' },
108
+ { title: 'CEO' },
109
+ ),
110
+ shape(
111
+ 'dev',
112
+ 'rectangle',
113
+ { x: '20', y: '220', width: '120', height: '80' },
114
+ { title: 'Dev' },
115
+ ),
116
+ shape('l', 'arrow', { from: 'ceo', to: 'dev' }, { title: 'manages' }),
117
+ ],
118
+ };
119
+ const layers = getLayers(parent, {});
120
+ const types = new Set(layers.map((l: Layer) => l.type));
121
+ expect(types.has('shape')).toBe(true);
122
+ expect(types.has('path')).toBe(true);
123
+ });
124
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ computeDrawingLayout,
4
+ normalizeShapeKind,
5
+ isShapeName,
6
+ } from '../doc/templates/drawingLayout.js';
7
+ import type { Block } from '../schemas/Doc.js';
8
+
9
+ /** Build a drawing child: `template` is the shape, geometry rides in overrides. */
10
+ function shape(
11
+ id: string,
12
+ template: string,
13
+ overrides: Record<string, string> = {},
14
+ extra: Partial<Block> = {},
15
+ ): Block {
16
+ return {
17
+ id,
18
+ startTime: 0,
19
+ duration: 0,
20
+ audioSegment: 0,
21
+ template,
22
+ templateOverrides: overrides,
23
+ ...extra,
24
+ } as Block;
25
+ }
26
+
27
+ describe('normalizeShapeKind', () => {
28
+ it('maps aliases to canonical kinds', () => {
29
+ expect(normalizeShapeKind('rectangle')).toBe('rect');
30
+ expect(normalizeShapeKind('rect')).toBe('rect');
31
+ expect(normalizeShapeKind('ellipse')).toBe('circle');
32
+ expect(normalizeShapeKind('CIRCLE')).toBe('circle');
33
+ expect(normalizeShapeKind('line')).toBe('line');
34
+ expect(normalizeShapeKind('arrow')).toBe('arrow');
35
+ expect(normalizeShapeKind('path')).toBe('path');
36
+ expect(normalizeShapeKind('text')).toBe('text');
37
+ });
38
+
39
+ it('returns null for non-shapes', () => {
40
+ expect(normalizeShapeKind('sectionHeader')).toBeNull();
41
+ expect(normalizeShapeKind(undefined)).toBeNull();
42
+ expect(isShapeName('rectangle')).toBe(true);
43
+ expect(isShapeName('banana')).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe('computeDrawingLayout', () => {
48
+ it('positions a rectangle from its geometry params', () => {
49
+ const layout = computeDrawingLayout([
50
+ shape('ceo', 'rectangle', { x: '21', y: '25', width: '100', height: '80' }, { title: 'CEO' }),
51
+ ]);
52
+ expect(layout.shapes).toHaveLength(1);
53
+ expect(layout.shapes[0]).toMatchObject({
54
+ id: 'ceo',
55
+ kind: 'rect',
56
+ x: 21,
57
+ y: 25,
58
+ width: 100,
59
+ height: 80,
60
+ pinned: true,
61
+ label: 'CEO',
62
+ });
63
+ });
64
+
65
+ it('tolerates trailing commas in geometry (matches the sketch syntax)', () => {
66
+ const layout = computeDrawingLayout([
67
+ shape('a', 'rect', { x: '21,', y: '25,', width: '100,', height: '80' }),
68
+ ]);
69
+ expect(layout.shapes[0]).toMatchObject({ x: 21, y: 25, width: 100, height: 80 });
70
+ });
71
+
72
+ it('applies default size when width/height are omitted', () => {
73
+ const layout = computeDrawingLayout([shape('a', 'circle', { x: '0', y: '0' })]);
74
+ expect(layout.shapes[0].kind).toBe('circle');
75
+ expect(layout.shapes[0].width).toBeGreaterThan(0);
76
+ expect(layout.shapes[0].height).toBeGreaterThan(0);
77
+ });
78
+
79
+ it('auto-positions shapes missing a coordinate below pinned ones', () => {
80
+ const layout = computeDrawingLayout([
81
+ shape('pinned', 'rect', { x: '200', y: '100', width: '100', height: '60' }),
82
+ shape('floating', 'rect'),
83
+ ]);
84
+ const pinned = layout.shapes.find((s) => s.id === 'pinned')!;
85
+ const floating = layout.shapes.find((s) => s.id === 'floating')!;
86
+ expect(pinned.pinned).toBe(true);
87
+ expect(floating.pinned).toBe(false);
88
+ expect(floating.y).toBeGreaterThan(pinned.y);
89
+ });
90
+
91
+ it('turns a line with from/to into a resolved connector', () => {
92
+ const layout = computeDrawingLayout([
93
+ shape('ceo', 'rectangle', { x: '0', y: '0', width: '100', height: '60' }),
94
+ shape('dev', 'rectangle', { x: '0', y: '200', width: '100', height: '60' }),
95
+ shape('l1', 'line', { from: 'ceo', to: 'dev' }, { title: 'reports to' }),
96
+ ]);
97
+ expect(layout.shapes).toHaveLength(2); // line is a connector, not a shape
98
+ expect(layout.connectors).toHaveLength(1);
99
+ expect(layout.connectors[0]).toMatchObject({
100
+ id: 'l1',
101
+ kind: 'line',
102
+ from: 'ceo',
103
+ to: 'dev',
104
+ label: 'reports to',
105
+ });
106
+ });
107
+
108
+ it('emits an arrow connector with an arrowhead for arrow shapes', () => {
109
+ const layout = computeDrawingLayout([
110
+ shape('a', 'rect', { x: '0', y: '0', width: '50', height: '50' }),
111
+ shape('b', 'rect', { x: '200', y: '0', width: '50', height: '50' }),
112
+ shape('arr', 'arrow', { from: 'a', to: 'b' }),
113
+ ]);
114
+ expect(layout.connectors[0].kind).toBe('arrow');
115
+ });
116
+
117
+ it('drops a connector whose endpoint matches no shape, with a warning', () => {
118
+ const layout = computeDrawingLayout([
119
+ shape('a', 'rect', { x: '0', y: '0', width: '50', height: '50' }),
120
+ shape('l', 'line', { from: 'a', to: 'ghost' }),
121
+ ]);
122
+ expect(layout.connectors).toHaveLength(0);
123
+ expect(layout.warnings.some((w) => w.includes('ghost'))).toBe(true);
124
+ });
125
+
126
+ it('also derives arrow connectors from a shape connectsTo', () => {
127
+ const layout = computeDrawingLayout([
128
+ shape(
129
+ 'a',
130
+ 'rect',
131
+ { x: '0', y: '0', width: '50', height: '50' },
132
+ {
133
+ connectsTo: [{ target: 'b' }],
134
+ },
135
+ ),
136
+ shape('b', 'rect', { x: '200', y: '0', width: '50', height: '50' }),
137
+ ]);
138
+ expect(layout.connectors).toHaveLength(1);
139
+ expect(layout.connectors[0]).toMatchObject({ from: 'a', to: 'b', kind: 'arrow' });
140
+ });
141
+
142
+ it('skips non-shape children and warns', () => {
143
+ const layout = computeDrawingLayout([
144
+ shape('a', 'rect', { x: '0', y: '0', width: '50', height: '50' }),
145
+ shape('note', 'sectionHeader'),
146
+ ]);
147
+ expect(layout.shapes).toHaveLength(1);
148
+ expect(layout.warnings.some((w) => w.includes('note'))).toBe(true);
149
+ });
150
+
151
+ it('reads text content for text shapes (text= param, else title)', () => {
152
+ const fromParam = computeDrawingLayout([shape('t', 'text', { x: '0', y: '0', text: 'Hello' })]);
153
+ expect(fromParam.shapes[0].text).toBe('Hello');
154
+ const fromTitle = computeDrawingLayout([
155
+ shape('t', 'text', { x: '0', y: '0' }, { title: 'Heading text' }),
156
+ ]);
157
+ expect(fromTitle.shapes[0].text).toBe('Heading text');
158
+ });
159
+
160
+ it('carries the raw d for path shapes', () => {
161
+ const layout = computeDrawingLayout([
162
+ shape('p', 'path', { x: '0', y: '0', d: 'M 0 0 L 10 10' }),
163
+ ]);
164
+ expect(layout.shapes[0].d).toBe('M 0 0 L 10 10');
165
+ });
166
+
167
+ it('returns an empty layout for no children', () => {
168
+ const layout = computeDrawingLayout([]);
169
+ expect(layout).toEqual({ shapes: [], connectors: [], warnings: [] });
170
+ });
171
+ });
@@ -51,9 +51,9 @@ describe('getGeohash4Neighbors', () => {
51
51
  expect(result[0]).toBe('c23n');
52
52
  });
53
53
 
54
- it('throws for non-4-char input', () => {
55
- expect(() => getGeohash4Neighbors('c2')).toThrow();
56
- expect(() => getGeohash4Neighbors('c23nn')).toThrow();
54
+ it('returns empty array for non-4-char input', () => {
55
+ expect(getGeohash4Neighbors('c2')).toEqual([]);
56
+ expect(getGeohash4Neighbors('c23nn')).toEqual([]);
57
57
  });
58
58
  });
59
59
 
@@ -70,8 +70,9 @@ describe('geohashToHierarchicalPath', () => {
70
70
  expect(geohashToHierarchicalPath('9q8y')).toBe('9/q/8/y');
71
71
  });
72
72
 
73
- it('throws for non-4-char input', () => {
74
- expect(() => geohashToHierarchicalPath('c2')).toThrow();
73
+ it('returns empty string for non-4-char input', () => {
74
+ expect(geohashToHierarchicalPath('c2')).toBe('');
75
+ expect(geohashToHierarchicalPath('c23nn')).toBe('');
75
76
  });
76
77
  });
77
78