@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,257 @@
1
+ import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { MarkdownPreview } from "./markdown-preview";
5
+ import type { CitationData } from "../markdown-academic/citations";
6
+
7
+ afterEach(cleanup);
8
+
9
+ /*
10
+ * The academic layer (footnotes / math / citations / TOC) — none of these nest a
11
+ * MarkdownPreview, so they share one file. They each render a single Streamdown.
12
+ */
13
+
14
+ describe("MarkdownPreview — footnotes", () => {
15
+ const DOC = `A claim.[^1] Repeated.[^1] Another.[^big]\n\n[^1]: First note with [a link](https://x.com).\n[^big]: Second note.`;
16
+
17
+ it("renders refs whose anchors resolve to the definitions (no broken ids)", async () => {
18
+ const { container } = render(<MarkdownPreview footnotes>{DOC}</MarkdownPreview>);
19
+ await waitFor(() => expect(container.querySelector("[data-footnote-ref]")).toBeInTheDocument());
20
+
21
+ const firstRef = container.querySelector('a[id="fnref-1"]') as HTMLAnchorElement;
22
+ expect(firstRef).toBeInTheDocument();
23
+ expect(firstRef.getAttribute("href")).toBe("#fn-1");
24
+ // The ref target exists.
25
+ expect(container.querySelector('li[id="fn-1"]')).toBeInTheDocument();
26
+ // In-page anchors must NOT open a new tab.
27
+ expect(firstRef).not.toHaveAttribute("target");
28
+ });
29
+
30
+ it("gives a repeated reference a unique id but the same number", async () => {
31
+ const { container } = render(<MarkdownPreview footnotes>{DOC}</MarkdownPreview>);
32
+ await waitFor(() => expect(container.querySelector('a[id="fnref-1-2"]')).toBeInTheDocument());
33
+ const second = container.querySelector('a[id="fnref-1-2"]') as HTMLAnchorElement;
34
+ expect(second.getAttribute("href")).toBe("#fn-1");
35
+ expect(second.textContent).toBe("1");
36
+ });
37
+
38
+ it("renders a labelled footnote section with a working back-reference", async () => {
39
+ const { container } = render(<MarkdownPreview footnotes>{DOC}</MarkdownPreview>);
40
+ await waitFor(() =>
41
+ expect(screen.getByRole("region", { name: "Footnotes" })).toBeInTheDocument(),
42
+ );
43
+ const backref = container.querySelector('a[data-footnote-backref][href="#fnref-1"]');
44
+ expect(backref).toBeInTheDocument();
45
+ // The footnote is cited twice → one back-ref per mention (GFM behavior).
46
+ expect(
47
+ container.querySelector('a[data-footnote-backref][href="#fnref-1-2"]'),
48
+ ).toBeInTheDocument();
49
+ // The definition's link survived sanitization.
50
+ expect(screen.getByRole("link", { name: "a link" })).toHaveAttribute("href", "https://x.com/");
51
+ });
52
+
53
+ // Regression guard for #317: a link inside a running block of text must carry a
54
+ // non-color cue (a resting `underline`) or axe's `link-in-text-block` fires. The
55
+ // footnote marker is one of the sites that regressed to color-only (`no-underline`
56
+ // at rest, `hover:underline` only).
57
+ it("keeps a resting underline on the footnote marker (#317 — link-in-text-block)", async () => {
58
+ const { container } = render(<MarkdownPreview footnotes>{DOC}</MarkdownPreview>);
59
+ await waitFor(() => expect(container.querySelector("[data-footnote-ref]")).toBeInTheDocument());
60
+ const ref = container.querySelector("[data-footnote-ref]") as HTMLAnchorElement;
61
+ expect(ref.className).toMatch(/(?:^|\s)underline(?:\s|$)/);
62
+ expect(ref.className).not.toMatch(/(?:^|\s)no-underline(?:\s|$)/);
63
+ });
64
+ });
65
+
66
+ describe("MarkdownPreview — math (KaTeX)", () => {
67
+ it("renders inline math with MathML for assistive tech", async () => {
68
+ const { container } = render(
69
+ <MarkdownPreview math>{`Mass-energy: $E = mc^2$.`}</MarkdownPreview>,
70
+ );
71
+ await waitFor(() => expect(container.querySelector('[role="math"]')).toBeInTheDocument());
72
+ // KaTeX emits MathML (read by AT) + an aria-hidden visual layer.
73
+ const mathml = container.querySelector(".katex-mathml");
74
+ expect(mathml).toBeInTheDocument();
75
+ expect(mathml?.textContent).toContain("E = mc^2"); // the TeX annotation, for AT
76
+ expect(container.querySelector('.katex-html[aria-hidden="true"]')).toBeInTheDocument();
77
+ // A raw-TeX fallback name for AT that can't process MathML.
78
+ expect(container.querySelector('[role="math"]')).toHaveAttribute("aria-label", "E = mc^2");
79
+ });
80
+
81
+ it("renders block math ($$ on its own lines)", async () => {
82
+ const { container } = render(
83
+ <MarkdownPreview
84
+ math
85
+ >{`Result:\n\n$$\n\\int_0^1 x\\,dx = \\frac{1}{2}\n$$`}</MarkdownPreview>,
86
+ );
87
+ await waitFor(() => expect(container.querySelector('div[role="math"]')).toBeInTheDocument());
88
+ expect(container.querySelector('div[role="math"] .katex')).toBeInTheDocument();
89
+ });
90
+
91
+ it("does not parse $…$ when math is off", async () => {
92
+ const { container } = render(<MarkdownPreview>{`Price is $5 and $10.`}</MarkdownPreview>);
93
+ await waitFor(() => expect(screen.getByTestId("markdown-preview")).toBeInTheDocument());
94
+ expect(container.querySelector('[role="math"]')).toBeNull();
95
+ });
96
+
97
+ it("degrades a malformed expression to a contained fallback (no crash)", async () => {
98
+ render(<MarkdownPreview math>{`Bad: $\\frac{1}{$`}</MarkdownPreview>);
99
+ // Either a KaTeX error node or our code fallback — the page still renders.
100
+ await waitFor(() => expect(screen.getByTestId("markdown-preview")).toBeInTheDocument());
101
+ });
102
+ });
103
+
104
+ describe("MarkdownPreview — citations + bibliography", () => {
105
+ const DB: Record<string, CitationData> = {
106
+ smith2020: { author: "Smith", year: 2020, title: "On Things", url: "https://ex.com/s" },
107
+ jones2019: { author: "Jones", year: 2019, title: "More Things", doi: "10.1/x" },
108
+ };
109
+ const resolveCitation = (k: string) => DB[k] ?? null;
110
+
111
+ it("numbers inline cites and the bibliography consistently", async () => {
112
+ const { container } = render(
113
+ <MarkdownPreview resolveCitation={resolveCitation}>
114
+ {`See [@smith2020] and [@jones2019].\n\n::bibliography`}
115
+ </MarkdownPreview>,
116
+ );
117
+ await waitFor(() =>
118
+ expect(screen.getByRole("region", { name: "References" })).toBeInTheDocument(),
119
+ );
120
+ // Inline cite 1 → smith, links to its bib entry id.
121
+ const cite = container.querySelector('a[href="#ref-smith2020"]') as HTMLAnchorElement;
122
+ expect(cite.textContent).toBe("1");
123
+ // The bare "1" carries a real accessible name (the title isn't announced by AT).
124
+ expect(cite.getAttribute("aria-label")).toMatch(/^Citation: /);
125
+ // Bibliography entry id matches the inline href.
126
+ expect(container.querySelector('li[id="ref-smith2020"]')).toBeInTheDocument();
127
+ expect(container.querySelector('li[id="ref-jones2019"]')).toBeInTheDocument();
128
+ });
129
+
130
+ it("renders a locator and multiple keys in one bracket", async () => {
131
+ const { container } = render(
132
+ <MarkdownPreview resolveCitation={resolveCitation}>
133
+ {`Per [@jones2019, p. 5; @smith2020].`}
134
+ </MarkdownPreview>,
135
+ );
136
+ await waitFor(() =>
137
+ expect(container.querySelector('a[href="#ref-jones2019"]')).toBeInTheDocument(),
138
+ );
139
+ expect(container.querySelector('a[href="#ref-jones2019"]')?.textContent).toBe("1, p. 5");
140
+ expect(container.querySelector('a[href="#ref-smith2020"]')?.textContent).toBe("2");
141
+ });
142
+
143
+ it("renders the author-year style", async () => {
144
+ const { container } = render(
145
+ <MarkdownPreview resolveCitation={resolveCitation} citationStyle="author-year">
146
+ {`See [@smith2020].`}
147
+ </MarkdownPreview>,
148
+ );
149
+ await waitFor(() =>
150
+ expect(container.querySelector('a[href="#ref-smith2020"]')).toBeInTheDocument(),
151
+ );
152
+ expect(container.querySelector('a[href="#ref-smith2020"]')?.textContent).toBe("Smith 2020");
153
+ });
154
+
155
+ it("keeps an unresolved citation as a graceful literal", async () => {
156
+ render(
157
+ <MarkdownPreview
158
+ resolveCitation={resolveCitation}
159
+ >{`Unknown [@ghost1999].`}</MarkdownPreview>,
160
+ );
161
+ await waitFor(() => expect(screen.getByText("[@ghost1999]")).toBeInTheDocument());
162
+ });
163
+
164
+ it("does not parse [@key] when no resolver is supplied", async () => {
165
+ render(<MarkdownPreview>{`Text [@smith2020] here.`}</MarkdownPreview>);
166
+ await waitFor(() => expect(screen.getByText(/\[@smith2020\]/)).toBeInTheDocument());
167
+ });
168
+
169
+ // Regression guard for #317: the inline citation ref and the bibliography's
170
+ // external DOI/URL link both live inside a running block of body text, so axe's
171
+ // `link-in-text-block` requires a non-color cue (a resting `underline`) — color
172
+ // alone (`text-primary` + `hover:underline`) is not enough.
173
+ it("keeps a resting underline on the inline cite and bibliography links (#317 — link-in-text-block)", async () => {
174
+ // (see also the `-text` rung guard below — #317 had two halves and the
175
+ // underline only closed the 1.4.1 one.)
176
+ const { container } = render(
177
+ <MarkdownPreview resolveCitation={resolveCitation}>
178
+ {`See [@smith2020] and [@jones2019].\n\n::bibliography`}
179
+ </MarkdownPreview>,
180
+ );
181
+ await waitFor(() =>
182
+ expect(screen.getByRole("region", { name: "References" })).toBeInTheDocument(),
183
+ );
184
+ const inlineCite = container.querySelector('a[href="#ref-smith2020"]') as HTMLAnchorElement;
185
+ expect(inlineCite.className).toMatch(/(?:^|\s)underline(?:\s|$)/);
186
+ expect(inlineCite.className).not.toMatch(/(?:^|\s)no-underline(?:\s|$)/);
187
+
188
+ // jones2019 has a `doi` (no `url`) → renders the bibliography's external link.
189
+ const bibLink = container.querySelector(
190
+ 'li[id="ref-jones2019"] a[href^="https://doi.org/"]',
191
+ ) as HTMLAnchorElement;
192
+ expect(bibLink).toBeInTheDocument();
193
+ expect(bibLink.className).toMatch(/(?:^|\s)underline(?:\s|$)/);
194
+ expect(bibLink.className).not.toMatch(/(?:^|\s)no-underline(?:\s|$)/);
195
+ });
196
+
197
+ // Regression guard for the OTHER half of #317 — `color-contrast`. Both links
198
+ // used the `--primary` FILL rung as body text, which measured 4.29-4.31:1 in
199
+ // light (sub-AA for normal text). #399 minted `--primary-text`, gated
200
+ // ≥4.5:1 on every content surface in every theme by
201
+ // `packages/tokens/src/themes-contrast.test.ts`; this row is the CLASS-NAME
202
+ // lock that keeps these two call sites pointed at it. It is not a contrast
203
+ // proof on its own — the token test is.
204
+ it("uses the on-surface text rung (text-primary-text), not the --primary fill (#317/#399 — color-contrast)", async () => {
205
+ const { container } = render(
206
+ <MarkdownPreview resolveCitation={resolveCitation}>
207
+ {`See [@smith2020] and [@jones2019].\n\n::bibliography`}
208
+ </MarkdownPreview>,
209
+ );
210
+ await waitFor(() =>
211
+ expect(screen.getByRole("region", { name: "References" })).toBeInTheDocument(),
212
+ );
213
+
214
+ const inlineCite = container.querySelector('a[href="#ref-smith2020"]') as HTMLAnchorElement;
215
+ expect(inlineCite.className.split(/\s+/)).toContain("text-primary-text");
216
+ expect(inlineCite.className.split(/\s+/)).not.toContain("text-primary");
217
+
218
+ const bibLink = container.querySelector(
219
+ 'li[id="ref-jones2019"] a[href^="https://doi.org/"]',
220
+ ) as HTMLAnchorElement;
221
+ expect(bibLink.className.split(/\s+/)).toContain("text-primary-text");
222
+ expect(bibLink.className.split(/\s+/)).not.toContain("text-primary");
223
+ });
224
+ });
225
+
226
+ describe("MarkdownPreview — generated TOC", () => {
227
+ it("renders a nav whose links resolve to heading ids", async () => {
228
+ const { container } = render(
229
+ <MarkdownPreview toc>{`::toc\n\n# Intro\n\nbody\n\n## Details`}</MarkdownPreview>,
230
+ );
231
+ const nav = await screen.findByRole("navigation", { name: "Contents" });
232
+ const links = within(nav).getAllByRole("link");
233
+ expect(links.map((l) => l.getAttribute("href"))).toEqual(["#intro", "#details"]);
234
+ expect(container.querySelector('h1[id="intro"]')).toBeInTheDocument();
235
+ expect(container.querySelector('h2[id="details"]')).toBeInTheDocument();
236
+ });
237
+
238
+ it("does not stamp heading ids when toc is off", async () => {
239
+ const { container } = render(<MarkdownPreview>{`# Intro`}</MarkdownPreview>);
240
+ await waitFor(() => expect(screen.getByRole("heading", { name: "Intro" })).toBeInTheDocument());
241
+ expect(container.querySelector("h1")?.getAttribute("id")).toBeNull();
242
+ });
243
+
244
+ // Regression guard for #317: TOC entries share the same color-only-link pattern
245
+ // (kept consistent with citations/footnotes even though axe didn't flag this site
246
+ // in the original scan).
247
+ it("keeps a resting underline on TOC entries (#317 — link-in-text-block)", async () => {
248
+ render(<MarkdownPreview toc>{`::toc\n\n# Intro\n\nbody\n\n## Details`}</MarkdownPreview>);
249
+ const nav = await screen.findByRole("navigation", { name: "Contents" });
250
+ const links = within(nav).getAllByRole("link");
251
+ expect(links.length).toBeGreaterThan(0);
252
+ for (const link of links) {
253
+ expect(link.className).toMatch(/(?:^|\s)underline(?:\s|$)/);
254
+ expect(link.className).not.toMatch(/(?:^|\s)no-underline(?:\s|$)/);
255
+ }
256
+ });
257
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Locks the user-reported gap: a ```calc fence used as an iteration cell template
3
+ * must RESOLVE into a CalcBlock (not show as raw text) — in BOTH the matrix/pivot
4
+ * and the stacked layouts. MarkdownPreview threads the calc `evaluate` into each
5
+ * cell's nested preview, so calc objects compute per cell. The builder dialog's
6
+ * live preview renders through this same path.
7
+ *
8
+ * Nested previews are jsdom-sensitive (Streamdown), so this lives in its own file
9
+ * with generous waits — per the markdown-preview-iteration suite convention.
10
+ */
11
+ import { cleanup, render, waitFor } from "@testing-library/react";
12
+ import { afterEach, describe, expect, it } from "vitest";
13
+
14
+ import { MarkdownPreview } from "./markdown-preview";
15
+ import { evaluateEmbedded } from "../markdown-iteration/iteration-builder";
16
+ import type { EvaluateCalc } from "../calc-block";
17
+
18
+ afterEach(cleanup);
19
+
20
+ // A minimal calc engine: each non-empty line resolves to 42. Proves a calc cell
21
+ // RESOLVES into a CalcBlock inside an iteration — the library renders, the app
22
+ // computes (no math engine bundled).
23
+ const evaluateCalc: EvaluateCalc = (source) => ({
24
+ results: source
25
+ .split("\n")
26
+ .filter((line) => line.trim().length > 0)
27
+ .map((_, i) => ({
28
+ line: i + 1,
29
+ tokens: [],
30
+ value: { kind: "number" as const, raw: 42, display: "42" },
31
+ })),
32
+ });
33
+
34
+ const calcCount = (root: HTMLElement) => root.querySelectorAll('[data-testid="calc-block"]').length;
35
+
36
+ describe("calc objects resolve inside iteration cells (every layout)", () => {
37
+ it("pivot/matrix: every cell's calc fence renders a CalcBlock", async () => {
38
+ const md = [
39
+ `:::pivot{rows="Q1, Q2" cols="N, S" layout="matrix"}`,
40
+ "```calc",
41
+ "items = {{row}} + {{col}}",
42
+ "total = items",
43
+ "```",
44
+ ":::",
45
+ ].join("\n");
46
+ const { container } = render(
47
+ <MarkdownPreview evaluateIteration={evaluateEmbedded} evaluate={evaluateCalc}>
48
+ {md}
49
+ </MarkdownPreview>,
50
+ );
51
+ // 2 rows × 2 cols = 4 resolved calc blocks.
52
+ await waitFor(() => expect(calcCount(container)).toBe(4), { timeout: 8000 });
53
+ });
54
+
55
+ it("iterate/stacked: every item's calc fence renders a CalcBlock", async () => {
56
+ const md = [
57
+ `:::iterate{as="item" values="A, B, C" layout="stacked"}`,
58
+ "```calc",
59
+ "x = {{item.name}}",
60
+ "```",
61
+ ":::",
62
+ ].join("\n");
63
+ const { container } = render(
64
+ <MarkdownPreview evaluateIteration={evaluateEmbedded} evaluate={evaluateCalc}>
65
+ {md}
66
+ </MarkdownPreview>,
67
+ );
68
+ // 3 values = 3 resolved calc blocks (stacked).
69
+ await waitFor(() => expect(calcCount(container)).toBe(3), { timeout: 8000 });
70
+ });
71
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Locks the fence-collision fix: a nested CONTAINER directive (`:::card`) inside an
3
+ * `:::iterate` / `:::pivot` cell must render as the REAL component AND keep the
4
+ * content AFTER it, instead of being truncated. Before the fix the builder
5
+ * serialized a 3-colon `:::iterate`, so the nested `:::card`'s closing `:::` closed
6
+ * the OUTER block early — the card rendered as plain text and everything after it
7
+ * (incl. `{{tokens}}`) was dropped. `serializeIterationDirective` now escalates the
8
+ * outer fence past any nested fence (`outerDirectiveFence`).
9
+ *
10
+ * Nested previews are jsdom-sensitive (Streamdown), so this lives in its OWN file
11
+ * with generous waits — per the markdown-preview-iteration suite convention.
12
+ */
13
+ import { cleanup, render, waitFor } from "@testing-library/react";
14
+ import { afterEach, describe, expect, it } from "vitest";
15
+
16
+ import { MarkdownPreview } from "./markdown-preview";
17
+ import {
18
+ evaluateEmbedded,
19
+ serializeIterationDirective,
20
+ } from "../markdown-iteration/iteration-builder";
21
+
22
+ afterEach(cleanup);
23
+
24
+ // Cards compose the `bg-card` surface — count them to prove the directive rendered
25
+ // as a real <Card>, not as truncated plain text.
26
+ const cardCount = (root: HTMLElement) => root.querySelectorAll(".bg-card").length;
27
+
28
+ describe("nested directive inside an iteration cell (fence-collision fix)", () => {
29
+ it("iterate: a :::card with trailing content renders a real Card AND the trailing text, per item", async () => {
30
+ const md = serializeIterationDirective({
31
+ kind: "iterate",
32
+ as: "item",
33
+ layout: "stacked",
34
+ values: ["Ada", "Bo"],
35
+ template: [
36
+ `:::card{title="{{item.name}}"}`,
37
+ `body for {{item.name}}`,
38
+ `:::`,
39
+ ``,
40
+ `tail {{item.name}}`,
41
+ ].join("\n"),
42
+ });
43
+ // The serializer escalates the outer fence past the nested `:::card` (3 colons).
44
+ expect(md.startsWith("::::iterate")).toBe(true);
45
+
46
+ const { container } = render(
47
+ <MarkdownPreview evaluateIteration={evaluateEmbedded}>{md}</MarkdownPreview>,
48
+ );
49
+
50
+ // One real Card per item (2) — was 0 before the fix (rendered as plain text).
51
+ await waitFor(() => expect(cardCount(container)).toBe(2), { timeout: 8000 });
52
+ expect(container.textContent).toContain("body for Ada");
53
+ expect(container.textContent).toContain("body for Bo");
54
+ // The content AFTER the nested card survives — it was dropped before the fix.
55
+ expect(container.textContent).toContain("tail Ada");
56
+ expect(container.textContent).toContain("tail Bo");
57
+ });
58
+ });
@@ -0,0 +1,187 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+
4
+ import { MarkdownPreview } from "./markdown-preview";
5
+ import type { IterationData, IterationSpec } from "../markdown-iteration";
6
+
7
+ const meta = {
8
+ title: "Editor/MarkdownPreview/Iteration",
9
+ component: MarkdownPreview,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ layout: "padded",
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "`:::iterate` / `:::pivot` repeat a per-cell markdown TEMPLATE (the directive body) " +
17
+ "over consumer-resolved data. `evaluateIteration(spec)` returns the data (the app owns " +
18
+ "the source); `interpolate(template, ctx)` fills each cell (a minimal `{{path}}` default " +
19
+ "is provided). Layouts: `stacked` (vertical), `grid` + `matrix` (a `@elabs-ai/components-ui` Table). " +
20
+ "Cells render through a nested, depth-capped `MarkdownPreview`.",
21
+ },
22
+ },
23
+ },
24
+ } satisfies Meta<typeof MarkdownPreview>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ /* A small in-app data source (would be a query / binding in a real app). */
30
+ const TEAM = [
31
+ { name: "Ada Lovelace", role: "Engineering", commits: 128 },
32
+ { name: "Grace Hopper", role: "Compilers", commits: 211 },
33
+ { name: "Alan Turing", role: "Theory", commits: 97 },
34
+ ];
35
+
36
+ const REVENUE: Record<string, Record<string, number>> = {
37
+ Q1: { EU: 120, US: 240 },
38
+ Q2: { EU: 150, US: 265 },
39
+ };
40
+
41
+ const evaluateIteration = (spec: IterationSpec): IterationData | null => {
42
+ if (spec.kind === "pivot") {
43
+ const rowHeaders = Object.keys(REVENUE);
44
+ const colHeaders = ["EU", "US"];
45
+ return {
46
+ rowHeaders,
47
+ colHeaders,
48
+ cells: rowHeaders.flatMap((r) =>
49
+ colHeaders.map((c) => ({ row: r, col: c, context: { amount: REVENUE[r]?.[c] ?? 0 } })),
50
+ ),
51
+ };
52
+ }
53
+ return {
54
+ columns: ["Member"],
55
+ cells: TEAM.map((m) => ({ context: m, key: m.name })),
56
+ };
57
+ };
58
+
59
+ export const Stacked: Story = {
60
+ args: {
61
+ evaluateIteration,
62
+ children: `# Team
63
+
64
+ :::iterate{as="m" layout="stacked"}
65
+ :::card{title="{{m.name}}"}
66
+ **{{m.role}}** · {{m.commits}} commits
67
+ :::
68
+ :::`,
69
+ },
70
+ play: async ({ canvasElement }) => {
71
+ const canvas = within(canvasElement);
72
+ await expect(await canvas.findByText("Ada Lovelace", {}, { timeout: 8000 })).toBeVisible();
73
+ await expect(canvas.getByText("Grace Hopper")).toBeVisible();
74
+ },
75
+ };
76
+
77
+ export const Grid: Story = {
78
+ args: {
79
+ evaluateIteration,
80
+ children: `# Team grid
81
+
82
+ :::iterate{as="m" layout="grid"}
83
+ **{{m.name}}** — {{m.role}}
84
+ :::`,
85
+ },
86
+ };
87
+
88
+ export const Bento: Story = {
89
+ args: {
90
+ evaluateIteration,
91
+ children: `# Team bento
92
+
93
+ :::iterate{as="m" layout="bento"}
94
+ **{{m.name}}**
95
+
96
+ {{m.role}}
97
+ :::`,
98
+ },
99
+ play: async ({ canvasElement }) => {
100
+ const canvas = within(canvasElement);
101
+ await expect(await canvas.findByText("Ada Lovelace", {}, { timeout: 8000 })).toBeVisible();
102
+ await expect(canvas.getByText("Alan Turing")).toBeVisible();
103
+ },
104
+ };
105
+
106
+ /**
107
+ * A nested `:::card` (with content AFTER it) inside an `:::iterate` cell. Authored
108
+ * with a FOUR-colon outer fence (`::::iterate … ::::`) so the inner `:::card`'s
109
+ * close can't terminate the outer block — the card renders as a real component and
110
+ * the trailing line survives. The guided builder emits this fence automatically.
111
+ */
112
+ export const NestedCard: Story = {
113
+ name: "Nested :::card (+ trailing)",
114
+ args: {
115
+ evaluateIteration,
116
+ children: `::::iterate{as="m" layout="stacked"}
117
+ :::card{title="{{m.name}}"}
118
+ **{{m.role}}** · {{m.commits}} commits
119
+ :::
120
+
121
+ Reviewed {{m.name}}.
122
+ ::::`,
123
+ },
124
+ play: async ({ canvasElement }) => {
125
+ const canvas = within(canvasElement);
126
+ await expect(await canvas.findByText("Ada Lovelace", {}, { timeout: 8000 })).toBeVisible();
127
+ // The trailing line after the nested card survives (the fence-collision fix).
128
+ await expect(canvas.getByText("Reviewed Ada Lovelace.")).toBeVisible();
129
+ await expect(canvas.getByText("Reviewed Grace Hopper.")).toBeVisible();
130
+ },
131
+ };
132
+
133
+ export const Pivot: Story = {
134
+ name: "Pivot (matrix)",
135
+ args: {
136
+ evaluateIteration,
137
+ children: `# Revenue by quarter × region
138
+
139
+ :::pivot
140
+ **\${{amount}}**M
141
+ :::`,
142
+ },
143
+ play: async ({ canvasElement }) => {
144
+ const canvas = within(canvasElement);
145
+ await expect(
146
+ await canvas.findByRole("columnheader", { name: "EU" }, { timeout: 8000 }),
147
+ ).toBeVisible();
148
+ await expect(canvas.getByRole("rowheader", { name: "Q1" })).toBeVisible();
149
+ },
150
+ };
151
+
152
+ /**
153
+ * Nested iteration: an outer per-member repeat whose template contains an inner
154
+ * `:::iterate` over that member's data. The outer pass leaves the inner tokens
155
+ * literal; the inner pass (one level deeper, depth-capped) resolves them.
156
+ */
157
+ export const Nested: Story = {
158
+ args: {
159
+ evaluateIteration: (spec: IterationSpec): IterationData | null => {
160
+ if (spec.source === "skills") {
161
+ const skills = (spec.attributes.of ?? "").split(",").filter(Boolean);
162
+ return { cells: skills.map((s) => ({ context: { skill: s.trim() }, key: s })) };
163
+ }
164
+ return {
165
+ cells: [
166
+ { context: { name: "Ada", of: "Math, Engines, Notes" }, key: "Ada" },
167
+ { context: { name: "Grace", of: "Compilers, Teaching" }, key: "Grace" },
168
+ ],
169
+ };
170
+ },
171
+ children: `:::iterate{as="person"}
172
+ ### {{person.name}}
173
+
174
+ :::iterate{source="skills" of="{{person.of}}"}
175
+ - {{skill}}
176
+ :::
177
+ :::`,
178
+ },
179
+ };
180
+
181
+ export const EmptyState: Story = {
182
+ name: "Empty (no data)",
183
+ args: {
184
+ evaluateIteration: () => ({ cells: [] }),
185
+ children: `:::iterate{as="m"}\n{{m.name}}\n:::`,
186
+ },
187
+ };
@@ -0,0 +1,90 @@
1
+ import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { MarkdownPreview } from "./markdown-preview";
5
+ import type { IterationData, IterationSpec } from "../markdown-iteration";
6
+
7
+ afterEach(cleanup);
8
+
9
+ /*
10
+ * `:::iterate` cells mount a NESTED MarkdownPreview (interpolated template →
11
+ * Streamdown). That nested render is jsdom-sensitive (it is perturbed by other
12
+ * Streamdown renders in the same file), so — per the suite convention — these
13
+ * live in their OWN file with the HEAVIEST (nested-iteration) case FIRST.
14
+ */
15
+
16
+ const PEOPLE = [
17
+ { name: "Ada", role: "Engineering" },
18
+ { name: "Bo", role: "Design" },
19
+ ];
20
+
21
+ function evaluateIteration(spec: IterationSpec): IterationData | null {
22
+ if (spec.kind === "pivot") {
23
+ return {
24
+ rowHeaders: ["Q1", "Q2"],
25
+ colHeaders: ["EU", "US"],
26
+ cells: ["Q1", "Q2"].flatMap((r) =>
27
+ ["EU", "US"].map((c) => ({ row: r, col: c, context: { cell: `${r}-${c}` } })),
28
+ ),
29
+ };
30
+ }
31
+ // The nested case: each person owns a sub-list of projects.
32
+ if (spec.source === "projects") {
33
+ const projects = (spec.attributes.of ?? "").split(",").filter(Boolean);
34
+ return { cells: projects.map((p) => ({ context: { project: p.trim() }, key: p })) };
35
+ }
36
+ return { cells: PEOPLE.map((p) => ({ context: p, key: p.name })) };
37
+ }
38
+
39
+ describe("MarkdownPreview — iteration (nested render, heaviest first)", () => {
40
+ it("renders a nested :::iterate inside an iterated cell (depth-capped, no loop)", async () => {
41
+ const md = `:::iterate{as="person"}
42
+ ### {{person.name}}
43
+
44
+ :::iterate{source="projects" of="Alpha, Beta"}
45
+ - {{project}}
46
+ :::
47
+ :::`;
48
+ render(<MarkdownPreview evaluateIteration={evaluateIteration}>{md}</MarkdownPreview>);
49
+ await waitFor(() => expect(screen.getByText("Ada")).toBeInTheDocument(), { timeout: 8000 });
50
+ // Inner iteration rendered the per-person project list — once per person (2),
51
+ // proving the outer pass left `{{project}}` literal for the inner pass.
52
+ expect(screen.getAllByText("Alpha")).toHaveLength(2);
53
+ expect(screen.getAllByText("Beta")).toHaveLength(2);
54
+ });
55
+ });
56
+
57
+ describe("MarkdownPreview — iteration layouts", () => {
58
+ it("stacked: repeats the interpolated template per item", async () => {
59
+ const md = `:::iterate{as="p" layout="stacked"}\n**{{p.name}}** — {{p.role}}\n:::`;
60
+ const { container } = render(
61
+ <MarkdownPreview evaluateIteration={evaluateIteration}>{md}</MarkdownPreview>,
62
+ );
63
+ await waitFor(() =>
64
+ expect(container.querySelector('[data-iteration="iterate"]')).toBeInTheDocument(),
65
+ );
66
+ const group = container.querySelector('[data-iteration-layout="stacked"]')!;
67
+ expect(group.textContent).toContain("Ada");
68
+ expect(group.textContent).toContain("Engineering");
69
+ });
70
+
71
+ it("pivot: builds a matrix table with row + column headers", async () => {
72
+ const md = `:::pivot\nCell {{cell}}\n:::`;
73
+ const { container } = render(
74
+ <MarkdownPreview evaluateIteration={evaluateIteration}>{md}</MarkdownPreview>,
75
+ );
76
+ await waitFor(() =>
77
+ expect(container.querySelector('[data-iteration="pivot"]')).toBeInTheDocument(),
78
+ );
79
+ const table = container.querySelector("table")!;
80
+ expect(within(table).getByRole("columnheader", { name: "EU" })).toBeInTheDocument();
81
+ expect(within(table).getByRole("rowheader", { name: "Q1" })).toBeInTheDocument();
82
+ expect(table.textContent).toContain("Q1-US");
83
+ });
84
+
85
+ it("does not parse :::iterate when no evaluateIteration is supplied", async () => {
86
+ // Unknown container directive → the explicit unknown-block error, not a crash.
87
+ render(<MarkdownPreview>{`:::iterate{as="p"}\n{{p.name}}\n:::`}</MarkdownPreview>);
88
+ await waitFor(() => expect(screen.getByText(/Unknown block/i)).toBeInTheDocument());
89
+ });
90
+ });