@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- 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
|
+
});
|