@elabs-ai/components-editor 4.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Unit tests for the iteration BUILDER model (A5): serialize ⇄ parse round-trip
3
+ * (so the `⋯` re-edit is lossless) and the built-in `evaluateEmbedded` that turns
4
+ * the embedded value lists into a populated result (so a guided-built block
5
+ * renders without a consumer data engine).
6
+ */
7
+ import { describe, expect, test } from "vitest";
8
+
9
+ import { specFromDirective } from "./directive";
10
+ import {
11
+ builderValueFromParts,
12
+ directivePartsFromValue,
13
+ evaluateEmbedded,
14
+ parseIterationDirective,
15
+ serializeIterationDirective,
16
+ splitList,
17
+ staticMarkdownFromValue,
18
+ transposeIterationValue,
19
+ type IterationBuilderValue,
20
+ } from "./iteration-builder";
21
+
22
+ describe("serializeIterationDirective", () => {
23
+ test("iterate: embeds the value list + bind name + layout in the attributes", () => {
24
+ const md = serializeIterationDirective({
25
+ kind: "iterate",
26
+ as: "item",
27
+ layout: "stacked",
28
+ values: ["Alice", "Bob", "Charlie"],
29
+ template: "{{item.name}}",
30
+ });
31
+ expect(md).toContain(":::iterate{");
32
+ expect(md).toContain(`as="item"`);
33
+ expect(md).toContain(`layout="stacked"`);
34
+ expect(md).toContain(`values="Alice, Bob, Charlie"`);
35
+ expect(md.trimEnd().endsWith(":::")).toBe(true);
36
+ });
37
+
38
+ test("pivot: embeds both axes as rows= and cols=", () => {
39
+ const md = serializeIterationDirective({
40
+ kind: "pivot",
41
+ as: "item",
42
+ layout: "matrix",
43
+ values: ["Q1", "Q2"],
44
+ cols: ["North", "South"],
45
+ template: "{{row}} · {{col}}",
46
+ });
47
+ expect(md).toContain(":::pivot{");
48
+ expect(md).toContain(`rows="Q1, Q2"`);
49
+ expect(md).toContain(`cols="North, South"`);
50
+ });
51
+ });
52
+
53
+ describe("round-trip (serialize → parse)", () => {
54
+ test("iterate round-trips losslessly", () => {
55
+ const value: IterationBuilderValue = {
56
+ kind: "iterate",
57
+ as: "person",
58
+ layout: "grid",
59
+ values: ["Alice", "Bob"],
60
+ template: "Name: {{item.name}}",
61
+ };
62
+ expect(parseIterationDirective(serializeIterationDirective(value))).toEqual(value);
63
+ });
64
+
65
+ test("pivot round-trips losslessly (both value lists preserved)", () => {
66
+ const value: IterationBuilderValue = {
67
+ kind: "pivot",
68
+ as: "item",
69
+ layout: "matrix",
70
+ values: ["Q1", "Q2", "Q3"],
71
+ cols: ["North", "South"],
72
+ template: "{{row}} / {{col}}",
73
+ };
74
+ expect(parseIterationDirective(serializeIterationDirective(value))).toEqual(value);
75
+ });
76
+
77
+ test("parse returns null for non-iteration markdown", () => {
78
+ expect(parseIterationDirective("# just a heading")).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe("nested-directive fence escalation", () => {
83
+ test("escalates the outer fence past a nested :::card so the inner close can't terminate it", () => {
84
+ const md = serializeIterationDirective({
85
+ kind: "iterate",
86
+ as: "item",
87
+ layout: "stacked",
88
+ values: ["A"],
89
+ template: `:::card{title="{{item.name}}"}\nbody\n:::\n\ntrailing`,
90
+ });
91
+ expect(md.startsWith("::::iterate{")).toBe(true);
92
+ expect(md.trimEnd().endsWith("::::")).toBe(true);
93
+ });
94
+
95
+ test("keeps the canonical ::: fence when there is no nested container directive", () => {
96
+ const md = serializeIterationDirective({
97
+ kind: "iterate",
98
+ as: "item",
99
+ layout: "stacked",
100
+ values: ["A"],
101
+ template: "{{item.name}}",
102
+ });
103
+ expect(md.startsWith(":::iterate{")).toBe(true);
104
+ expect(md.startsWith("::::")).toBe(false);
105
+ });
106
+
107
+ test("a nested :::card + trailing content round-trips losslessly (and survives a bento layout)", () => {
108
+ const value: IterationBuilderValue = {
109
+ kind: "iterate",
110
+ as: "item",
111
+ layout: "bento",
112
+ values: ["A", "B"],
113
+ template: `:::card{title="{{item.name}}"}\nbody\n:::\n\ntrailing`,
114
+ };
115
+ expect(parseIterationDirective(serializeIterationDirective(value))).toEqual(value);
116
+ });
117
+ });
118
+
119
+ describe("builderValueFromParts defaults", () => {
120
+ test("falls back to the default layout + template when attributes are sparse", () => {
121
+ const v = builderValueFromParts("iterate", {}, "");
122
+ expect(v.layout).toBe("stacked");
123
+ expect(v.template).toBe("{{item.name}}");
124
+ expect(v.values).toEqual([]);
125
+ });
126
+ });
127
+
128
+ describe("directivePartsFromValue", () => {
129
+ test("pivot writes rows/cols, not values", () => {
130
+ const { attributes } = directivePartsFromValue({
131
+ kind: "pivot",
132
+ as: "item",
133
+ layout: "matrix",
134
+ values: ["a", "b"],
135
+ cols: ["x"],
136
+ template: "t",
137
+ });
138
+ expect(attributes.rows).toBe("a, b");
139
+ expect(attributes.cols).toBe("x");
140
+ expect(attributes.values).toBeUndefined();
141
+ });
142
+ });
143
+
144
+ describe("evaluateEmbedded", () => {
145
+ test("iterate: one cell per embedded value, with {{value}}/{{name}} in scope", () => {
146
+ const spec = specFromDirective("iterate", { values: "Alice, Bob, Charlie", as: "item" }, "x");
147
+ const data = evaluateEmbedded(spec);
148
+ expect(data.cells).toHaveLength(3);
149
+ expect(data.cells[0]?.context).toMatchObject({ value: "Alice", name: "Alice" });
150
+ });
151
+
152
+ test("pivot: rows × cols cells + ordered headers", () => {
153
+ const spec = specFromDirective("pivot", { rows: "Q1, Q2", cols: "N, S, E" }, "x");
154
+ const data = evaluateEmbedded(spec);
155
+ expect(data.rowHeaders).toEqual(["Q1", "Q2"]);
156
+ expect(data.colHeaders).toEqual(["N", "S", "E"]);
157
+ expect(data.cells).toHaveLength(6);
158
+ expect(data.cells[0]).toMatchObject({ row: "Q1", col: "N" });
159
+ });
160
+
161
+ test("empty lists → no cells (renders the empty state)", () => {
162
+ expect(evaluateEmbedded(specFromDirective("iterate", {}, "x")).cells).toEqual([]);
163
+ expect(evaluateEmbedded(specFromDirective("pivot", { rows: "Q1" }, "x")).cells).toEqual([]);
164
+ });
165
+ });
166
+
167
+ describe("splitList", () => {
168
+ test("trims, drops empties, tolerates trailing commas", () => {
169
+ expect(splitList("a, b ,, c,")).toEqual(["a", "b", "c"]);
170
+ expect(splitList(undefined)).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe("transposeIterationValue", () => {
175
+ test("pivot: swaps rows (values) and cols", () => {
176
+ const value: IterationBuilderValue = {
177
+ kind: "pivot",
178
+ as: "item",
179
+ layout: "matrix",
180
+ values: ["Q1", "Q2"],
181
+ cols: ["North", "South", "East"],
182
+ template: "{{row}} · {{col}}",
183
+ };
184
+ const transposed = transposeIterationValue(value);
185
+ expect(transposed.values).toEqual(["North", "South", "East"]);
186
+ expect(transposed.cols).toEqual(["Q1", "Q2"]);
187
+ });
188
+
189
+ test("pivot: transpose∘transpose is the identity (lossless round-trip)", () => {
190
+ const value: IterationBuilderValue = {
191
+ kind: "pivot",
192
+ as: "item",
193
+ layout: "matrix",
194
+ values: ["Q1", "Q2", "Q3"],
195
+ cols: ["North", "South"],
196
+ template: "{{row}} / {{col}}",
197
+ };
198
+ expect(transposeIterationValue(transposeIterationValue(value))).toEqual(value);
199
+ });
200
+
201
+ test("iterate: is a no-op (no second axis to swap)", () => {
202
+ const value: IterationBuilderValue = {
203
+ kind: "iterate",
204
+ as: "item",
205
+ layout: "stacked",
206
+ values: ["Alice", "Bob"],
207
+ template: "{{item.name}}",
208
+ };
209
+ expect(transposeIterationValue(value)).toEqual(value);
210
+ });
211
+ });
212
+
213
+ describe("staticMarkdownFromValue", () => {
214
+ test("matrix layout emits a GFM table with row + column headers", () => {
215
+ const md = staticMarkdownFromValue({
216
+ kind: "pivot",
217
+ as: "item",
218
+ layout: "matrix",
219
+ values: ["Q1", "Q2"],
220
+ cols: ["N", "S"],
221
+ template: "{{row}}-{{col}}",
222
+ });
223
+ const lines = md.split("\n");
224
+ expect(lines[0]).toBe("| | N | S |");
225
+ expect(lines[1]).toBe("| --- | --- | --- |");
226
+ expect(lines[2]).toBe("| Q1 | Q1-N | Q1-S |");
227
+ expect(lines[3]).toBe("| Q2 | Q2-N | Q2-S |");
228
+ });
229
+
230
+ test("stacked layout emits sequential blocks separated by a blank line", () => {
231
+ const md = staticMarkdownFromValue({
232
+ kind: "iterate",
233
+ as: "item",
234
+ layout: "stacked",
235
+ values: ["Alice", "Bob"],
236
+ template: "Name: {{item.name}}",
237
+ });
238
+ expect(md).toBe("Name: Alice\n\nName: Bob");
239
+ });
240
+
241
+ test("empty value list yields an empty string", () => {
242
+ expect(
243
+ staticMarkdownFromValue({
244
+ kind: "iterate",
245
+ as: "item",
246
+ layout: "stacked",
247
+ values: [],
248
+ template: "{{item.name}}",
249
+ }),
250
+ ).toBe("");
251
+ });
252
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Iteration BUILDER model (A5) — the data layer behind the guided
3
+ * `:::iterate` / `:::pivot` authoring dialog.
4
+ *
5
+ * The existing `IterationTemplateDialog` only edits the per-cell TEMPLATE. The
6
+ * guided builder also collects the DATA (the value list(s) + bind name + the
7
+ * pivot's second axis + layout) and writes a **fully-bound** directive whose
8
+ * value lists live in its attributes — so the block renders a populated result
9
+ * with the built-in {@link evaluateEmbedded} (no consumer data engine needed) and
10
+ * the `⋯` re-edit can reopen it losslessly.
11
+ *
12
+ * Pure + React-free so it is unit-testable on its own:
13
+ * serialize → `:::iterate{as="item" layout="stacked" values="A, B, C"}\n…\n:::`
14
+ * `:::pivot{layout="matrix" rows="Q1, Q2" cols="N, S"}\n…\n:::`
15
+ * parse ← the inverse (for the `⋯` reopen)
16
+ * evaluate → {@link evaluateEmbedded} turns the embedded lists into cells.
17
+ *
18
+ * Value lists are comma-separated in the attribute (values containing a literal
19
+ * comma aren't supported — the dialog enters one value per line and joins them).
20
+ */
21
+ import {
22
+ defaultInterpolate,
23
+ type InterpolateTemplate,
24
+ type IterationCell,
25
+ type IterationData,
26
+ type IterationLayout,
27
+ type IterationSpec,
28
+ } from "./iteration";
29
+
30
+ /** The dialog's working value — the collected data + template for one directive. */
31
+ export interface IterationBuilderValue {
32
+ kind: "iterate" | "pivot";
33
+ /** Loop-variable name (iterate). Default `"item"`. */
34
+ as: string;
35
+ layout: IterationLayout;
36
+ /** iterate: the list of items. pivot: the ROW values. */
37
+ values: string[];
38
+ /** pivot: the COLUMN values. */
39
+ cols?: string[];
40
+ /** The per-cell markdown template (directive body). */
41
+ template: string;
42
+ }
43
+
44
+ const DEFAULT_LAYOUT: Record<"iterate" | "pivot", IterationLayout> = {
45
+ iterate: "stacked",
46
+ pivot: "matrix",
47
+ };
48
+
49
+ /**
50
+ * The layouts offered per kind (the guided builder's toggle group AND the
51
+ * node-menu's "Change layout" submenu share this single list so they can't
52
+ * drift). `iterate` has no `matrix` (it has only one axis); `pivot` leads with
53
+ * `matrix` (its natural shape).
54
+ */
55
+ export const ITERATION_LAYOUTS: Record<"iterate" | "pivot", IterationLayout[]> = {
56
+ iterate: ["stacked", "grid", "bento"],
57
+ pivot: ["matrix", "grid", "bento"],
58
+ };
59
+
60
+ /** Default per-cell template per kind (so a fresh block renders something). */
61
+ export const DEFAULT_TEMPLATE: Record<"iterate" | "pivot", string> = {
62
+ iterate: "{{item.name}}",
63
+ pivot: "{{row}} · {{col}}",
64
+ };
65
+
66
+ /** Split a comma-separated attribute into trimmed, non-empty values. */
67
+ export function splitList(value: string | undefined): string[] {
68
+ return (value ?? "")
69
+ .split(",")
70
+ .map((v) => v.trim())
71
+ .filter((v) => v.length > 0);
72
+ }
73
+
74
+ /** Parse a directive attribute string (`a="x" b="y"`) into a record. */
75
+ export function parseAttributes(attrString: string): Record<string, string> {
76
+ const out: Record<string, string> = {};
77
+ const re = /([\w-]+)\s*=\s*"([^"]*)"/g;
78
+ let m: RegExpExecArray | null;
79
+ while ((m = re.exec(attrString))) {
80
+ const key = m[1];
81
+ if (key) out[key] = m[2] ?? "";
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Serialize an attributes record back to `key="value"` (stable, escaped). */
87
+ function attrString(attrs: Record<string, string>): string {
88
+ return Object.entries(attrs)
89
+ .filter(([, v]) => v != null && v !== "")
90
+ .map(([k, v]) => `${k}="${String(v).replace(/"/g, "'")}"`)
91
+ .join(" ");
92
+ }
93
+
94
+ /** Build the `{ attributes, template }` for a builder value (the write-back shape). */
95
+ export function directivePartsFromValue(value: IterationBuilderValue): {
96
+ attributes: Record<string, string>;
97
+ template: string;
98
+ } {
99
+ const attributes: Record<string, string> =
100
+ value.kind === "pivot"
101
+ ? {
102
+ layout: value.layout,
103
+ rows: value.values.join(", "),
104
+ cols: (value.cols ?? []).join(", "),
105
+ }
106
+ : {
107
+ as: value.as || "item",
108
+ layout: value.layout,
109
+ values: value.values.join(", "),
110
+ };
111
+ return { attributes, template: value.template.trim() };
112
+ }
113
+
114
+ /**
115
+ * The longest run of leading colons (≥3 — a nested CONTAINER-directive fence such
116
+ * as `:::card` / `:::callout` / a nested `:::iterate`) on any line of the template.
117
+ * `remark-directive` closes a container at the nearest fence of EQUAL length, so an
118
+ * outer fence the same length as a nested one is terminated early (the body is
119
+ * truncated and everything after the nested block is dropped). Returns 0 when the
120
+ * template has no nested container directive.
121
+ */
122
+ function maxColonRun(template: string): number {
123
+ let max = 0;
124
+ // Up to 3 leading spaces are allowed before a directive fence (CommonMark).
125
+ const re = /^ {0,3}(:{3,})/;
126
+ for (const line of template.split(/\r?\n/)) {
127
+ const m = re.exec(line);
128
+ if (m && m[1]!.length > max) max = m[1]!.length;
129
+ }
130
+ return max;
131
+ }
132
+
133
+ /**
134
+ * Pick the OUTER directive fence: at least 3 colons, and always STRICTLY longer
135
+ * than any nested container fence in the template — so a nested `:::card`'s closing
136
+ * `:::` can't terminate the outer `:::iterate` early. A template with no nested
137
+ * container directive keeps the canonical `:::` (backward compatible).
138
+ */
139
+ export function outerDirectiveFence(template: string): string {
140
+ return ":".repeat(Math.max(3, maxColonRun(template) + 1));
141
+ }
142
+
143
+ /** Serialize a builder value to a fully-bound `:::iterate` / `:::pivot` directive. */
144
+ export function serializeIterationDirective(value: IterationBuilderValue): string {
145
+ const { attributes, template } = directivePartsFromValue(value);
146
+ const fence = outerDirectiveFence(template);
147
+ return `${fence}${value.kind}{${attrString(attributes)}}\n${template}\n${fence}`;
148
+ }
149
+
150
+ /** Build a builder value from a directive's kind + attributes + body template. */
151
+ export function builderValueFromParts(
152
+ kind: "iterate" | "pivot",
153
+ attributes: Record<string, string>,
154
+ template: string,
155
+ ): IterationBuilderValue {
156
+ const layout = (attributes.layout as IterationLayout) || DEFAULT_LAYOUT[kind];
157
+ if (kind === "pivot") {
158
+ return {
159
+ kind,
160
+ as: attributes.as?.trim() || "item",
161
+ layout,
162
+ values: splitList(attributes.rows),
163
+ cols: splitList(attributes.cols),
164
+ template: template.trim() || DEFAULT_TEMPLATE.pivot,
165
+ };
166
+ }
167
+ return {
168
+ kind,
169
+ as: attributes.as?.trim() || "item",
170
+ layout,
171
+ values: splitList(attributes.values),
172
+ template: template.trim() || DEFAULT_TEMPLATE.iterate,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Parse a full `:::iterate` / `:::pivot` directive string into a builder value.
178
+ *
179
+ * The fence length is variable (`:{3,}`) and the close is matched by BACKREFERENCE
180
+ * (`\1`) to the same length as the open, so a directive whose body contains a
181
+ * nested `:::card` (3 colons) is opened/closed with `::::` (4) and the inner fence
182
+ * is not mistaken for the outer close. The non-greedy body anchored to end-of-input
183
+ * keeps the canonical 3-colon form round-tripping unchanged.
184
+ */
185
+ export function parseIterationDirective(markdown: string): IterationBuilderValue | null {
186
+ const m = markdown.match(/^(:{3,})(iterate|pivot)\{([^}]*)\}\n?([\s\S]*?)\n?\1\s*$/);
187
+ if (!m) return null;
188
+ return builderValueFromParts(
189
+ m[2] as "iterate" | "pivot",
190
+ parseAttributes(m[3] ?? ""),
191
+ m[4] ?? "",
192
+ );
193
+ }
194
+
195
+ /** An empty builder value for a fresh insert. */
196
+ export function emptyBuilderValue(kind: "iterate" | "pivot"): IterationBuilderValue {
197
+ return {
198
+ kind,
199
+ as: "item",
200
+ layout: DEFAULT_LAYOUT[kind],
201
+ values: [],
202
+ cols: kind === "pivot" ? [] : undefined,
203
+ template: DEFAULT_TEMPLATE[kind],
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Built-in iteration evaluator: resolve the value list(s) embedded in the
209
+ * directive attributes into cells — so a block authored with the guided builder
210
+ * renders a populated result with no consumer data engine. iterate reads
211
+ * `values=`; pivot reads `rows=` × `cols=`. Assignable to `EvaluateIteration`
212
+ * (it never returns null — it degrades to an empty cell list).
213
+ */
214
+ export function evaluateEmbedded(spec: IterationSpec): IterationData {
215
+ if (spec.kind === "pivot") {
216
+ const rows = splitList(spec.rows ?? spec.attributes.rows);
217
+ const cols = splitList(spec.cols ?? spec.attributes.cols);
218
+ if (rows.length === 0 || cols.length === 0) return { cells: [] };
219
+ const cells = rows.flatMap((row) =>
220
+ cols.map((col) => ({ context: { row, col }, row, col, key: `${row}|${col}` })),
221
+ );
222
+ return { cells, rowHeaders: rows, colHeaders: cols };
223
+ }
224
+ const values = splitList(spec.attributes.values ?? spec.source);
225
+ if (values.length === 0) return { cells: [] };
226
+ return {
227
+ cells: values.map((value, i) => ({ context: { value, name: value }, key: `${i}:${value}` })),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Swap a pivot's ROW and COLUMN value lists (the node-menu "Transpose" action).
233
+ * A no-op for `iterate` (it has no second axis) — the menu item hides itself in
234
+ * that case, but the function stays total (never throws) so a stale/misrouted
235
+ * call is harmless. `transposeIterationValue(transposeIterationValue(v)) === v`
236
+ * for any pivot value (a lossless round-trip).
237
+ */
238
+ export function transposeIterationValue(value: IterationBuilderValue): IterationBuilderValue {
239
+ if (value.kind !== "pivot") return value;
240
+ return { ...value, values: value.cols ?? [], cols: value.values };
241
+ }
242
+
243
+ /** Build the interpolation scope for one resolved cell (mirrors `IterationBlock`'s). */
244
+ function cellScope(as: string, cell: IterationCell, index: number): Record<string, unknown> {
245
+ return { ...cell.context, [as]: cell.context, index, row: cell.row, col: cell.col };
246
+ }
247
+
248
+ /** Escape a cell's markdown for use inside a GFM table cell (single logical line). */
249
+ function escapeTableCell(markdown: string): string {
250
+ return markdown
251
+ .replace(/\|/g, "\\|")
252
+ .replace(/\r?\n+/g, " ")
253
+ .trim();
254
+ }
255
+
256
+ /**
257
+ * Render a builder value's RESOLVED cells as plain, static markdown — the
258
+ * "Convert to static" node-menu action. Reuses {@link evaluateEmbedded} (the same
259
+ * resolver the live preview renders through), so the output matches what the
260
+ * block currently shows, with no ProseMirror/dialog dependency:
261
+ * - `matrix` (with both header axes present) → a GFM table.
262
+ * - anything else (`stacked` / `grid` / `bento`) → cells joined as sequential
263
+ * markdown blocks, separated by a blank line.
264
+ * An empty result set (no values entered yet) returns an empty string.
265
+ */
266
+ export function staticMarkdownFromValue(
267
+ value: IterationBuilderValue,
268
+ interpolate: InterpolateTemplate = defaultInterpolate,
269
+ ): string {
270
+ const { attributes } = directivePartsFromValue(value);
271
+ const spec: IterationSpec = {
272
+ kind: value.kind,
273
+ layout: value.layout,
274
+ template: value.template,
275
+ as: value.as,
276
+ attributes,
277
+ };
278
+ const data = evaluateEmbedded(spec);
279
+ if (data.cells.length === 0) return "";
280
+
281
+ const cellMarkdown = (cell: IterationCell, index: number): string =>
282
+ interpolate(value.template, cellScope(value.as, cell, index)).trim();
283
+
284
+ if (value.layout === "matrix" && data.rowHeaders?.length && data.colHeaders?.length) {
285
+ const rowHeaders = data.rowHeaders;
286
+ const colHeaders = data.colHeaders;
287
+ const at = new Map<string, string>();
288
+ data.cells.forEach((cell, i) => {
289
+ if (cell.row != null && cell.col != null) {
290
+ at.set(`${cell.row}|${cell.col}`, cellMarkdown(cell, i));
291
+ }
292
+ });
293
+ const headerRow = ["", ...colHeaders.map(escapeTableCell)];
294
+ const dividerRow = headerRow.map(() => "---");
295
+ const bodyRows = rowHeaders.map((row) => [
296
+ escapeTableCell(row),
297
+ ...colHeaders.map((col) => escapeTableCell(at.get(`${row}|${col}`) ?? "")),
298
+ ]);
299
+ return [headerRow, dividerRow, ...bodyRows].map((r) => `| ${r.join(" | ")} |`).join("\n");
300
+ }
301
+
302
+ return data.cells.map((cell, i) => cellMarkdown(cell, i)).join("\n\n");
303
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defaultInterpolate } from "./iteration";
4
+ import { specFromDirective } from "./directive";
5
+
6
+ describe("defaultInterpolate", () => {
7
+ it("substitutes a top-level token", () => {
8
+ expect(defaultInterpolate("Hi {{name}}", { name: "Ada" })).toBe("Hi Ada");
9
+ });
10
+
11
+ it("resolves a dot path", () => {
12
+ expect(defaultInterpolate("{{item.role}}", { item: { role: "Eng" } })).toBe("Eng");
13
+ });
14
+
15
+ it("leaves an unresolved token literal (so nested templates compose)", () => {
16
+ expect(defaultInterpolate("[{{nope}}]", {})).toBe("[{{nope}}]");
17
+ });
18
+
19
+ it("leaves non-token text untouched (markdown survives)", () => {
20
+ expect(defaultInterpolate("**{{n}}** and `code`", { n: "x" })).toBe("**x** and `code`");
21
+ });
22
+ });
23
+
24
+ describe("specFromDirective", () => {
25
+ it("defaults iterate to the stacked layout, item var", () => {
26
+ const spec = specFromDirective("iterate", {}, "body");
27
+ expect(spec).toMatchObject({
28
+ kind: "iterate",
29
+ layout: "stacked",
30
+ as: "item",
31
+ template: "body",
32
+ });
33
+ });
34
+
35
+ it("defaults pivot to the matrix layout", () => {
36
+ expect(specFromDirective("pivot", {}, "").layout).toBe("matrix");
37
+ });
38
+
39
+ it("honors an explicit layout + as + columns", () => {
40
+ const spec = specFromDirective("iterate", { layout: "grid", as: "p", columns: "2" }, "t");
41
+ expect(spec).toMatchObject({ layout: "grid", as: "p", columns: 2 });
42
+ });
43
+
44
+ it("ignores an invalid layout (falls back to the default)", () => {
45
+ expect(specFromDirective("iterate", { layout: "bogus" }, "t").layout).toBe("stacked");
46
+ });
47
+
48
+ it("passes the raw attributes through (escape hatch for evaluate)", () => {
49
+ expect(specFromDirective("iterate", { source: "sales", region: "EU" }, "t").attributes).toEqual(
50
+ {
51
+ source: "sales",
52
+ region: "EU",
53
+ },
54
+ );
55
+ });
56
+ });