@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,83 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DocumentOutline (#L6) — the quiet table-of-contents rail for a markdown
5
+ * document. Pure presentation: pass `items` from `parseMarkdownOutline` /
6
+ * `useMarkdownOutline`, drive `activeId` from your scroll observer, and handle
7
+ * `onSelect` (e.g. scroll the matching `data-sourcepos` block into view).
8
+ */
9
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
10
+ import { forwardRef, useMemo, type HTMLAttributes, type ReactNode } from "react";
11
+
12
+ import { parseMarkdownOutline, type MarkdownOutlineItem } from "./markdown-outline";
13
+
14
+ /** Memoized outline of a markdown source. */
15
+ export function useMarkdownOutline(markdown: string): MarkdownOutlineItem[] {
16
+ return useMemo(() => parseMarkdownOutline(markdown), [markdown]);
17
+ }
18
+
19
+ export interface DocumentOutlineProps extends Omit<HTMLAttributes<HTMLElement>, "onSelect"> {
20
+ items: MarkdownOutlineItem[];
21
+ /** The outline item currently in view. */
22
+ activeId?: string;
23
+ onSelect?: (item: MarkdownOutlineItem) => void;
24
+ /**
25
+ * Per-entry hover affordances (pin/copy-link…), rendered at the row end.
26
+ * Revealed on row hover/focus; stays visible while it contains a pressed
27
+ * toggle (`aria-pressed="true"`).
28
+ */
29
+ itemActions?: (item: MarkdownOutlineItem) => ReactNode;
30
+ }
31
+
32
+ export const DocumentOutline = forwardRef<HTMLElement, DocumentOutlineProps>(
33
+ function DocumentOutline({ items, activeId, onSelect, itemActions, className, ...props }, ref) {
34
+ const minLevel = items.reduce<number>((min, it) => Math.min(min, it.level), 6);
35
+ return (
36
+ <nav
37
+ ref={ref}
38
+ aria-label="Document outline"
39
+ className={cn("text-body", className)}
40
+ {...props}
41
+ >
42
+ {/* TOC grammar: a structural hairline rail; each entry overlays it with a
43
+ 2px segment — primary for the active heading, visible on hover — so
44
+ position-in-document reads at a glance (the accent-rail channel from
45
+ the separation grammar, not a generic list). */}
46
+ <ul className="m-0 list-none border-s border-border p-0">
47
+ {items.map((item) => {
48
+ const actions = itemActions?.(item);
49
+ return (
50
+ <li key={item.id} className={cn(actions && "group/outline-item relative")}>
51
+ <button
52
+ type="button"
53
+ aria-current={item.id === activeId ? "true" : undefined}
54
+ onClick={() => onSelect?.(item)}
55
+ className={cn(
56
+ "-ms-px block w-full truncate border-s-2 py-1 pe-2 text-start text-caption",
57
+ "transition-colors duration-fast ease-standard motion-reduce:transition-none",
58
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
59
+ item.id === activeId
60
+ ? "border-s-primary font-medium text-foreground"
61
+ : "border-s-transparent text-muted-foreground hover:border-s-border-strong hover:text-foreground",
62
+ actions && "pe-8",
63
+ )}
64
+ style={{ paddingInlineStart: `${(item.level - minLevel) * 0.875 + 0.75}rem` }}
65
+ >
66
+ {item.text}
67
+ </button>
68
+ {actions ? (
69
+ <span className="absolute end-0.5 top-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-fast ease-standard focus-within:opacity-100 group-hover/outline-item:opacity-100 has-[[aria-pressed=true]]:opacity-100 motion-reduce:transition-none">
70
+ {actions}
71
+ </span>
72
+ ) : null}
73
+ </li>
74
+ );
75
+ })}
76
+ </ul>
77
+ {items.length === 0 ? (
78
+ <p className="px-2 py-1 text-caption text-muted-foreground">No headings yet.</p>
79
+ ) : null}
80
+ </nav>
81
+ );
82
+ },
83
+ );
@@ -0,0 +1,2 @@
1
+ export { parseMarkdownOutline, type MarkdownOutlineItem } from "./markdown-outline";
2
+ export { DocumentOutline, useMarkdownOutline, type DocumentOutlineProps } from "./document-outline";
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseMarkdownOutline } from "./markdown-outline";
4
+
5
+ const DOC = `---
6
+ title: Plan
7
+ ---
8
+ # Reporting migration plan
9
+
10
+ Intro text.
11
+
12
+ ## Phase overview
13
+
14
+ \`\`\`md
15
+ # not a heading (fenced)
16
+ \`\`\`
17
+
18
+ ### Discovery & *catalog*
19
+
20
+ ## Phase overview
21
+ `;
22
+
23
+ describe("parseMarkdownOutline", () => {
24
+ it("extracts headings with levels and stripped-source lines", () => {
25
+ const items = parseMarkdownOutline(DOC);
26
+ expect(items.map((i) => [i.level, i.text])).toEqual([
27
+ [1, "Reporting migration plan"],
28
+ [2, "Phase overview"],
29
+ [3, "Discovery & catalog"],
30
+ [2, "Phase overview"],
31
+ ]);
32
+ // Line 1 of the STRIPPED body is the H1 (frontmatter removed).
33
+ expect(items[0]!.line).toBe(1);
34
+ });
35
+
36
+ it("skips headings inside fenced code blocks", () => {
37
+ expect(parseMarkdownOutline(DOC).some((i) => i.text.includes("not a heading"))).toBe(false);
38
+ });
39
+
40
+ it("dedupes repeated slugs", () => {
41
+ const ids = parseMarkdownOutline(DOC).map((i) => i.id);
42
+ expect(ids).toContain("phase-overview");
43
+ expect(ids).toContain("phase-overview-1");
44
+ });
45
+
46
+ it("returns [] for heading-free documents", () => {
47
+ expect(parseMarkdownOutline("just text\n\nmore text")).toEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Markdown outline extraction (#L6) — the pure half of `DocumentOutline`.
3
+ *
4
+ * `parseMarkdownOutline` walks ATX headings (`#` … `######`) outside fenced
5
+ * code blocks and returns the document outline. Line numbers are 1-based and
6
+ * relative to the frontmatter-STRIPPED body — the same coordinate space as the
7
+ * `data-sourcepos` attributes `MarkdownPreview` stamps on rendered blocks, so
8
+ * outline → block lookup is a direct equality on the start line.
9
+ */
10
+ import { parseFrontmatter } from "../lib/markdown/frontmatter";
11
+ import { plainText, slugifyHeading, uniqueSlug } from "../lib/markdown/slugify";
12
+
13
+ export interface MarkdownOutlineItem {
14
+ /** Stable slug (GitHub-style, deduped with `-n` suffixes). */
15
+ id: string;
16
+ /** Plain heading text (inline markdown stripped). */
17
+ text: string;
18
+ /** Heading level 1–6. */
19
+ level: 1 | 2 | 3 | 4 | 5 | 6;
20
+ /** 1-based line in the frontmatter-stripped source (matches `data-sourcepos`). */
21
+ line: number;
22
+ }
23
+
24
+ const HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
25
+ const FENCE_RE = /^(```|~~~)/;
26
+
27
+ export function parseMarkdownOutline(markdown: string): MarkdownOutlineItem[] {
28
+ let body = markdown;
29
+ try {
30
+ body = parseFrontmatter(markdown).content;
31
+ } catch {
32
+ // Transient-invalid frontmatter while typing — outline the raw source.
33
+ }
34
+ const lines = body.split("\n");
35
+ const items: MarkdownOutlineItem[] = [];
36
+ const used = new Map<string, number>();
37
+ let inFence = false;
38
+
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i]!;
41
+ if (FENCE_RE.test(line.trimStart())) {
42
+ inFence = !inFence;
43
+ continue;
44
+ }
45
+ if (inFence) continue;
46
+ const match = HEADING_RE.exec(line);
47
+ if (!match) continue;
48
+ const text = plainText(match[2] ?? "");
49
+ if (!text) continue;
50
+ const base = slugifyHeading(text);
51
+ const id = uniqueSlug(base, used);
52
+ items.push({
53
+ id,
54
+ text,
55
+ level: match[1]!.length as MarkdownOutlineItem["level"],
56
+ line: i + 1,
57
+ });
58
+ }
59
+ return items;
60
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CodeFence — the highlighted non-mermaid code fence inside MarkdownPreview.
5
+ *
6
+ * Highlighting rides on the SAME engine the rest of the workspace already
7
+ * ships (`@streamdown/code`, Streamdown's shiki plugin — cached highlighters,
8
+ * sync-after-first-tokenize), but the theme is a shiki **CSS-variables theme**:
9
+ * every token color resolves to a `var(--md-code-*)` reference that this
10
+ * component maps onto the semantic tokens below. One theme, correct in every
11
+ * `data-theme` (light, dark, …) — no per-theme shiki theme, no raw
12
+ * colors, and a runtime theme switch recolors already-tokenized code for free.
13
+ *
14
+ * Until shiki finishes loading (or for a fence with no language tag) the raw
15
+ * fence text renders as before — highlighting is a progressive enhancement.
16
+ */
17
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
18
+ import { code as codeHighlighter } from "@streamdown/code";
19
+ import {
20
+ useEffect,
21
+ useRef,
22
+ useState,
23
+ type CSSProperties,
24
+ type HTMLAttributes,
25
+ type ReactNode,
26
+ } from "react";
27
+ import type { BundledLanguage, ThemedToken, TokensResult } from "shiki";
28
+ import { createCssVariablesTheme } from "shiki";
29
+
30
+ import { CopyButton } from "../copy-button";
31
+
32
+ /** Extract the fence language from react-markdown's `language-*` className. */
33
+ export function fenceLanguage(className?: string): string | undefined {
34
+ return /\blanguage-([\w+#.-]+)\b/.exec(className ?? "")?.[1];
35
+ }
36
+
37
+ /**
38
+ * One shiki theme for BOTH slots (the plugin API is [light, dark]): colors are
39
+ * pure CSS-variable references, so the active `data-theme` decides the actual
40
+ * values — both slots resolve identically by construction.
41
+ */
42
+ const cssVariablesTheme = createCssVariablesTheme({
43
+ name: "brand-tokens",
44
+ variablePrefix: "--md-code-",
45
+ fontStyle: true,
46
+ });
47
+ const SHIKI_THEMES: [typeof cssVariablesTheme, typeof cssVariablesTheme] = [
48
+ cssVariablesTheme,
49
+ cssVariablesTheme,
50
+ ];
51
+
52
+ /**
53
+ * The `--md-code-*` seams mapped onto semantic tokens (scoped to the fence, so
54
+ * nothing leaks into `themes.css`). Chart tokens carry the categorical hues —
55
+ * they are the only themed accent ramp guaranteed to exist in every theme.
56
+ */
57
+ const SHIKI_TOKEN_VARS = cn(
58
+ "[--md-code-foreground:var(--foreground)]",
59
+ "[--md-code-background:transparent]",
60
+ "[--md-code-token-comment:var(--muted-foreground)]",
61
+ "[--md-code-token-constant:var(--chart-1)]",
62
+ "[--md-code-token-function:var(--chart-3)]",
63
+ "[--md-code-token-keyword:var(--chart-4)]",
64
+ "[--md-code-token-link:var(--primary)]",
65
+ "[--md-code-token-parameter:var(--chart-5)]",
66
+ "[--md-code-token-punctuation:var(--muted-foreground)]",
67
+ "[--md-code-token-string-expression:var(--chart-2)]",
68
+ "[--md-code-token-string:var(--chart-2)]",
69
+ );
70
+
71
+ // Shiki encodes font style as bitflags: 1 = italic, 2 = bold, 4 = underline.
72
+ const hasFontFlag = (fontStyle: number | undefined, flag: number) =>
73
+ ((fontStyle ?? 0) & flag) === flag;
74
+
75
+ function tokenStyle(token: ThemedToken): CSSProperties {
76
+ return {
77
+ // htmlStyle.color carries the theme var; token.color is the fallback path.
78
+ color: (token.htmlStyle as Record<string, string> | undefined)?.color ?? token.color,
79
+ fontStyle: hasFontFlag(token.fontStyle, 1) ? "italic" : undefined,
80
+ fontWeight: hasFontFlag(token.fontStyle, 2) ? "bold" : undefined,
81
+ textDecoration: hasFontFlag(token.fontStyle, 4) ? "underline" : undefined,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Tokenize via the shared plugin. Returns `null` until the highlighter is
87
+ * ready (first render of a language) — cached fences resolve synchronously.
88
+ */
89
+ function useHighlightedTokens(codeText: string, language: string | undefined) {
90
+ const [result, setResult] = useState<TokensResult | null>(null);
91
+ const keyRef = useRef({ codeText, language });
92
+
93
+ // Invalidate stale tokens synchronously during render (no flash of the
94
+ // previous fence's tokens when the source changes).
95
+ if (keyRef.current.codeText !== codeText || keyRef.current.language !== language) {
96
+ keyRef.current = { codeText, language };
97
+ setResult(null);
98
+ }
99
+
100
+ useEffect(() => {
101
+ if (!language) return undefined; // no language tag — keep the plain text
102
+ let cancelled = false;
103
+ const sync = codeHighlighter.highlight(
104
+ // Unknown languages fall back to "text" inside the plugin.
105
+ { code: codeText, language: language as BundledLanguage, themes: SHIKI_THEMES },
106
+ (r) => {
107
+ if (!cancelled) setResult(r);
108
+ },
109
+ );
110
+ if (sync && !cancelled) setResult(sync);
111
+ return () => {
112
+ cancelled = true;
113
+ };
114
+ }, [codeText, language]);
115
+
116
+ return result;
117
+ }
118
+
119
+ export interface CodeFenceProps extends HTMLAttributes<HTMLElement> {
120
+ /** Raw fence text (trailing newline already stripped). */
121
+ codeText: string;
122
+ /** The fence's language tag (` ```ts `), if any. */
123
+ language?: string;
124
+ /** This fence contains the active in-document search hit. */
125
+ searchActive?: boolean;
126
+ /** Fallback content (the un-highlighted fence) while shiki loads. */
127
+ children?: ReactNode;
128
+ }
129
+
130
+ export function CodeFence({
131
+ codeText,
132
+ language,
133
+ searchActive,
134
+ className,
135
+ children,
136
+ ...props
137
+ }: CodeFenceProps) {
138
+ const tokens = useHighlightedTokens(codeText, language)?.tokens ?? null;
139
+
140
+ return (
141
+ <div
142
+ data-code-fence={language ?? ""}
143
+ className={cn("group/code-fence relative my-3", className)}
144
+ {...props}
145
+ >
146
+ <pre
147
+ data-search-active={searchActive ? "" : undefined}
148
+ className={cn(
149
+ "!my-0 overflow-x-auto rounded-md p-3 font-mono text-code",
150
+ SHIKI_TOKEN_VARS,
151
+ searchActive ? "bg-primary/10" : "bg-surface-muted",
152
+ )}
153
+ >
154
+ {tokens ? (
155
+ <code>
156
+ {tokens.map((line, lineIdx) => (
157
+ // Lines are positionally stable for a given source string (the
158
+ // whole list is rebuilt when `codeText` changes).
159
+ <span key={`line-${lineIdx}`} className="block">
160
+ {line.length === 0
161
+ ? "\n"
162
+ : line.map((token, tokenIdx) => (
163
+ <span key={`token-${lineIdx}-${tokenIdx}`} style={tokenStyle(token)}>
164
+ {token.content}
165
+ </span>
166
+ ))}
167
+ </span>
168
+ ))}
169
+ </code>
170
+ ) : (
171
+ children
172
+ )}
173
+ </pre>
174
+
175
+ <div className="absolute end-2 top-2 flex items-center gap-1">
176
+ <CopyButton
177
+ value={codeText}
178
+ label={false}
179
+ size="icon-sm"
180
+ className="opacity-0 transition-opacity duration-fast ease-standard focus-visible:opacity-100 group-hover/code-fence:opacity-100 motion-reduce:transition-none"
181
+ />
182
+ {language ? (
183
+ <span
184
+ aria-hidden="true"
185
+ className="pointer-events-none select-none rounded-sm bg-surface-muted px-1.5 py-0.5 font-mono text-meta text-muted-foreground"
186
+ >
187
+ {language}
188
+ </span>
189
+ ) : null}
190
+ </div>
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ MarkdownPreview,
3
+ type MarkdownPreviewProps,
4
+ type MarkdownHeadingInfo,
5
+ type MarkdownUrlKind,
6
+ type MarkdownAnnotation,
7
+ type MarkdownAnnotationKind,
8
+ } from "./markdown-preview";
@@ -0,0 +1,203 @@
1
+ // KaTeX styling — consumers must import this once at their app entry. The story
2
+ // imports it so the `Math` stories render correctly in Storybook.
3
+ import "katex/dist/katex.min.css";
4
+
5
+ import type { Meta, StoryObj } from "@storybook/react-vite";
6
+ import { expect, within } from "storybook/test";
7
+
8
+ import { MarkdownPreview } from "./markdown-preview";
9
+ import type { CitationData } from "../markdown-academic/citations";
10
+
11
+ const meta = {
12
+ title: "Editor/MarkdownPreview/Academic",
13
+ component: MarkdownPreview,
14
+ tags: ["autodocs"],
15
+ parameters: {
16
+ layout: "padded",
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "The opt-in **academic layer** for `MarkdownPreview`: branded footnotes " +
21
+ "(`footnotes`), `$…$` / `$$…$$` math via KaTeX (`math`), Pandoc / Better-BibTeX " +
22
+ "citations + a generated bibliography (`resolveCitation`), and a generated `::toc` " +
23
+ "(`toc`). The BibTeX/CSL database, the math engine, and CSL formatting stay in the " +
24
+ "app — the library renders. Math requires the consumer to load KaTeX CSS once " +
25
+ '(`import "katex/dist/katex.min.css"`).',
26
+ },
27
+ },
28
+ },
29
+ } satisfies Meta<typeof MarkdownPreview>;
30
+
31
+ export default meta;
32
+ type Story = StoryObj<typeof meta>;
33
+
34
+ /* ------------------------------------------------------------------ */
35
+ /* A small in-app citation database (would be BibTeX/CSL in real apps). */
36
+ /* ------------------------------------------------------------------ */
37
+ const CITES: Record<string, CitationData> = {
38
+ knuth1984: {
39
+ author: "Knuth",
40
+ year: 1984,
41
+ title: "Literate Programming",
42
+ container: "The Computer Journal",
43
+ doi: "10.1093/comjnl/27.2.97",
44
+ },
45
+ dijkstra1968: {
46
+ author: "Dijkstra",
47
+ year: 1968,
48
+ title: "Go To Statement Considered Harmful",
49
+ container: "Communications of the ACM",
50
+ url: "https://dl.acm.org/doi/10.1145/362929.362947",
51
+ },
52
+ turing1936: {
53
+ author: "Turing",
54
+ year: 1936,
55
+ title: "On Computable Numbers",
56
+ container: "Proc. London Math. Soc.",
57
+ },
58
+ };
59
+ const resolveCitation = (key: string): CitationData | null => CITES[key] ?? null;
60
+
61
+ export const Footnotes: Story = {
62
+ args: {
63
+ footnotes: true,
64
+ children: `# Field notes
65
+
66
+ The estate ran on nine legacy dashboards.[^scope] Consolidation cut that to one.[^win]
67
+
68
+ Both numbers were verified against the warehouse before sign-off.[^scope]
69
+
70
+ [^scope]: Counted from the migration manifest — see the [scope sheet](https://example.com/scope).
71
+ [^win]: Post-launch audit, 2026-Q1.`,
72
+ },
73
+ play: async ({ canvasElement }) => {
74
+ const canvas = within(canvasElement);
75
+ await expect(
76
+ await canvas.findByRole("region", { name: "Footnotes" }, { timeout: 8000 }),
77
+ ).toBeVisible();
78
+ },
79
+ };
80
+
81
+ export const Math: Story = {
82
+ args: {
83
+ math: true,
84
+ children: `# Cost model
85
+
86
+ The unit cost is $c = \\frac{f + v\\,n}{n}$, fixed cost $f$ amortized over $n$ runs.
87
+
88
+ Total spend over the window:
89
+
90
+ $$
91
+ S = \\sum_{i=1}^{N} c_i n_i = f N + v \\sum_{i=1}^{N} n_i
92
+ $$
93
+
94
+ So the marginal cost approaches $v$ as $n \\to \\infty$.`,
95
+ },
96
+ play: async ({ canvasElement }) => {
97
+ const canvas = within(canvasElement);
98
+ await canvas.findByRole("heading", { name: "Cost model" }, { timeout: 8000 });
99
+ await expect(canvasElement.querySelectorAll('[role="math"]').length).toBeGreaterThan(0);
100
+ },
101
+ };
102
+
103
+ export const CitationsNumeric: Story = {
104
+ name: "Citations — numeric + bibliography",
105
+ args: {
106
+ resolveCitation,
107
+ children: `# Related work
108
+
109
+ Literate programming [@knuth1984] reframed source as exposition, building on the
110
+ structured-programming turn [@dijkstra1968]. The theoretical floor was set decades
111
+ earlier [@turing1936, p. 230]. See also [@knuth1984; @dijkstra1968].
112
+
113
+ ::bibliography`,
114
+ },
115
+ play: async ({ canvasElement }) => {
116
+ const canvas = within(canvasElement);
117
+ await expect(
118
+ await canvas.findByRole("region", { name: "References" }, { timeout: 8000 }),
119
+ ).toBeVisible();
120
+ },
121
+ };
122
+
123
+ export const CitationsAuthorYear: Story = {
124
+ name: "Citations — author-year",
125
+ args: {
126
+ resolveCitation,
127
+ citationStyle: "author-year",
128
+ children: `Knuth's literate programming [@knuth1984] inverts the usual order — see also
129
+ the earlier argument [-@dijkstra1968] for structure.
130
+
131
+ ::references`,
132
+ },
133
+ };
134
+
135
+ export const TableOfContents: Story = {
136
+ name: "Generated TOC",
137
+ args: {
138
+ toc: true,
139
+ children: `::toc
140
+
141
+ # Architecture
142
+
143
+ Overview prose.
144
+
145
+ ## Data layer
146
+
147
+ How storage works.
148
+
149
+ ## Service layer
150
+
151
+ How services compose.
152
+
153
+ # Operations
154
+
155
+ ### Monitoring
156
+
157
+ Dashboards and alerts.`,
158
+ },
159
+ play: async ({ canvasElement }) => {
160
+ const canvas = within(canvasElement);
161
+ const nav = await canvas.findByRole("navigation", { name: "Contents" }, { timeout: 8000 });
162
+ await expect(within(nav).getAllByRole("link").length).toBeGreaterThan(1);
163
+ },
164
+ };
165
+
166
+ /**
167
+ * Everything together — the shape of a short research note: a TOC, inline math,
168
+ * a footnote, citations, and a generated bibliography in one document.
169
+ */
170
+ export const AcademicPaper: Story = {
171
+ args: {
172
+ footnotes: true,
173
+ math: true,
174
+ toc: true,
175
+ resolveCitation,
176
+ children: `::toc
177
+
178
+ # Amortizing fixed cost
179
+
180
+ We model per-run cost as $c = \\frac{f + v n}{n}$, following the structured-cost
181
+ argument [@dijkstra1968].[^model] As $n \\to \\infty$, $c \\to v$.
182
+
183
+ $$
184
+ S = f N + v \\sum_{i=1}^{N} n_i
185
+ $$
186
+
187
+ ## Prior art
188
+
189
+ Literate exposition of such models traces to Knuth [@knuth1984].
190
+
191
+ ::bibliography
192
+
193
+ [^model]: Fixed cost $f$ is treated as constant across the window.`,
194
+ },
195
+ play: async ({ canvasElement }) => {
196
+ const canvas = within(canvasElement);
197
+ await expect(
198
+ await canvas.findByRole("navigation", { name: "Contents" }, { timeout: 8000 }),
199
+ ).toBeVisible();
200
+ await expect(canvas.getByRole("region", { name: "References" })).toBeVisible();
201
+ await expect(canvas.getByRole("region", { name: "Footnotes" })).toBeVisible();
202
+ },
203
+ };