@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,61 @@
1
+ import { render, screen, waitFor, cleanup } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { MarkdownPreview } from "./markdown-preview";
5
+
6
+ afterEach(cleanup);
7
+
8
+ /*
9
+ * Wikilinks + renderLinkPreview. Kept out of markdown-preview.test.tsx (whose
10
+ * mermaid/Shiki async tests perturb later remark-transform renders in the same
11
+ * jsdom module). The wikilink resolved case (a remark-plugin-inserted link) is
12
+ * asserted FIRST in this clean module. Features verified here; cross-theme pixel
13
+ * sweep owed. (Transclusion has its own file — its nested Streamdown is even more
14
+ * env-sensitive.)
15
+ */
16
+
17
+ describe("MarkdownPreview — wikilinks (resolveWikilink)", () => {
18
+ it("rewrites [[target#anchor|alias]] to a resolved <a>, passing the anchor", async () => {
19
+ const calls: Array<{ target: string; anchor?: string }> = [];
20
+ render(
21
+ <MarkdownPreview
22
+ resolveWikilink={(target, opts) => {
23
+ calls.push({ target, anchor: opts.anchor });
24
+ // Absolute URL: relative hrefs are stripped by Streamdown's harden
25
+ // sanitizer (the same constraint resolveUrl documents).
26
+ return target === "Page" ? `https://wiki.test/${target}` : null;
27
+ }}
28
+ >{`See [[Page#intro|the page]] for more.`}</MarkdownPreview>,
29
+ );
30
+ const link = await screen.findByRole("link", {}, { timeout: 5000 });
31
+ expect(link).toHaveAttribute("href", "https://wiki.test/Page");
32
+ expect(link).toHaveTextContent("the page");
33
+ expect(calls).toContainEqual({ target: "Page", anchor: "intro" });
34
+ });
35
+
36
+ it("leaves an UNresolvable wikilink as literal text (never a broken link)", async () => {
37
+ render(
38
+ <MarkdownPreview resolveWikilink={() => null}>{`A [[Ghost]] reference.`}</MarkdownPreview>,
39
+ );
40
+ await waitFor(() => expect(screen.getByText(/A/)).toBeInTheDocument());
41
+ expect(screen.getByText(/\[\[Ghost\]\]/)).toBeInTheDocument();
42
+ expect(screen.queryByRole("link")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("MarkdownPreview — renderLinkPreview", () => {
47
+ it("wraps each rendered anchor via the consumer hook", async () => {
48
+ render(
49
+ <MarkdownPreview
50
+ renderLinkPreview={(href, children) => (
51
+ <span data-testid="lp" data-href={href}>
52
+ {children}
53
+ </span>
54
+ )}
55
+ >{`A [link](https://example.com/page) here.`}</MarkdownPreview>,
56
+ );
57
+ const wrap = await screen.findByTestId("lp");
58
+ expect(wrap).toHaveAttribute("data-href", "https://example.com/page");
59
+ expect(wrap.querySelector("a")).not.toBeNull();
60
+ });
61
+ });
@@ -0,0 +1,33 @@
1
+ import { render, screen, waitFor, cleanup } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { MarkdownPreview } from "./markdown-preview";
5
+
6
+ afterEach(cleanup);
7
+
8
+ /*
9
+ * Transclusion is the most jsdom-sensitive render in the suite: the resolved path
10
+ * mounts a NESTED Streamdown inside the outer one. In a multi-render file the
11
+ * nested mount is perturbed by prior Streamdown renders (an env-only gremlin — the
12
+ * feature renders correctly in the browser and in isolation). So transclusion gets
13
+ * its OWN file with the resolved (nested-render) case asserted FIRST in a clean
14
+ * module context. Six-theme pixel sweep still owed.
15
+ */
16
+ describe("MarkdownPreview — transclusion (resolveTransclusion)", () => {
17
+ it("embeds resolved markdown in a labelled figure", async () => {
18
+ render(
19
+ <MarkdownPreview
20
+ resolveTransclusion={(target) => (target === "Note" ? "Embedded **fact**." : null)}
21
+ >{`![[Note#section]]`}</MarkdownPreview>,
22
+ );
23
+ const fig = await screen.findByTestId("transclusion-block", undefined, { timeout: 5000 });
24
+ expect(fig).toHaveAttribute("aria-label", "Embedded: Note#section");
25
+ expect(fig).toHaveTextContent("fact");
26
+ });
27
+
28
+ it("renders an unresolvable transclusion as literal text", async () => {
29
+ render(<MarkdownPreview resolveTransclusion={() => null}>{`![[Gone]]`}</MarkdownPreview>);
30
+ await waitFor(() => expect(screen.getByText(/!\[\[Gone\]\]/)).toBeInTheDocument());
31
+ expect(screen.queryByTestId("transclusion-block")).toBeNull();
32
+ });
33
+ });
@@ -0,0 +1,108 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Pin } from "lucide-react";
3
+ import { expect, within } from "storybook/test";
4
+ import { MarkdownPreview } from "./markdown-preview";
5
+
6
+ const meta = {
7
+ title: "Editor/MarkdownPreview",
8
+ component: MarkdownPreview,
9
+ tags: ["autodocs"],
10
+ parameters: {
11
+ layout: "padded",
12
+ docs: {
13
+ description: {
14
+ component:
15
+ "Renders markdown to REAL @brand components (Streamdown + a branded components " +
16
+ "map): `#` → Heading, link → Link, table → Table, `---` → Separator, and the brand " +
17
+ "directives `:::card` / `:::callout` / `::metric` / `:::timeline` → Card / Alert / " +
18
+ "MetricBlock / Timeline. Uses the SAME shared remark pipeline as the Milkdown editor. " +
19
+ "Unknown directives render an explicit error block instead of disappearing.",
20
+ },
21
+ },
22
+ },
23
+ } satisfies Meta<typeof MarkdownPreview>;
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ const DOC = `---
29
+ title: Migration Plan
30
+ status: review
31
+ ---
32
+
33
+ # Migration Plan
34
+
35
+ Move the reporting estate to the new platform. **Scope is frozen.**
36
+
37
+ :::callout{type="warning" title="Missing component"}
38
+ This section needs a component that does not exist yet — track it as a gap.
39
+ :::
40
+
41
+ ::metric{label="Migration scope" value="1,245" description="frozen report candidates" delta="+12%"}
42
+
43
+ :::card{title="Customer objective"}
44
+ Consolidate 9 legacy dashboards into a single governed app.
45
+ :::
46
+
47
+ ## Timeline
48
+
49
+ :::timeline
50
+ - (done) Draft
51
+ - (active) Review
52
+ - (pending) Publish
53
+ :::
54
+
55
+ | Phase | Owner |
56
+ | ------ | ------ |
57
+ | Draft | Manuel |
58
+ | Review | Team |
59
+ `;
60
+
61
+ export const BrandDialect: Story = {
62
+ args: { children: DOC },
63
+ play: async ({ canvasElement }) => {
64
+ const canvas = within(canvasElement);
65
+ await expect(
66
+ await canvas.findByRole("heading", { level: 1, name: "Migration Plan" }, { timeout: 8000 }),
67
+ ).toBeVisible();
68
+ await expect(canvas.getByText("Customer objective")).toBeVisible();
69
+ await expect(canvas.getByText("frozen report candidates")).toBeVisible();
70
+ },
71
+ };
72
+
73
+ export const UnknownBlock: Story = {
74
+ name: "Unknown directive → error",
75
+ args: { children: `:::diagram\nnot mapped yet\n:::` },
76
+ };
77
+
78
+ /**
79
+ * `headingActions` — hover affordances beside each heading (the workbench
80
+ * uses it to pin sections as snippet favorites). The slot is revealed on
81
+ * heading hover/focus and stays visible while it contains a pressed toggle.
82
+ */
83
+ export const HeadingActions: Story = {
84
+ args: {
85
+ children: `# Migration Plan\n\nIntro paragraph.\n\n## Timeline\n\nPhase one starts in June.`,
86
+ headingActions: (h) => (
87
+ <button
88
+ type="button"
89
+ aria-label={`Pin section “${h.text}”`}
90
+ aria-pressed={h.text === "Timeline"}
91
+ className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
92
+ >
93
+ <Pin className="size-3.5" aria-hidden="true" />
94
+ </button>
95
+ ),
96
+ },
97
+ play: async ({ canvasElement }) => {
98
+ const canvas = within(canvasElement);
99
+ await expect(
100
+ await canvas.findByRole("button", { name: "Pin section “Timeline”" }, { timeout: 8000 }),
101
+ ).toBeInTheDocument();
102
+ // The pressed pin stays visible without hover (working-memory grammar).
103
+ await expect(canvas.getByRole("button", { name: "Pin section “Timeline”" })).toHaveAttribute(
104
+ "aria-pressed",
105
+ "true",
106
+ );
107
+ },
108
+ };
@@ -0,0 +1,411 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { MarkdownPreview } from "./markdown-preview";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("MarkdownPreview", () => {
9
+ it("renders standard markdown via branded primitives", async () => {
10
+ render(
11
+ <MarkdownPreview>{`# Objective\n\nMigrate **reports** to the platform.\n\n- one\n- two`}</MarkdownPreview>,
12
+ );
13
+ await waitFor(() =>
14
+ expect(screen.getByRole("heading", { level: 1, name: "Objective" })).toBeInTheDocument(),
15
+ );
16
+ expect(screen.getByRole("list").tagName).toBe("UL");
17
+ });
18
+
19
+ it("renders a :::card directive as a Card", async () => {
20
+ render(
21
+ <MarkdownPreview>{`:::card{title="Customer Objective"}\nExplain the goal here.\n:::`}</MarkdownPreview>,
22
+ );
23
+ await waitFor(() => expect(screen.getByText("Customer Objective")).toBeInTheDocument());
24
+ expect(screen.getByText("Explain the goal here.")).toBeInTheDocument();
25
+ });
26
+
27
+ it("renders a :::callout directive as an Alert", async () => {
28
+ render(
29
+ <MarkdownPreview>{`:::callout{type="warning" title="Heads up"}\nMissing component.\n:::`}</MarkdownPreview>,
30
+ );
31
+ await waitFor(() => expect(screen.getByText("Heads up")).toBeInTheDocument());
32
+ expect(screen.getByRole("alert")).toBeInTheDocument();
33
+ });
34
+
35
+ it("does not make the :::callout title a document heading (#21 heading-order)", async () => {
36
+ render(
37
+ <MarkdownPreview>{`# Doc\n\n:::callout{type="warning" title="Heads up"}\nMissing component.\n:::`}</MarkdownPreview>,
38
+ );
39
+ // The title text is present...
40
+ await waitFor(() => expect(screen.getByText("Heads up")).toBeInTheDocument());
41
+ // ...but NOT as a heading (it would otherwise jump h1 -> h5 in the outline).
42
+ expect(screen.queryByRole("heading", { name: "Heads up" })).toBeNull();
43
+ });
44
+
45
+ it("renders a ::metric leaf directive as a MetricBlock", async () => {
46
+ render(
47
+ <MarkdownPreview>{`::metric{label="Scope" value="1,245" description="reports"}`}</MarkdownPreview>,
48
+ );
49
+ await waitFor(() => expect(screen.getByText("1,245")).toBeInTheDocument());
50
+ expect(screen.getByText("Scope")).toBeInTheDocument();
51
+ });
52
+
53
+ it("renders a :::timeline directive as a Timeline", async () => {
54
+ render(
55
+ <MarkdownPreview>{`:::timeline\n- (done) Draft\n- (active) Review\n- (pending) Publish\n:::`}</MarkdownPreview>,
56
+ );
57
+ await waitFor(() => expect(screen.getByText("Draft")).toBeInTheDocument());
58
+ expect(screen.getByText("Review")).toBeInTheDocument();
59
+ expect(screen.getByText("Publish")).toBeInTheDocument();
60
+ });
61
+
62
+ it("surfaces an unknown directive instead of dropping it", async () => {
63
+ render(<MarkdownPreview>{`:::mystery\nbody\n:::`}</MarkdownPreview>);
64
+ await waitFor(() => expect(screen.getByText(/Unknown block: mystery/)).toBeInTheDocument());
65
+ });
66
+ });
67
+
68
+ describe("MarkdownPreview — sourcepos + annotations (#L18)", () => {
69
+ it("stamps data-sourcepos on block elements", async () => {
70
+ render(<MarkdownPreview>{`# Title\n\nA paragraph.`}</MarkdownPreview>);
71
+ await waitFor(() =>
72
+ expect(screen.getByRole("heading", { level: 1 })).toHaveAttribute("data-sourcepos", "1:1"),
73
+ );
74
+ expect(screen.getByText("A paragraph.")).toHaveAttribute("data-sourcepos", "3:3");
75
+ });
76
+
77
+ it("washes annotated blocks and renders the removed marker", async () => {
78
+ render(
79
+ <MarkdownPreview
80
+ annotations={[
81
+ { kind: "added", startLine: 3, endLine: 3 },
82
+ { kind: "removed-before", startLine: 1, endLine: 1, removedCount: 2 },
83
+ ]}
84
+ >{`# Title\n\nNew paragraph.`}</MarkdownPreview>,
85
+ );
86
+ await waitFor(() => expect(screen.getByText("New paragraph.")).toBeInTheDocument());
87
+ const washed = screen.getByText("New paragraph.").closest("[data-annotation]");
88
+ expect(washed).not.toBeNull();
89
+ expect(washed).toHaveAttribute("data-annotation", "added");
90
+ expect(screen.getByRole("note", { name: /2 lines removed/ })).toBeInTheDocument();
91
+ });
92
+
93
+ it("shifts annotation lines when frontmatter is stripped", async () => {
94
+ const md = `---\ntitle: X\n---\n# Title\n\nBody line.`;
95
+ render(
96
+ <MarkdownPreview annotations={[{ kind: "added", startLine: 6, endLine: 6 }]}>
97
+ {md}
98
+ </MarkdownPreview>,
99
+ );
100
+ await waitFor(() => expect(screen.getByText("Body line.")).toBeInTheDocument());
101
+ expect(screen.getByText("Body line.").closest("[data-annotation]")).not.toBeNull();
102
+ });
103
+ });
104
+
105
+ describe("MarkdownPreview — in-document search (searchTerm/activeSearchLine)", () => {
106
+ it("washes the block containing the active search line", async () => {
107
+ render(
108
+ <MarkdownPreview searchTerm="paragraph" activeSearchLine={3}>
109
+ {`# Title\n\nA paragraph.\n\nAnother one.`}
110
+ </MarkdownPreview>,
111
+ );
112
+ await waitFor(() => expect(screen.getByText("A paragraph.")).toBeInTheDocument());
113
+ expect(screen.getByText("A paragraph.").closest("[data-search-active]")).not.toBeNull();
114
+ expect(screen.getByText("Another one.").closest("[data-search-active]")).toBeNull();
115
+ });
116
+
117
+ it("shifts the active line across stripped frontmatter", async () => {
118
+ const md = `---\ntitle: X\n---\n# Title\n\nBody line.`;
119
+ render(
120
+ <MarkdownPreview searchTerm="Body" activeSearchLine={6}>
121
+ {md}
122
+ </MarkdownPreview>,
123
+ );
124
+ await waitFor(() => expect(screen.getByText("Body line.")).toBeInTheDocument());
125
+ expect(screen.getByText("Body line.").closest("[data-search-active]")).not.toBeNull();
126
+ });
127
+
128
+ it("marks the active non-mermaid fence instead of wrapping it", async () => {
129
+ const { container } = render(
130
+ <MarkdownPreview searchTerm="const" activeSearchLine={2}>
131
+ {"```js\nconst x = 1;\n```"}
132
+ </MarkdownPreview>,
133
+ );
134
+ await waitFor(() => expect(container.querySelector("pre")).not.toBeNull());
135
+ expect(container.querySelector("pre")).toHaveAttribute("data-search-active");
136
+ });
137
+ });
138
+
139
+ describe("MarkdownPreview — headingActions slot", () => {
140
+ it("renders the action slot for each heading with text, level and line", async () => {
141
+ const seen: Array<{ level: number; text: string; line?: number }> = [];
142
+ render(
143
+ <MarkdownPreview
144
+ headingActions={(h) => {
145
+ seen.push(h);
146
+ return (
147
+ <button type="button" aria-label={`Pin ${h.text}`}>
148
+ pin
149
+ </button>
150
+ );
151
+ }}
152
+ >
153
+ {`# Alpha\n\nbody\n\n## Beta **bold**\n\nmore`}
154
+ </MarkdownPreview>,
155
+ );
156
+ await waitFor(() => expect(screen.getByRole("button", { name: "Pin Alpha" })).toBeTruthy());
157
+ expect(screen.getByRole("button", { name: "Pin Beta bold" })).toBeTruthy();
158
+ const alpha = seen.find((h) => h.text === "Alpha");
159
+ const beta = seen.find((h) => h.text === "Beta bold");
160
+ expect(alpha?.level).toBe(1);
161
+ expect(alpha?.line).toBe(1);
162
+ expect(beta?.level).toBe(2);
163
+ expect(beta?.line).toBe(5);
164
+ });
165
+
166
+ it("hands STRIPPED-coordinate lines (matches parseMarkdownOutline)", async () => {
167
+ const lines: Array<number | undefined> = [];
168
+ render(
169
+ <MarkdownPreview
170
+ headingActions={(h) => {
171
+ lines.push(h.line);
172
+ return <button type="button" aria-label={`a-${h.text}`} />;
173
+ }}
174
+ >
175
+ {`---\ntitle: X\n---\n# Alpha`}
176
+ </MarkdownPreview>,
177
+ );
178
+ await waitFor(() => expect(screen.getByRole("button", { name: "a-Alpha" })).toBeTruthy());
179
+ expect(lines).toContain(1);
180
+ });
181
+
182
+ it("renders headings unchanged when no slot is provided", async () => {
183
+ render(<MarkdownPreview>{`# Plain`}</MarkdownPreview>);
184
+ await waitFor(() =>
185
+ expect(screen.getByRole("heading", { level: 1, name: "Plain" })).toBeInTheDocument(),
186
+ );
187
+ expect(screen.queryByRole("button")).toBeNull();
188
+ });
189
+ });
190
+
191
+ describe("MarkdownPreview — resolveUrl (#L4)", () => {
192
+ it("rewrites image sources and link hrefs (before sanitization)", async () => {
193
+ render(
194
+ <MarkdownPreview
195
+ resolveUrl={(url, kind) =>
196
+ kind === "image" ? `data:image/png;base64,${btoa(url)}` : `https://app.test/repo/${url}`
197
+ }
198
+ >{`[doc](other.md)\n\n![shot](images/a.png)`}</MarkdownPreview>,
199
+ );
200
+ await waitFor(() => expect(screen.getByRole("link", { name: "doc" })).toBeInTheDocument());
201
+ expect(screen.getByRole("link", { name: "doc" })).toHaveAttribute(
202
+ "href",
203
+ "https://app.test/repo/other.md",
204
+ );
205
+ expect(screen.getByRole("img", { name: "shot" })).toHaveAttribute(
206
+ "src",
207
+ `data:image/png;base64,${btoa("images/a.png")}`,
208
+ );
209
+ });
210
+ });
211
+
212
+ describe("MarkdownPreview — code fences (highlight + chip + copy)", () => {
213
+ it("renders a language chip and a copy button on a fenced block", async () => {
214
+ const { container } = render(<MarkdownPreview>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>);
215
+ await waitFor(() => expect(container.querySelector("pre")).toBeInTheDocument());
216
+ expect(container.querySelector('[data-code-fence="ts"]')).not.toBeNull();
217
+ expect(screen.getByText("ts")).toBeInTheDocument();
218
+ // CopyButton (shared editor affordance) is wired to the fence text.
219
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
220
+ });
221
+
222
+ it("tokenizes the fence with CSS-variable-driven colors", async () => {
223
+ const { container } = render(<MarkdownPreview>{"```js\nconst x = 1;\n```"}</MarkdownPreview>);
224
+ // Shiki loads async; tokens land as styled spans referencing --md-code-*.
225
+ await waitFor(
226
+ () => {
227
+ const token = container.querySelector("pre code span[style]");
228
+ expect(token).not.toBeNull();
229
+ expect((token as HTMLElement).style.color).toContain("var(--md-code-");
230
+ },
231
+ { timeout: 10_000 },
232
+ );
233
+ });
234
+
235
+ it("renders a fence without a language tag as plain text (no chip)", async () => {
236
+ const { container } = render(<MarkdownPreview>{"```\nplain text\n```"}</MarkdownPreview>);
237
+ await waitFor(() => expect(container.querySelector("pre")).toBeInTheDocument());
238
+ expect(container.querySelector('[data-code-fence=""]')).not.toBeNull();
239
+ expect(screen.getByText("plain text")).toBeInTheDocument();
240
+ });
241
+
242
+ it("keeps the fence addressable by source line (data-sourcepos)", async () => {
243
+ const { container } = render(
244
+ <MarkdownPreview>{"intro\n\n```ts\nconst x = 1;\n```"}</MarkdownPreview>,
245
+ );
246
+ await waitFor(() => expect(container.querySelector("pre")).toBeInTheDocument());
247
+ expect(container.querySelector("[data-code-fence]")).toHaveAttribute("data-sourcepos", "3:5");
248
+ });
249
+ });
250
+
251
+ describe("MarkdownPreview — mermaid fences (#L1)", () => {
252
+ it("routes ```mermaid fences to the MermaidDiagram component", async () => {
253
+ render(<MarkdownPreview>{"```mermaid\ngraph TD; A-->B\n```"}</MarkdownPreview>);
254
+ await waitFor(() => expect(screen.getByTestId("mermaid-diagram")).toBeInTheDocument());
255
+ });
256
+
257
+ it("keeps non-mermaid fences as plain pre blocks", async () => {
258
+ const { container } = render(<MarkdownPreview>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>);
259
+ await waitFor(() => expect(container.querySelector("pre")).toBeInTheDocument());
260
+ expect(container.querySelector('[data-testid="mermaid-diagram"]')).toBeNull();
261
+ });
262
+ });
263
+
264
+ describe("MarkdownPreview — colon false-positives (#workbench live finding)", () => {
265
+ it("renders prose colons literally instead of unknown-block errors", async () => {
266
+ render(
267
+ <MarkdownPreview>
268
+ {"Models: qwen3.5:9b default, qwen3:0.6b embeddings, profile 35b-a3b:4b."}
269
+ </MarkdownPreview>,
270
+ );
271
+ await waitFor(() => expect(screen.getByText(/Models:/)).toBeInTheDocument());
272
+ expect(screen.getByText(/qwen3\.5:9b/)).toBeInTheDocument();
273
+ expect(screen.getByText(/qwen3:0\.6b embeddings/)).toBeInTheDocument();
274
+ expect(screen.queryByText(/Unknown block/)).toBeNull();
275
+ });
276
+
277
+ it("restores unknown leaf directives (::name) as literal text", async () => {
278
+ render(<MarkdownPreview>{"before\n\n::nope\n\nafter"}</MarkdownPreview>);
279
+ await waitFor(() => expect(screen.getByText("after")).toBeInTheDocument());
280
+ expect(screen.getByText("::nope")).toBeInTheDocument();
281
+ expect(screen.queryByText(/Unknown block/)).toBeNull();
282
+ });
283
+
284
+ it("still flags unknown CONTAINER directives as explicit errors", async () => {
285
+ render(<MarkdownPreview>{":::definitelynotreal\ncontent\n:::"}</MarkdownPreview>);
286
+ await waitFor(() =>
287
+ expect(screen.getByText(/Unknown block: definitelynotreal/)).toBeInTheDocument(),
288
+ );
289
+ });
290
+ });
291
+
292
+ describe("MarkdownPreview — extensions seam (#L: register without forking the engine)", () => {
293
+ it("renders a registered CONTAINER directive via the consumer's renderer", async () => {
294
+ render(
295
+ <MarkdownPreview
296
+ extensions={{
297
+ directives: [
298
+ {
299
+ name: "decision",
300
+ kinds: ["container"],
301
+ render: ({ attributes, children }) => (
302
+ <div data-testid="decision" data-status={attributes.status}>
303
+ {children}
304
+ </div>
305
+ ),
306
+ },
307
+ ],
308
+ }}
309
+ >{`:::decision{status="accepted"}\nWe will migrate.\n:::`}</MarkdownPreview>,
310
+ );
311
+ await waitFor(() => expect(screen.getByTestId("decision")).toBeInTheDocument());
312
+ expect(screen.getByTestId("decision")).toHaveAttribute("data-status", "accepted");
313
+ expect(screen.getByText("We will migrate.")).toBeInTheDocument();
314
+ });
315
+
316
+ it("renders a registered INLINE directive in the text flow", async () => {
317
+ render(
318
+ <MarkdownPreview
319
+ extensions={{
320
+ directives: [
321
+ {
322
+ name: "entity",
323
+ kinds: ["inline"],
324
+ render: ({ attributes, children }) => (
325
+ <mark data-testid="entity" data-kind={attributes.kind}>
326
+ {children}
327
+ </mark>
328
+ ),
329
+ },
330
+ ],
331
+ }}
332
+ >{`Owner :entity[Acme]{kind=org} ships it.`}</MarkdownPreview>,
333
+ );
334
+ await waitFor(() => expect(screen.getByTestId("entity")).toBeInTheDocument());
335
+ expect(screen.getByTestId("entity")).toHaveTextContent("Acme");
336
+ expect(screen.getByTestId("entity")).toHaveAttribute("data-kind", "org");
337
+ // Inline → no block wrapper: the chip lives INSIDE the paragraph (valid HTML).
338
+ expect(screen.getByTestId("entity").closest("p")).not.toBeNull();
339
+ expect(screen.queryByText(/Unknown block/)).toBeNull();
340
+ });
341
+
342
+ it("leaves an UNregistered inline prose colon as literal text", async () => {
343
+ render(
344
+ <MarkdownPreview
345
+ extensions={{
346
+ directives: [{ name: "entity", kinds: ["inline"], render: () => <span /> }],
347
+ }}
348
+ >{`Use :other[x] only when registered.`}</MarkdownPreview>,
349
+ );
350
+ await waitFor(() => expect(screen.getByText(/only when registered/)).toBeInTheDocument());
351
+ expect(screen.getByText(/:other\[x\]/)).toBeInTheDocument();
352
+ expect(screen.queryByText(/Unknown block/)).toBeNull();
353
+ });
354
+
355
+ it("renders a registered FENCE and keeps it addressable by source line", async () => {
356
+ const { container } = render(
357
+ <MarkdownPreview
358
+ extensions={{
359
+ fences: [
360
+ {
361
+ lang: "chart",
362
+ render: ({ source }) => <figure data-testid="chart">{source}</figure>,
363
+ },
364
+ ],
365
+ }}
366
+ >
367
+ {"intro\n\n```chart\nbars: 1,2,3\n```"}
368
+ </MarkdownPreview>,
369
+ );
370
+ await waitFor(() => expect(screen.getByTestId("chart")).toBeInTheDocument());
371
+ expect(screen.getByTestId("chart")).toHaveTextContent("bars: 1,2,3");
372
+ expect(container.querySelector("[data-sourcepos='3:5']")).not.toBeNull();
373
+ });
374
+
375
+ it("routes a ```calc fence to CalcBlock via the evaluate sugar", async () => {
376
+ render(
377
+ <MarkdownPreview
378
+ evaluate={() => ({
379
+ results: [{ line: 1, tokens: [], value: { kind: "number", raw: 42, display: "42" } }],
380
+ total: { kind: "number", raw: 42, display: "42" },
381
+ })}
382
+ >
383
+ {"```calc\n40 + 2\n```"}
384
+ </MarkdownPreview>,
385
+ );
386
+ await waitFor(() => expect(screen.getByTestId("calc-block")).toBeInTheDocument());
387
+ });
388
+
389
+ it("routes an inline :calc[expr] to a CalcInline chip with the VERBATIM expression", async () => {
390
+ let seen = "";
391
+ render(
392
+ <MarkdownPreview
393
+ evaluate={(src) => {
394
+ seen = src;
395
+ return {
396
+ results: [
397
+ { line: 1, tokens: [], value: { kind: "currency", raw: 2720, display: "2,720 USD" } },
398
+ ],
399
+ };
400
+ }}
401
+ >{`Budget is :calc[85 USD * 32] this quarter.`}</MarkdownPreview>,
402
+ );
403
+ await waitFor(() => expect(screen.getByTestId("calc-inline")).toBeInTheDocument());
404
+ // The chip shows the result; the multiplication operator survived parsing
405
+ // (raw-label capture), so the consumer's evaluate saw the real expression.
406
+ expect(screen.getByTestId("calc-inline")).toHaveTextContent("2,720 USD");
407
+ expect(seen).toBe("85 USD * 32");
408
+ // Inline → lives inside the paragraph, no block wrapper.
409
+ expect(screen.getByTestId("calc-inline").closest("p")).not.toBeNull();
410
+ });
411
+ });