@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,1541 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MarkdownPreview — renders markdown to REAL @brand components (not default HTML).
5
+ *
6
+ * Built on Streamdown (the same react-markdown + remark engine @elabs-ai/components-ai uses), with
7
+ * a branded `components` map: `#` → Heading, paragraph → Text, link → Link, list →
8
+ * List, table → @elabs-ai/components-ui Table, `---` → Separator, blockquote → Blockquote, and the
9
+ * `:::card`/`:::callout`/`::metric`/`:::timeline` directives → Card / Alert /
10
+ * MetricBlock / Timeline. The directive plugins come from the SHARED
11
+ * `buildMarkdownPlugins()` array, so the preview and the Milkdown editor parse the
12
+ * brand dialect identically. Unknown directives render an explicit error block.
13
+ *
14
+ * Five production seams (#L1 / #L4 / #L18 / #L-wikilink / #L-transclusion):
15
+ * - ```mermaid fences render through the branded `MermaidDiagram`;
16
+ * - `resolveUrl` rewrites image/link targets (private-repo assets, relative paths);
17
+ * - every block carries `data-sourcepos="start:end"` (1-based source lines), and an
18
+ * `annotations` prop washes changed blocks / marks removals — the "ghost diff";
19
+ * - `resolveWikilink` rewrites `[[target]]` / `[[target|alias]]` /
20
+ * `[[target#anchor|alias]]` into normal mdast LINK nodes (Obsidian-vault style);
21
+ * - `resolveTransclusion` embeds `![[target]]` / `![[target#section]]` as a
22
+ * visually-nested, labelled block (recursion capped at 3 levels).
23
+ */
24
+ import {
25
+ Alert,
26
+ AlertDescription,
27
+ AlertTitle,
28
+ Card,
29
+ CardContent,
30
+ CardHeader,
31
+ CardTitle,
32
+ Separator,
33
+ Table,
34
+ TableBody,
35
+ TableCell,
36
+ TableHead,
37
+ TableHeader,
38
+ TableRow,
39
+ } from "@elabs-ai/components-ui";
40
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
41
+ import {
42
+ createContext,
43
+ forwardRef,
44
+ isValidElement,
45
+ useContext,
46
+ useMemo,
47
+ type HTMLAttributes,
48
+ type ReactElement,
49
+ type ReactNode,
50
+ } from "react";
51
+ import {
52
+ Streamdown,
53
+ defaultRehypePlugins,
54
+ defaultRemarkPlugins,
55
+ type Components,
56
+ } from "streamdown";
57
+ import type { PluggableList } from "unified";
58
+ import { visit } from "unist-util-visit";
59
+
60
+ import {
61
+ BRAND_DIRECTIVE_ATTR,
62
+ BRAND_DIRECTIVE_INLINE_TAG,
63
+ BRAND_DIRECTIVE_PROP,
64
+ BRAND_DIRECTIVE_TAG,
65
+ buildMarkdownPlugins,
66
+ type BrandDirectivePayload,
67
+ type MarkdownDirectiveRenderer,
68
+ type MarkdownExtensions,
69
+ type MarkdownFenceRenderer,
70
+ } from "../lib/markdown/directives";
71
+ import {
72
+ annotationForRange,
73
+ removedMarkerAt,
74
+ shiftAnnotations,
75
+ type MarkdownAnnotation,
76
+ } from "../lib/markdown/diff";
77
+ import { parseFrontmatter } from "../lib/markdown/frontmatter";
78
+ import { CalcBlock, CalcInline, type EvaluateCalc } from "../calc-block";
79
+ import { MermaidDiagram } from "../mermaid-diagram";
80
+ import { MetricBlock } from "../metric-block";
81
+ import { Blockquote, Heading, Link, List, ListItem, Text, type HeadingLevel } from "../prose";
82
+ import { Timeline, type TimelineStatus } from "../timeline";
83
+ import { CodeFence, fenceLanguage } from "./code-fence";
84
+ import { parseMarkdownOutline } from "../markdown-outline";
85
+ import remarkMath from "remark-math";
86
+ import {
87
+ Bibliography,
88
+ CITE_TAG,
89
+ CITE_PROP,
90
+ CitationProvider,
91
+ collectCitations,
92
+ InlineCite,
93
+ remarkBrandCitations,
94
+ type CitationStyle,
95
+ type CollectedCitations,
96
+ type ResolveCitation,
97
+ } from "../markdown-academic/citations";
98
+ import {
99
+ FOOTNOTE_ITEM_TAG,
100
+ FOOTNOTE_LIST_TAG,
101
+ FOOTNOTE_PROP,
102
+ FOOTNOTE_REF_TAG,
103
+ FootnoteItem,
104
+ FootnoteList,
105
+ FootnoteRef,
106
+ remarkBrandFootnotes,
107
+ } from "../markdown-academic/footnotes";
108
+ import {
109
+ MATH_BLOCK_TAG,
110
+ MATH_INLINE_TAG,
111
+ MATH_PROP,
112
+ MathBlockTag,
113
+ MathInlineTag,
114
+ remarkBrandMath,
115
+ } from "../markdown-academic/math";
116
+ import { TableOfContents, TocProvider, useHeadingId } from "../markdown-academic/toc";
117
+ import {
118
+ IterationDirective,
119
+ specFromDirective,
120
+ type EvaluateIteration,
121
+ type InterpolateTemplate,
122
+ } from "../markdown-iteration";
123
+
124
+ // Streamdown's own default remark plugins (gfm etc.). The brand directive
125
+ // plugins are appended PER-INSTANCE inside the component, because the known
126
+ // directive-name set depends on the consumer's `extensions` (see the `plugins`
127
+ // memo below).
128
+ const baseRemarkPlugins = Object.values(defaultRemarkPlugins);
129
+
130
+ /**
131
+ * Custom element for inline transclusion embeds (`![[target]]`). A SEPARATE tag
132
+ * from the brand-directive tags so the sanitize schema stays narrow. The JSON
133
+ * payload property is `data-transclusion` (hast: `dataTransclusion`).
134
+ */
135
+ const BRAND_TRANSCLUSION_TAG = "brand-transclusion";
136
+ const BRAND_TRANSCLUSION_ATTR = "data-transclusion";
137
+ const BRAND_TRANSCLUSION_PROP = "dataTransclusion";
138
+
139
+ // Allow-list uses the hast PROPERTY name (camelCase), which is what survives
140
+ // Streamdown's sanitization — not the rendered `data-brand` attribute name.
141
+ // Both directive tags (block/leaf + inline) carry the same JSON payload property.
142
+ const allowedTags = {
143
+ [BRAND_DIRECTIVE_TAG]: [BRAND_DIRECTIVE_PROP],
144
+ [BRAND_DIRECTIVE_INLINE_TAG]: [BRAND_DIRECTIVE_PROP],
145
+ [BRAND_TRANSCLUSION_TAG]: [BRAND_TRANSCLUSION_PROP],
146
+ // Academic layer (footnotes / math / citations) — opt-in via props, but the
147
+ // tags are always allow-listed (harmless when the feature is off).
148
+ [FOOTNOTE_REF_TAG]: [FOOTNOTE_PROP],
149
+ [FOOTNOTE_ITEM_TAG]: [FOOTNOTE_PROP],
150
+ [FOOTNOTE_LIST_TAG]: [],
151
+ [MATH_BLOCK_TAG]: [MATH_PROP],
152
+ [MATH_INLINE_TAG]: [MATH_PROP],
153
+ [CITE_TAG]: [CITE_PROP],
154
+ };
155
+
156
+ /** All academic custom tags + their payload props, for the sanitize schema. */
157
+ const ACADEMIC_TAGS = [
158
+ FOOTNOTE_REF_TAG,
159
+ FOOTNOTE_ITEM_TAG,
160
+ FOOTNOTE_LIST_TAG,
161
+ MATH_BLOCK_TAG,
162
+ MATH_INLINE_TAG,
163
+ CITE_TAG,
164
+ ];
165
+ const ACADEMIC_TAG_ATTRS: Record<string, string[]> = {
166
+ [FOOTNOTE_REF_TAG]: [FOOTNOTE_PROP],
167
+ [FOOTNOTE_ITEM_TAG]: [FOOTNOTE_PROP],
168
+ [FOOTNOTE_LIST_TAG]: [],
169
+ [MATH_BLOCK_TAG]: [MATH_PROP],
170
+ [MATH_INLINE_TAG]: [MATH_PROP],
171
+ [CITE_TAG]: [CITE_PROP],
172
+ };
173
+
174
+ /**
175
+ * Streamdown's default sanitize schema only lets http(s) image `src` through,
176
+ * which kills the `resolveUrl` story (#L4): authenticated repo assets arrive
177
+ * as `data:`/`blob:` URLs. Extend the SAME default pipeline (raw → sanitize →
178
+ * harden) with those protocols — harden itself already validates them.
179
+ */
180
+ const rehypePlugins = (() => {
181
+ const defaults = defaultRehypePlugins as Record<string, unknown>;
182
+ const sanitize = defaults.sanitize as [
183
+ unknown,
184
+ {
185
+ protocols?: Record<string, unknown[]>;
186
+ tagNames?: string[];
187
+ attributes?: Record<string, unknown[]>;
188
+ },
189
+ ];
190
+ const schema = sanitize[1] ?? {};
191
+ const protocols = (schema.protocols ?? {}) as Record<string, unknown[]>;
192
+ const extendedSanitize = [
193
+ sanitize[0],
194
+ {
195
+ ...schema,
196
+ protocols: { ...protocols, src: [...(protocols.src ?? ["http", "https"]), "data", "blob"] },
197
+ // Custom rehypePlugins bypass Streamdown's `allowedTags` merge — so the brand-directive
198
+ // tags, the transclusion tag, and their JSON payload properties all go into the schema here.
199
+ tagNames: [
200
+ ...(schema.tagNames ?? []),
201
+ BRAND_DIRECTIVE_TAG,
202
+ BRAND_DIRECTIVE_INLINE_TAG,
203
+ BRAND_TRANSCLUSION_TAG,
204
+ ...ACADEMIC_TAGS,
205
+ ],
206
+ attributes: {
207
+ ...(schema.attributes ?? {}),
208
+ [BRAND_DIRECTIVE_TAG]: [BRAND_DIRECTIVE_PROP],
209
+ [BRAND_DIRECTIVE_INLINE_TAG]: [BRAND_DIRECTIVE_PROP],
210
+ [BRAND_TRANSCLUSION_TAG]: [BRAND_TRANSCLUSION_PROP],
211
+ ...ACADEMIC_TAG_ATTRS,
212
+ },
213
+ },
214
+ ];
215
+ return [defaults.raw, extendedSanitize, defaults.harden] as PluggableList;
216
+ })();
217
+
218
+ /** Treat the whole document as one block (keeps multi-line directives intact). */
219
+ const singleBlock = (md: string): string[] => [md];
220
+
221
+ /** react-markdown passes `node` to every component — strip it before spreading. */
222
+ type MdProps = { node?: unknown; children?: ReactNode } & Record<string, unknown>;
223
+
224
+ /* ------------------------------------------------------------------ */
225
+ /* Contexts (keep the `components` map static across renders) */
226
+ /* ------------------------------------------------------------------ */
227
+
228
+ const AnnotationsContext = createContext<MarkdownAnnotation[]>([]);
229
+
230
+ /**
231
+ * In-document search state (term + the active hit's line), threaded to the
232
+ * blocks: the active block gets a primary wash; mermaid fences mark matching
233
+ * nodes. Lines are 1-based relative to the STRIPPED markdown (the provider
234
+ * shifts the public prop).
235
+ */
236
+ interface SearchState {
237
+ term?: string;
238
+ activeLine?: number;
239
+ /** Stripped source lines — used to hand the active line's text to diagrams. */
240
+ lines: readonly string[];
241
+ }
242
+
243
+ const SearchContext = createContext<SearchState>({ lines: [] });
244
+
245
+ /**
246
+ * Hover affordances beside headings (#L6 companion): a generic render-prop —
247
+ * the preview knows nothing about what the action does (pinning, anchors,
248
+ * copy-link…). Revealed on heading hover / focus, and kept visible while the
249
+ * slot contains a pressed toggle (`aria-pressed="true"`).
250
+ */
251
+ export interface MarkdownHeadingInfo {
252
+ level: HeadingLevel;
253
+ /** Plain text content of the heading. */
254
+ text: string;
255
+ /** 1-based start line in the frontmatter-STRIPPED source (= `data-sourcepos`). */
256
+ line?: number;
257
+ }
258
+
259
+ const HeadingActionsContext = createContext<((heading: MarkdownHeadingInfo) => ReactNode) | null>(
260
+ null,
261
+ );
262
+
263
+ /**
264
+ * The resolved render registry for this preview instance: directive renderers by
265
+ * name + fence renderers by language (the `extensions` prop, plus the calc fence
266
+ * synthesized from `evaluate`). Consulted by `BrandDirective` /
267
+ * `BrandInlineDirective` (directives) and `PreBlock` (fences).
268
+ */
269
+ interface PreviewRegistry {
270
+ directives: Map<string, MarkdownDirectiveRenderer>;
271
+ fences: Map<string, MarkdownFenceRenderer>;
272
+ }
273
+
274
+ const EMPTY_REGISTRY: PreviewRegistry = { directives: new Map(), fences: new Map() };
275
+ const RegistryContext = createContext<PreviewRegistry>(EMPTY_REGISTRY);
276
+
277
+ /**
278
+ * Consumer-supplied link-preview render slot. When supplied, every rendered `<a>`
279
+ * is wrapped via this function; the consumer attaches its own hover card /
280
+ * popover. The library never fetches — the consumer owns the preview content.
281
+ * Default (not supplied) → the plain `Link` component.
282
+ */
283
+ const LinkPreviewContext = createContext<((href: string, children: ReactNode) => ReactNode) | null>(
284
+ null,
285
+ );
286
+
287
+ /**
288
+ * Transclusion resolver — threaded into `TransclusionBlock` so recursive
289
+ * `MarkdownPreview` renders can access the same hook without prop-drilling.
290
+ */
291
+ const TransclusionResolverContext = createContext<
292
+ ((target: string, opts: TransclusionResolveOptions) => string | null) | null
293
+ >(null);
294
+
295
+ /** Maximum nesting depth for `![[transclusion]]` embeds (prevents cycles). */
296
+ const TRANSCLUSION_MAX_DEPTH = 3;
297
+
298
+ /** Tracks the current embed depth; 0 = top-level document. */
299
+ const TransclusionDepthContext = createContext<number>(0);
300
+
301
+ /** Flatten a rendered heading's children to plain text (descends elements). */
302
+ function flattenNodeText(node: ReactNode): string {
303
+ if (typeof node === "string" || typeof node === "number") return String(node);
304
+ if (Array.isArray(node)) return node.map((n) => flattenNodeText(n as ReactNode)).join("");
305
+ if (isValidElement(node)) {
306
+ return flattenNodeText((node.props as { children?: ReactNode }).children);
307
+ }
308
+ return "";
309
+ }
310
+
311
+ export type MarkdownUrlKind = "image" | "link";
312
+ type UrlResolver = (url: string, kind: MarkdownUrlKind) => string;
313
+
314
+ /**
315
+ * URL rewriting must happen at the REMARK stage: Streamdown's sanitizer
316
+ * (harden-react-markdown) runs on the hast and blocks unresolvable relative
317
+ * URLs before any React component sees them — so the resolver maps them to
318
+ * absolute (or protocol-carrying) URLs first.
319
+ */
320
+ interface MdUrlNode {
321
+ type: string;
322
+ url?: string;
323
+ }
324
+
325
+ function remarkResolveUrls(resolve: UrlResolver) {
326
+ // Unified plugin shape: an ATTACHER that returns the transformer.
327
+ return function attacher() {
328
+ return (tree: unknown) => {
329
+ visit(tree as Parameters<typeof visit>[0], (node) => {
330
+ const n = node as MdUrlNode;
331
+ if (n.type === "image" || n.type === "imageReference") {
332
+ if (typeof n.url === "string") n.url = resolve(n.url, "image");
333
+ } else if (n.type === "link" || n.type === "definition") {
334
+ if (typeof n.url === "string") n.url = resolve(n.url, "link");
335
+ }
336
+ });
337
+ };
338
+ };
339
+ }
340
+
341
+ /* ------------------------------------------------------------------ */
342
+ /* Wikilink resolver types (exported for consumers) */
343
+ /* ------------------------------------------------------------------ */
344
+
345
+ /**
346
+ * Options passed to `resolveWikilink` for each wikilink found in the document.
347
+ */
348
+ export interface WikilinkResolveOptions {
349
+ /** The `#anchor` fragment, if present — e.g. `[[target#Section 1]]` → `"Section 1"`. */
350
+ anchor?: string;
351
+ }
352
+
353
+ /**
354
+ * Options passed to `resolveTransclusion` for each transclusion embed found.
355
+ */
356
+ export interface TransclusionResolveOptions {
357
+ /**
358
+ * A `#section` heading, if present — e.g. `![[target#Introduction]]` → `"Introduction"`.
359
+ * The consumer can use this to extract only that section from the document.
360
+ */
361
+ section?: string;
362
+ }
363
+
364
+ /* ------------------------------------------------------------------ */
365
+ /* remarkResolveWikilinks — `[[target]]` → mdast link node */
366
+ /* ------------------------------------------------------------------ */
367
+
368
+ /**
369
+ * Wikilink syntax supported:
370
+ * `[[target]]` → link text = target, href from resolveWikilink(target, {})
371
+ * `[[target|alias]]` → link text = alias, href from resolveWikilink(target, {})
372
+ * `[[target#anchor]]` → link text = target, href from resolveWikilink(target, { anchor })
373
+ * `[[target#anchor|alias]]` → link text = alias, href from resolveWikilink(target, { anchor })
374
+ * (Obsidian style: anchor is on the TARGET side, before the `|` separator)
375
+ *
376
+ * Unresolvable wikilinks (hook returns null) render as plain text `[[original]]`.
377
+ * The produced link flows through the existing `a:` renderer (resolveUrl + renderLinkPreview apply).
378
+ */
379
+ function remarkResolveWikilinks(
380
+ resolve: (target: string, opts: WikilinkResolveOptions) => string | null,
381
+ ) {
382
+ // Match `[[...]]` but NOT `![[...]]` (transclusion is handled separately).
383
+ // Lookbehind `(?<!!)` ensures we don't consume transclusion prefixes.
384
+ const WIKILINK_RE = /(?<!!)\[\[([^\]]+)\]\]/g;
385
+
386
+ return function attacher() {
387
+ return (tree: unknown) => {
388
+ visit(tree as Parameters<typeof visit>[0], "text", (node, index, parent) => {
389
+ const n = node as { type: string; value: string };
390
+ const p = parent as { children?: unknown[] } | undefined;
391
+ if (!p?.children || index == null || typeof n.value !== "string") return;
392
+
393
+ const text = n.value;
394
+ // Fast path: no wikilinks in this text node.
395
+ if (!text.includes("[[")) return;
396
+
397
+ const newChildren: unknown[] = [];
398
+ let lastIndex = 0;
399
+ WIKILINK_RE.lastIndex = 0;
400
+ let match: RegExpExecArray | null;
401
+
402
+ while ((match = WIKILINK_RE.exec(text)) !== null) {
403
+ // Text before this wikilink.
404
+ if (match.index > lastIndex) {
405
+ newChildren.push({ type: "text", value: text.slice(lastIndex, match.index) });
406
+ }
407
+
408
+ const inner = match[1]!;
409
+ // Split on FIRST `|` for alias — anchor lives on the target side (before `|`).
410
+ const pipeIdx = inner.indexOf("|");
411
+ const targetPart = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner;
412
+ const alias = pipeIdx !== -1 ? inner.slice(pipeIdx + 1) : undefined;
413
+
414
+ // Split target on FIRST `#` for anchor.
415
+ const hashIdx = targetPart.indexOf("#");
416
+ const target = hashIdx !== -1 ? targetPart.slice(0, hashIdx) : targetPart;
417
+ const anchor = hashIdx !== -1 ? targetPart.slice(hashIdx + 1) : undefined;
418
+
419
+ const opts: WikilinkResolveOptions = anchor ? { anchor } : {};
420
+ const href = resolve(target.trim(), opts);
421
+ const linkText = alias?.trim() || target.trim();
422
+
423
+ if (href === null) {
424
+ // Unresolvable → plain text, preserving the original `[[…]]` literal.
425
+ newChildren.push({ type: "text", value: match[0] });
426
+ } else {
427
+ // A normal mdast link — flows through the existing `a:` renderer.
428
+ newChildren.push({
429
+ type: "link",
430
+ url: href,
431
+ title: null,
432
+ children: [{ type: "text", value: linkText }],
433
+ });
434
+ }
435
+
436
+ lastIndex = match.index + match[0].length;
437
+ }
438
+
439
+ // Remaining text after the last wikilink.
440
+ if (lastIndex < text.length) {
441
+ newChildren.push({ type: "text", value: text.slice(lastIndex) });
442
+ }
443
+
444
+ // Only splice if we actually found wikilinks.
445
+ if (newChildren.length > 0) {
446
+ p.children.splice(index, 1, ...newChildren);
447
+ // Return the next index to skip past the newly inserted nodes.
448
+ return index + newChildren.length;
449
+ }
450
+ });
451
+ };
452
+ };
453
+ }
454
+
455
+ /* ------------------------------------------------------------------ */
456
+ /* remarkResolveTransclusions — `![[target]]` → brand-transclusion */
457
+ /* ------------------------------------------------------------------ */
458
+
459
+ interface TransclusionPayload {
460
+ target: string;
461
+ section?: string;
462
+ }
463
+
464
+ /**
465
+ * Rewrites standalone `![[target]]` / `![[target#section]]` lines into a custom
466
+ * `<brand-transclusion>` element carrying a JSON payload. The React component
467
+ * (`TransclusionBlock`) resolves + renders the content recursively, with a
468
+ * depth cap to prevent infinite loops.
469
+ *
470
+ * "Standalone" means the wikilink embed appears as its own paragraph (the most
471
+ * common Obsidian authoring pattern). Embeds mid-sentence are also caught via
472
+ * the text-node transform but are treated as paragraph-level blocks by inserting
473
+ * a paragraph wrapper — this keeps valid mdast structure.
474
+ */
475
+ function remarkResolveTransclusions() {
476
+ // A STANDALONE transclusion: a paragraph whose only content is `![[target]]`
477
+ // (the Obsidian authoring pattern). Transclusion is a BLOCK embed, so we
478
+ // rewrite the whole PARAGRAPH (not an inline text node — a figure inside <p>
479
+ // would be invalid HTML) and use `data.hName`/`hProperties` (the same reliable
480
+ // mechanism the brand directives use) rather than a raw `html` node, which does
481
+ // not round-trip through Streamdown's rehype pipeline.
482
+ const STANDALONE_RE = /^!\[\[([^\]]+)\]\]$/;
483
+
484
+ return function attacher() {
485
+ return (tree: unknown) => {
486
+ visit(tree as Parameters<typeof visit>[0], "paragraph", (node) => {
487
+ const n = node as {
488
+ children?: { type: string; value?: string }[];
489
+ data?: { hName?: string; hProperties?: Record<string, unknown> };
490
+ };
491
+ if (!n.children || n.children.length !== 1) return;
492
+ const child = n.children[0]!;
493
+ if (child.type !== "text" || typeof child.value !== "string") return;
494
+
495
+ const match = child.value.trim().match(STANDALONE_RE);
496
+ if (!match) return;
497
+
498
+ const inner = match[1]!;
499
+ const hashIdx = inner.indexOf("#");
500
+ const target = (hashIdx !== -1 ? inner.slice(0, hashIdx) : inner).trim();
501
+ const section = hashIdx !== -1 ? inner.slice(hashIdx + 1).trim() : undefined;
502
+ const payload: TransclusionPayload = section ? { target, section } : { target };
503
+
504
+ const data = n.data ?? (n.data = {});
505
+ data.hName = BRAND_TRANSCLUSION_TAG;
506
+ data.hProperties = { [BRAND_TRANSCLUSION_PROP]: JSON.stringify(payload) };
507
+ n.children = []; // consumed into the payload; TransclusionBlock renders it
508
+ });
509
+ };
510
+ };
511
+ }
512
+
513
+ interface SourcePos {
514
+ start: number;
515
+ end: number;
516
+ }
517
+
518
+ /** Does a DESCENDANT list item already contain this line? (innermost li wins) */
519
+ function nestedItemContains(node: unknown, line: number): boolean {
520
+ const kids = (node as { children?: unknown[] } | undefined)?.children ?? [];
521
+ for (const kid of kids) {
522
+ const el = kid as { tagName?: string };
523
+ if (el.tagName === "li") {
524
+ const pos = getSourcePos(kid);
525
+ if (pos && line >= pos.start && line <= pos.end) return true;
526
+ }
527
+ if (nestedItemContains(kid, line)) return true;
528
+ }
529
+ return false;
530
+ }
531
+
532
+ function getSourcePos(node: unknown): SourcePos | undefined {
533
+ const pos = (
534
+ node as { position?: { start?: { line?: number }; end?: { line?: number } } } | undefined
535
+ )?.position;
536
+ if (typeof pos?.start?.line !== "number") return undefined;
537
+ return { start: pos.start.line, end: pos.end?.line ?? pos.start.line };
538
+ }
539
+
540
+ function RemovedMarker({ count }: { count: number }) {
541
+ return (
542
+ <div
543
+ role="note"
544
+ aria-label={`${count} ${count === 1 ? "line" : "lines"} removed here`}
545
+ className="flex items-center gap-2 text-meta text-destructive-text"
546
+ >
547
+ <span aria-hidden="true" className="font-mono">
548
+
549
+ </span>
550
+ <span aria-hidden="true" className="flex-1 border-t border-dashed border-destructive/40" />
551
+ <span>
552
+ {count} {count === 1 ? "line" : "lines"} removed
553
+ </span>
554
+ <span aria-hidden="true" className="flex-1 border-t border-dashed border-destructive/40" />
555
+ </div>
556
+ );
557
+ }
558
+
559
+ /**
560
+ * Wrap a block renderer with the sourcepos + annotation layer: stamps
561
+ * `data-sourcepos`, washes added/modified blocks (accent rail + tint), washes
562
+ * the active search hit's block (primary tint), and renders the
563
+ * removed-content marker anchored to this block.
564
+ *
565
+ * `searchWash: false` opts a block out of the active-search wash — `pre`
566
+ * fences (incl. mermaid) carry their own treatment.
567
+ */
568
+ function annotated(render: (props: MdProps) => ReactNode, searchWash = true) {
569
+ return function AnnotatedBlock(props: MdProps) {
570
+ const annotations = useContext(AnnotationsContext);
571
+ const search = useContext(SearchContext);
572
+ const pos = getSourcePos(props.node);
573
+ const enriched = pos ? { ...props, "data-sourcepos": `${pos.start}:${pos.end}` } : props;
574
+
575
+ const wash =
576
+ pos && annotations.length > 0
577
+ ? annotationForRange(annotations, pos.start, pos.end)
578
+ : undefined;
579
+ const removed =
580
+ pos && annotations.length > 0 ? removedMarkerAt(annotations, pos.start) : undefined;
581
+ const activeSearch =
582
+ searchWash &&
583
+ pos != null &&
584
+ search.activeLine != null &&
585
+ search.activeLine >= pos.start &&
586
+ search.activeLine <= pos.end;
587
+
588
+ let content = render(enriched);
589
+ if (!wash && !removed && !activeSearch) return content;
590
+
591
+ if (wash) {
592
+ content = (
593
+ <div
594
+ data-annotation={wash.kind}
595
+ className="border-s-2 border-s-success bg-success/10 py-1.5 pe-2 ps-3"
596
+ >
597
+ {content}
598
+ </div>
599
+ );
600
+ }
601
+ if (activeSearch) {
602
+ content = (
603
+ <div data-search-active="" className="-mx-2 rounded-md bg-primary/10 px-2 py-1">
604
+ {content}
605
+ </div>
606
+ );
607
+ }
608
+ return (
609
+ <>
610
+ {removed ? <RemovedMarker count={removed.removedCount ?? 1} /> : null}
611
+ {content}
612
+ </>
613
+ );
614
+ };
615
+ }
616
+
617
+ function heading(level: HeadingLevel) {
618
+ return function HeadingMd({ node: _n, children, ...rest }: MdProps) {
619
+ const headingActions = useContext(HeadingActionsContext);
620
+ const start = (rest["data-sourcepos"] as string | undefined)?.split(":")[0];
621
+ const line = start ? Number(start) : undefined;
622
+ // Stable slug id (only when TOC is enabled) so `::toc` anchors resolve.
623
+ const headingId = useHeadingId(line);
624
+ const slot = headingActions?.({
625
+ level,
626
+ text: flattenNodeText(children),
627
+ line,
628
+ });
629
+ return (
630
+ <Heading
631
+ level={level}
632
+ id={headingId}
633
+ {...(rest as HTMLAttributes<HTMLHeadingElement>)}
634
+ className={cn(
635
+ slot ? "group/heading" : undefined,
636
+ headingId ? "scroll-mt-4" : undefined,
637
+ rest.className as string | undefined,
638
+ )}
639
+ >
640
+ {children}
641
+ {slot ? (
642
+ <span
643
+ // GitHub-anchor grammar: revealed on hover/focus; stays visible
644
+ // while a contained toggle is pressed (a pinned section keeps its pin).
645
+ className="ms-1.5 inline-flex align-middle opacity-0 transition-opacity duration-fast ease-standard focus-within:opacity-100 group-hover/heading:opacity-100 has-[[aria-pressed=true]]:opacity-100 motion-reduce:transition-none"
646
+ >
647
+ {slot}
648
+ </span>
649
+ ) : null}
650
+ </Heading>
651
+ );
652
+ };
653
+ }
654
+
655
+ const CALLOUT_VARIANT: Record<string, "default" | "info" | "success" | "warning" | "destructive"> =
656
+ {
657
+ info: "info",
658
+ note: "info",
659
+ tip: "success",
660
+ success: "success",
661
+ warning: "warning",
662
+ caution: "warning",
663
+ danger: "destructive",
664
+ error: "destructive",
665
+ destructive: "destructive",
666
+ };
667
+
668
+ const TIMELINE_STATUS: Record<string, TimelineStatus> = {
669
+ done: "done",
670
+ complete: "done",
671
+ completed: "done",
672
+ active: "active",
673
+ current: "active",
674
+ pending: "pending",
675
+ todo: "pending",
676
+ };
677
+
678
+ function UnknownBlock({ name }: { name: string }) {
679
+ return (
680
+ <Alert variant="destructive">
681
+ <AlertTitle>Unknown block: {name}</AlertTitle>
682
+ <AlertDescription>
683
+ No renderer is mapped for <code>:::{name}</code>. Add it to the brand directive registry, or
684
+ fix the directive name.
685
+ </AlertDescription>
686
+ </Alert>
687
+ );
688
+ }
689
+
690
+ /** Parse the JSON payload off a `<brand-directive*>` element's props. */
691
+ function readDirectivePayload(rest: MdProps): BrandDirectivePayload | "malformed" | null {
692
+ const raw =
693
+ (rest[BRAND_DIRECTIVE_ATTR] as string | undefined) ?? (rest.dataBrand as string | undefined);
694
+ if (!raw) return null;
695
+ try {
696
+ return JSON.parse(raw) as BrandDirectivePayload;
697
+ } catch {
698
+ return "malformed";
699
+ }
700
+ }
701
+
702
+ function BrandDirective({ node: _n, children, ...rest }: MdProps) {
703
+ const registry = useContext(RegistryContext);
704
+ const payload = readDirectivePayload(rest);
705
+ if (payload === null) return null;
706
+ if (payload === "malformed") return <UnknownBlock name="malformed" />;
707
+
708
+ if (!payload.known) return <UnknownBlock name={payload.name} />;
709
+
710
+ const attrs = payload.attributes ?? {};
711
+ switch (payload.name) {
712
+ case "card":
713
+ return (
714
+ <Card>
715
+ {attrs.title ? (
716
+ <CardHeader>
717
+ <CardTitle>{attrs.title}</CardTitle>
718
+ </CardHeader>
719
+ ) : null}
720
+ <CardContent className={cn(!attrs.title && "pt-6")}>{children}</CardContent>
721
+ </Card>
722
+ );
723
+ case "callout":
724
+ return (
725
+ <Alert variant={CALLOUT_VARIANT[attrs.type ?? ""] ?? "default"}>
726
+ {/* Callout title is a label, NOT a document section heading — a callout is
727
+ inserted INTO the content flow, so an <h5> (AlertTitle's default for a
728
+ standalone banner) would break the document heading outline. Render the
729
+ same visual as a non-heading <div> instead (see #21). */}
730
+ {attrs.title ? (
731
+ <div className="mb-1 font-medium leading-none tracking-tight">{attrs.title}</div>
732
+ ) : null}
733
+ <AlertDescription>{children}</AlertDescription>
734
+ </Alert>
735
+ );
736
+ case "metric":
737
+ return (
738
+ <MetricBlock
739
+ label={attrs.label ?? ""}
740
+ value={attrs.value ?? ""}
741
+ description={attrs.description}
742
+ delta={attrs.delta}
743
+ deltaDirection={
744
+ attrs.delta?.startsWith("+") ? "up" : attrs.delta?.startsWith("-") ? "down" : "neutral"
745
+ }
746
+ />
747
+ );
748
+ case "timeline":
749
+ return (
750
+ <Timeline
751
+ items={(payload.items ?? []).map((it) => ({
752
+ title: it.title,
753
+ status: TIMELINE_STATUS[it.status] ?? "pending",
754
+ }))}
755
+ />
756
+ );
757
+ default: {
758
+ // Not a built-in → a consumer-registered directive (`extensions`). The
759
+ // name reached `known: true` only because it was registered, so a renderer
760
+ // should exist; if somehow missing, surface the unknown-block error.
761
+ const renderer = registry.directives.get(payload.name);
762
+ if (renderer && (!renderer.kinds || renderer.kinds.includes(payload.kind))) {
763
+ return (
764
+ <>
765
+ {renderer.render({
766
+ name: payload.name,
767
+ kind: payload.kind,
768
+ attributes: attrs,
769
+ children,
770
+ textValue: payload.label,
771
+ rawBody: payload.body,
772
+ })}
773
+ </>
774
+ );
775
+ }
776
+ return <UnknownBlock name={payload.name} />;
777
+ }
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Inline (`:name[label]{attrs}`) directives. Rendered via a SEPARATE tag so it
783
+ * stays in the text flow (no block wrapper / annotation layer). Only registered
784
+ * inline names reach here (unregistered ones were restored to literal text by
785
+ * the parser); a registered name with no inline renderer falls back to its label.
786
+ */
787
+ function BrandInlineDirective({ node: _n, children, ...rest }: MdProps) {
788
+ const registry = useContext(RegistryContext);
789
+ const payload = readDirectivePayload(rest);
790
+ if (payload === null || payload === "malformed") return <>{children}</>;
791
+
792
+ const renderer = registry.directives.get(payload.name);
793
+ if (!renderer || (renderer.kinds && !renderer.kinds.includes("inline"))) {
794
+ return <>{children}</>;
795
+ }
796
+ return (
797
+ <>
798
+ {renderer.render({
799
+ name: payload.name,
800
+ kind: "inline",
801
+ attributes: payload.attributes ?? {},
802
+ children,
803
+ textValue: payload.label,
804
+ })}
805
+ </>
806
+ );
807
+ }
808
+
809
+ /* ------------------------------------------------------------------ */
810
+ /* Mermaid fences + resolved images/links */
811
+ /* ------------------------------------------------------------------ */
812
+
813
+ /** Flatten react-markdown `children` (string | array) into the raw fence text. */
814
+ function fenceText(children: ReactNode): string {
815
+ if (typeof children === "string") return children;
816
+ if (Array.isArray(children)) return children.map((c) => fenceText(c as ReactNode)).join("");
817
+ return "";
818
+ }
819
+
820
+ function isMermaidCodeElement(child: unknown): child is ReactElement<{
821
+ className?: string;
822
+ children?: ReactNode;
823
+ }> {
824
+ return (
825
+ isValidElement(child) &&
826
+ /\blanguage-mermaid\b/.test((child.props as { className?: string } | null)?.className ?? "")
827
+ );
828
+ }
829
+
830
+ function PreBlock({ node, children, ...rest }: MdProps) {
831
+ const search = useContext(SearchContext);
832
+ const registry = useContext(RegistryContext);
833
+ const pos = getSourcePos(node);
834
+ const activeInBlock =
835
+ pos != null &&
836
+ search.activeLine != null &&
837
+ search.activeLine >= pos.start &&
838
+ search.activeLine <= pos.end;
839
+
840
+ const list = Array.isArray(children) ? children : [children];
841
+ // Mermaid stays a PRIVILEGED built-in: it carries search-highlight + active-line
842
+ // coupling that the generic `{ source, lang }` fence contract deliberately omits.
843
+ const mermaidChild = list.find(isMermaidCodeElement);
844
+ if (mermaidChild) {
845
+ const chart = fenceText((mermaidChild.props as { children?: ReactNode }).children).replace(
846
+ /\n$/,
847
+ "",
848
+ );
849
+ return (
850
+ <MermaidDiagram
851
+ chart={chart}
852
+ // The diagram must stay addressable by source line (outline/search jumps).
853
+ data-sourcepos={pos ? `${pos.start}:${pos.end}` : undefined}
854
+ highlightTerm={search.term}
855
+ activeText={
856
+ activeInBlock && search.activeLine != null
857
+ ? search.lines[search.activeLine - 1]
858
+ : undefined
859
+ }
860
+ />
861
+ );
862
+ }
863
+ // Registered fences (the seam): calc is registered from the `evaluate` prop;
864
+ // consumers register their own via `extensions.fences`. The library renders the
865
+ // result; the consumer's renderer owns any domain hook. Stamp `data-sourcepos`
866
+ // so the block stays addressable by outline/search jumps.
867
+ const codeEl = list.find(isValidElement) as
868
+ | ReactElement<{ className?: string; children?: ReactNode }>
869
+ | undefined;
870
+ const fenceLang = fenceLanguage(codeEl?.props.className);
871
+ const fenceRenderer = fenceLang ? registry.fences.get(fenceLang) : undefined;
872
+ if (fenceLang && fenceRenderer && codeEl) {
873
+ const source = fenceText(codeEl.props.children).replace(/\n$/, "");
874
+ const rendered = fenceRenderer.render({ source, lang: fenceLang });
875
+ return pos ? <div data-sourcepos={`${pos.start}:${pos.end}`}>{rendered}</div> : <>{rendered}</>;
876
+ }
877
+ // Non-mermaid, unregistered fences: tokenized highlighting + language chip + hover copy.
878
+ // The fence keeps its source-line address (`data-sourcepos` arrives via
879
+ // `rest` onto the wrapper) and the active-search wash on the inner pre.
880
+ const codeText = fenceText(codeEl ? codeEl.props.children : (children as ReactNode)).replace(
881
+ /\n$/,
882
+ "",
883
+ );
884
+ return (
885
+ <CodeFence
886
+ {...(rest as HTMLAttributes<HTMLElement>)}
887
+ codeText={codeText}
888
+ language={fenceLang}
889
+ searchActive={activeInBlock}
890
+ >
891
+ {children}
892
+ </CodeFence>
893
+ );
894
+ }
895
+
896
+ function ImageMd({ node: _n, src, alt, ...rest }: MdProps) {
897
+ return (
898
+ <img
899
+ src={src as string}
900
+ alt={(alt as string) ?? ""}
901
+ loading="lazy"
902
+ className="max-w-full rounded-md border border-border"
903
+ {...(rest as HTMLAttributes<HTMLImageElement>)}
904
+ />
905
+ );
906
+ }
907
+
908
+ function LinkMd({ node: _n, href, children, ...rest }: MdProps) {
909
+ const renderLinkPreview = useContext(LinkPreviewContext);
910
+ const anchor = (
911
+ <Link href={href as string} {...(rest as HTMLAttributes<HTMLAnchorElement>)}>
912
+ {children}
913
+ </Link>
914
+ );
915
+ if (renderLinkPreview && typeof href === "string") {
916
+ return <>{renderLinkPreview(href, anchor)}</>;
917
+ }
918
+ return anchor;
919
+ }
920
+
921
+ /**
922
+ * Renders a `![[target]]` / `![[target#section]]` transclusion embed.
923
+ *
924
+ * - Reads the payload from the `data-transclusion` attribute (JSON).
925
+ * - Calls `resolveTransclusion(target, { section })` to get markdown text.
926
+ * - If null → renders the literal `![[target]]` as plain text.
927
+ * - If at the depth cap → renders a "transclusion too deep" notice.
928
+ * - Otherwise → recursively renders the returned markdown via `MarkdownPreview`
929
+ * inside a visually-nested, semantically-labelled block.
930
+ *
931
+ * The block uses a quiet inset separation: border-start rail + muted ground
932
+ * (no redundant border over the fill; satisfies the separation grammar).
933
+ */
934
+ function TransclusionBlock({ node: _n, ...rest }: MdProps) {
935
+ const resolveTransclusion = useContext(TransclusionResolverContext);
936
+ const depth = useContext(TransclusionDepthContext);
937
+ const linkPreview = useContext(LinkPreviewContext);
938
+
939
+ // Parse the JSON payload from the hast attribute.
940
+ const rawAttr =
941
+ (rest[BRAND_TRANSCLUSION_ATTR] as string | undefined) ??
942
+ (rest.dataTransclusion as string | undefined);
943
+
944
+ if (!rawAttr || !resolveTransclusion) {
945
+ // No resolver or malformed — render as literal fallback.
946
+ return (
947
+ <span>{rawAttr ? `![[${(JSON.parse(rawAttr) as TransclusionPayload).target}]]` : null}</span>
948
+ );
949
+ }
950
+
951
+ let payload: TransclusionPayload;
952
+ try {
953
+ payload = JSON.parse(rawAttr) as TransclusionPayload;
954
+ } catch {
955
+ return null;
956
+ }
957
+
958
+ const { target, section } = payload;
959
+ const label = section ? `${target}#${section}` : target;
960
+
961
+ if (depth >= TRANSCLUSION_MAX_DEPTH) {
962
+ return (
963
+ <figure
964
+ aria-label={`Embedded: ${label}`}
965
+ className="my-3 rounded-md border-s-2 border-s-muted bg-muted/40 px-4 py-3"
966
+ data-testid="transclusion-block"
967
+ data-transclusion-depth={depth}
968
+ >
969
+ <figcaption className="mb-1 text-meta text-muted-foreground">{label}</figcaption>
970
+ <p className="text-meta text-muted-foreground italic">
971
+ Transclusion too deep — embed skipped.
972
+ </p>
973
+ </figure>
974
+ );
975
+ }
976
+
977
+ const content = resolveTransclusion(target, section ? { section } : {});
978
+
979
+ if (content === null) {
980
+ // Unresolvable → plain text, never a broken element.
981
+ return <span>{`![[${label}]]`}</span>;
982
+ }
983
+
984
+ // Recursive render: inner MarkdownPreview reads depth+1 from context.
985
+ // We thread the SAME linkPreview context so consumer hooks propagate.
986
+ // NOTE: We render a plain MarkdownPreview without frontmatter strip by default.
987
+ // We must not import MarkdownPreview here (circular ref) — instead we render the
988
+ // Streamdown directly with the same plugin set. We solve this by rendering a
989
+ // lightweight recursive wrapper that bypasses the outer forwardRef. We achieve
990
+ // this by reading the current plugin array from the outer `plugins` memo (not
991
+ // possible here) — so instead we compose a separate inner pipeline with the same
992
+ // base plugins. The new contexts (depth + resolver + linkPreview) are provided by
993
+ // the outer MarkdownPreview render tree and inherited by RecursiveTransclusion.
994
+ return (
995
+ <TransclusionDepthContext.Provider value={depth + 1}>
996
+ <LinkPreviewContext.Provider value={linkPreview}>
997
+ <RecursiveTransclusionContent target={target} label={label} content={content} />
998
+ </LinkPreviewContext.Provider>
999
+ </TransclusionDepthContext.Provider>
1000
+ );
1001
+ }
1002
+
1003
+ /** Inner render for a resolved transclusion — used by TransclusionBlock. */
1004
+ function RecursiveTransclusionContent({
1005
+ target: _target,
1006
+ label,
1007
+ content,
1008
+ }: {
1009
+ target: string;
1010
+ label: string;
1011
+ content: string;
1012
+ }) {
1013
+ const plugins = useMemo<PluggableList>(() => {
1014
+ return [...baseRemarkPlugins, ...buildMarkdownPlugins()];
1015
+ }, []);
1016
+
1017
+ return (
1018
+ <figure
1019
+ aria-label={`Embedded: ${label}`}
1020
+ className="my-3 rounded-md border-s-2 border-s-muted bg-muted/40 px-4 py-2"
1021
+ data-testid="transclusion-block"
1022
+ >
1023
+ <figcaption className="mb-1.5 text-meta text-muted-foreground">{label}</figcaption>
1024
+ <div className="text-body text-foreground">
1025
+ <Streamdown
1026
+ parseMarkdownIntoBlocksFn={singleBlock}
1027
+ remarkPlugins={plugins}
1028
+ rehypePlugins={rehypePlugins}
1029
+ allowedTags={allowedTags}
1030
+ components={components}
1031
+ >
1032
+ {content}
1033
+ </Streamdown>
1034
+ </div>
1035
+ </figure>
1036
+ );
1037
+ }
1038
+
1039
+ const components = {
1040
+ h1: annotated(heading(1)),
1041
+ h2: annotated(heading(2)),
1042
+ h3: annotated(heading(3)),
1043
+ h4: annotated(heading(4)),
1044
+ h5: annotated(heading(5)),
1045
+ h6: annotated(heading(6)),
1046
+ p: annotated(({ node: _n, ...p }: MdProps) => (
1047
+ <Text {...(p as HTMLAttributes<HTMLParagraphElement>)} />
1048
+ )),
1049
+ a: LinkMd,
1050
+ img: ImageMd,
1051
+ // Lists wash at ITEM granularity (a whole-list wash drowns the page), so the
1052
+ // ul/ol wrappers opt out of the search wash and the li carries it inline
1053
+ // (no wrapper div — that would break list semantics).
1054
+ ul: annotated(
1055
+ ({ node: _n, ...p }: MdProps) => <List {...(p as HTMLAttributes<HTMLElement>)} />,
1056
+ false,
1057
+ ),
1058
+ ol: annotated(
1059
+ ({ node: _n, ...p }: MdProps) => <List ordered {...(p as HTMLAttributes<HTMLElement>)} />,
1060
+ false,
1061
+ ),
1062
+ li: function ListItemMd({ node, ...p }: MdProps) {
1063
+ const search = useContext(SearchContext);
1064
+ const pos = getSourcePos(node);
1065
+ const active =
1066
+ pos != null &&
1067
+ search.activeLine != null &&
1068
+ search.activeLine >= pos.start &&
1069
+ search.activeLine <= pos.end &&
1070
+ !nestedItemContains(node, search.activeLine);
1071
+ return (
1072
+ <ListItem
1073
+ data-sourcepos={pos ? `${pos.start}:${pos.end}` : undefined}
1074
+ data-search-active={active ? "" : undefined}
1075
+ {...(p as HTMLAttributes<HTMLLIElement>)}
1076
+ className={cn(active && "-mx-1 rounded-sm bg-primary/10 px-1", p.className as string)}
1077
+ />
1078
+ );
1079
+ },
1080
+ blockquote: annotated(({ node: _n, ...p }: MdProps) => (
1081
+ <Blockquote {...(p as HTMLAttributes<HTMLQuoteElement>)} />
1082
+ )),
1083
+ hr: annotated(() => <Separator className="my-4" />),
1084
+ pre: annotated(PreBlock, false),
1085
+ table: annotated(({ node: _n, ...p }: MdProps) => (
1086
+ <Table {...(p as HTMLAttributes<HTMLTableElement>)} />
1087
+ )),
1088
+ thead: ({ node: _n, ...p }: MdProps) => <TableHeader {...(p as object)} />,
1089
+ tbody: ({ node: _n, ...p }: MdProps) => <TableBody {...(p as object)} />,
1090
+ tr: ({ node: _n, ...p }: MdProps) => <TableRow {...(p as object)} />,
1091
+ th: ({ node: _n, ...p }: MdProps) => <TableHead {...(p as object)} />,
1092
+ td: ({ node: _n, ...p }: MdProps) => <TableCell {...(p as object)} />,
1093
+ [BRAND_DIRECTIVE_TAG]: annotated(BrandDirective),
1094
+ // Inline directives render un-`annotated` (no block wrapper) to stay in the text flow.
1095
+ [BRAND_DIRECTIVE_INLINE_TAG]: BrandInlineDirective,
1096
+ // Transclusion embeds (`![[target]]`) — resolved + recursively rendered by TransclusionBlock.
1097
+ [BRAND_TRANSCLUSION_TAG]: TransclusionBlock,
1098
+ // Academic layer — footnotes, math, citations (inline tags stay in the text flow;
1099
+ // the footnote section is a generated block).
1100
+ [FOOTNOTE_REF_TAG]: FootnoteRef,
1101
+ [FOOTNOTE_ITEM_TAG]: FootnoteItem,
1102
+ [FOOTNOTE_LIST_TAG]: ({ node: _n, children, ...rest }: MdProps) => (
1103
+ <FootnoteList {...(rest as HTMLAttributes<HTMLElement>)}>{children}</FootnoteList>
1104
+ ),
1105
+ [MATH_INLINE_TAG]: MathInlineTag,
1106
+ [MATH_BLOCK_TAG]: MathBlockTag,
1107
+ [CITE_TAG]: InlineCite,
1108
+ } as unknown as Components;
1109
+
1110
+ export interface MarkdownPreviewProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
1111
+ /** Markdown source. */
1112
+ children: string;
1113
+ /** Strip a leading YAML frontmatter block before rendering. Default true. */
1114
+ stripFrontmatter?: boolean;
1115
+ /**
1116
+ * Ghost-diff annotations (#L18) — typically from `computeMarkdownAnnotations`.
1117
+ * Lines are 1-based relative to the FULL `children` source (frontmatter
1118
+ * included); the preview shifts them when `stripFrontmatter` removes lines.
1119
+ */
1120
+ annotations?: MarkdownAnnotation[];
1121
+ /**
1122
+ * Rewrite image/link URLs (#L4) — e.g. resolve repo-relative paths or swap
1123
+ * private-repo asset URLs for authenticated blob URLs. Synchronous by design:
1124
+ * async consumers cache upstream and re-render when the URL is ready.
1125
+ */
1126
+ resolveUrl?: (url: string, kind: MarkdownUrlKind) => string;
1127
+ /**
1128
+ * In-document search term (≥2 chars): mermaid diagrams mark matching nodes.
1129
+ * Pair with an app-side text highlighter (CSS Custom Highlight API) for the
1130
+ * prose occurrences.
1131
+ */
1132
+ searchTerm?: string;
1133
+ /**
1134
+ * 1-based line of the ACTIVE search hit, relative to the FULL `children`
1135
+ * source (same convention as `annotations`). Its block gets a primary wash;
1136
+ * in a mermaid fence the matching node gets the active stroke.
1137
+ */
1138
+ activeSearchLine?: number;
1139
+ /**
1140
+ * Render hover affordances beside each heading (pin/anchor/copy-link…).
1141
+ * Presentational slot — revealed on heading hover/focus and kept visible
1142
+ * while it contains a pressed toggle. `line` is in frontmatter-STRIPPED
1143
+ * coordinates (the same space as `data-sourcepos` / `parseMarkdownOutline`).
1144
+ */
1145
+ headingActions?: (heading: MarkdownHeadingInfo) => ReactNode;
1146
+ /**
1147
+ * Evaluate a ```calc fence to a `CalcSheet` (the library renders, the app
1148
+ * computes — mirrors `resolveUrl`). With no `evaluate`, a ```calc fence renders
1149
+ * as a normal code block; the math engine stays in the consumer.
1150
+ *
1151
+ * Sugar over `extensions.fences`: it registers a built-in `calc` fence renderer.
1152
+ * Register your own `calc` fence via `extensions` to override it.
1153
+ */
1154
+ evaluate?: EvaluateCalc;
1155
+ /**
1156
+ * Extend the markdown dialect without forking the engine: register custom
1157
+ * `:::`/`::`/`:` directive renderers and ```lang fence renderers. Registered
1158
+ * directive names are also fed to the parser (so `:entity[…]` is recognized
1159
+ * while an unregistered prose colon stays literal). Domain logic stays in the
1160
+ * consumer's renderer (the library renders; the app computes).
1161
+ */
1162
+ extensions?: MarkdownExtensions;
1163
+ /**
1164
+ * Resolve an Obsidian-style wikilink (`[[target]]`, `[[target|alias]]`,
1165
+ * `[[target#anchor]]`, `[[target#anchor|alias]]`) to a URL.
1166
+ *
1167
+ * - Return a string href to produce a real `<a>` (flows through `resolveUrl`
1168
+ * and `renderLinkPreview` like any other link).
1169
+ * - Return `null` to leave the wikilink as literal plain text `[[target]]`
1170
+ * (graceful — never a broken link).
1171
+ *
1172
+ * Supported forms:
1173
+ * - `[[target]]` → `resolveWikilink("target", {})`
1174
+ * - `[[target|alias]]` → `resolveWikilink("target", {})`, link text = alias
1175
+ * - `[[target#anchor]]` → `resolveWikilink("target", { anchor: "anchor" })`
1176
+ * - `[[target#anchor|alias]]` → `resolveWikilink("target", { anchor: "anchor" })`, text = alias
1177
+ */
1178
+ resolveWikilink?: (target: string, opts: WikilinkResolveOptions) => string | null;
1179
+ /**
1180
+ * Resolve an Obsidian-style transclusion embed (`![[target]]`,
1181
+ * `![[target#section]]`) to the markdown TEXT to embed.
1182
+ *
1183
+ * - Return the markdown string to embed; it will be recursively rendered as a
1184
+ * visually-nested, AT-labelled block (depth cap: 3 levels).
1185
+ * - Return `null` to leave the embed as literal plain text `![[target]]`.
1186
+ *
1187
+ * The library never fetches — the consumer owns the vault index and resolution.
1188
+ */
1189
+ resolveTransclusion?: (target: string, opts: TransclusionResolveOptions) => string | null;
1190
+ /**
1191
+ * Wrap every rendered `<a>` to attach a hover/inline link preview (e.g. a
1192
+ * `@elabs-ai/components-ui` HoverCard showing metadata). The library does NOT fetch; the
1193
+ * consumer owns the preview content.
1194
+ *
1195
+ * Return `children` unchanged if the href should not trigger a preview.
1196
+ * Default (not supplied) → the plain `Link` component.
1197
+ */
1198
+ renderLinkPreview?: (href: string, children: ReactNode) => ReactNode;
1199
+ /**
1200
+ * Branded GFM footnotes (`[^1]` … `[^1]: definition`) — quiet superscript refs
1201
+ * + a footnote section at the document end with working same-page back-refs.
1202
+ * Default `false` (footnotes parse but render with the plain GFM treatment).
1203
+ */
1204
+ footnotes?: boolean;
1205
+ /**
1206
+ * Math via `remark-math` + KaTeX — `$inline$` and `$$block$$` (on their own
1207
+ * lines). KaTeX runs untrusted-safe (`trust:false`, bounded macro expansion);
1208
+ * MathML is emitted for assistive tech. **The consumer must load KaTeX CSS once**
1209
+ * (`import "katex/dist/katex.min.css"`). Default `false`.
1210
+ */
1211
+ math?: boolean;
1212
+ /**
1213
+ * Resolve a Pandoc / Better-BibTeX citation key (`[@smith2020]`,
1214
+ * `[@a; @b]`, `[@a, p. 5]`, `[-@a]`) to {@link CitationData}, or `null` when
1215
+ * unknown. The BibTeX/CSL database + any CSL formatting live in the app — the
1216
+ * library renders inline cites + the `::bibliography` / `::references` block with
1217
+ * consistent numbering. Setting this enables citations (the same way `evaluate`
1218
+ * enables calc).
1219
+ */
1220
+ resolveCitation?: ResolveCitation;
1221
+ /** Inline citation style: `"numeric"` `[1]` (default) or `"author-year"` `(Smith 2020)`. */
1222
+ citationStyle?: CitationStyle;
1223
+ /**
1224
+ * Enable the generated `::toc` block (a quiet in-flow table of contents) and
1225
+ * stamp stable slug `id`s on headings so the anchors resolve. Reuses the same
1226
+ * heading extractor as `DocumentOutline`. Default `false`.
1227
+ */
1228
+ toc?: boolean;
1229
+ /**
1230
+ * Resolve a `:::iterate` / `:::pivot` block's {@link IterationSpec} (parsed from
1231
+ * the directive's attributes + body template) to its data. The data source +
1232
+ * any query live in the app — the library renders the repeated/cross-tabbed
1233
+ * cells. Setting this enables the `iterate` + `pivot` directives (the way
1234
+ * `evaluate` enables calc).
1235
+ */
1236
+ evaluateIteration?: EvaluateIteration;
1237
+ /**
1238
+ * Fill a `:::iterate` cell template with its row/cell context. Default: a
1239
+ * minimal `{{path}}` substitution — pass your own engine for anything richer.
1240
+ */
1241
+ interpolate?: InterpolateTemplate;
1242
+ }
1243
+
1244
+ export const MarkdownPreview = forwardRef<HTMLDivElement, MarkdownPreviewProps>(
1245
+ function MarkdownPreview(
1246
+ {
1247
+ children,
1248
+ stripFrontmatter = true,
1249
+ annotations,
1250
+ resolveUrl,
1251
+ searchTerm,
1252
+ activeSearchLine,
1253
+ headingActions,
1254
+ evaluate,
1255
+ extensions,
1256
+ resolveWikilink,
1257
+ resolveTransclusion,
1258
+ renderLinkPreview,
1259
+ footnotes,
1260
+ math,
1261
+ resolveCitation,
1262
+ citationStyle = "numeric",
1263
+ toc,
1264
+ evaluateIteration,
1265
+ interpolate,
1266
+ className,
1267
+ ...props
1268
+ },
1269
+ ref,
1270
+ ) {
1271
+ const markdown = stripFrontmatter ? parseFrontmatter(children).content : children;
1272
+ const fmOffset = stripFrontmatter
1273
+ ? children.split("\n").length - markdown.split("\n").length
1274
+ : 0;
1275
+
1276
+ const shifted = useMemo(() => {
1277
+ if (!annotations?.length) return [];
1278
+ return fmOffset ? shiftAnnotations(annotations, fmOffset) : annotations;
1279
+ }, [annotations, fmOffset]);
1280
+
1281
+ const search = useMemo<SearchState>(() => {
1282
+ const term = searchTerm?.trim();
1283
+ const activeLine =
1284
+ activeSearchLine != null && activeSearchLine - fmOffset >= 1
1285
+ ? activeSearchLine - fmOffset
1286
+ : undefined;
1287
+ return {
1288
+ term: term && term.length >= 2 ? term : undefined,
1289
+ activeLine,
1290
+ lines: markdown.split("\n"),
1291
+ };
1292
+ }, [searchTerm, activeSearchLine, fmOffset, markdown]);
1293
+
1294
+ // Citation numbering authority — a single pre-pass so inline `[1]` and the
1295
+ // bibliography agree (only when a resolver is supplied).
1296
+ const citations = useMemo<CollectedCitations | null>(() => {
1297
+ if (!resolveCitation) return null;
1298
+ return collectCitations(markdown, resolveCitation);
1299
+ }, [markdown, resolveCitation]);
1300
+
1301
+ // Heading outline for the `::toc` block + heading-id stamping (only when on).
1302
+ const outline = useMemo(() => (toc ? parseMarkdownOutline(markdown) : null), [toc, markdown]);
1303
+
1304
+ // Resolve the render registry (directives + fences) for this instance. The
1305
+ // `evaluate` prop is sugar that registers the built-in `calc` fence.
1306
+ const registry = useMemo<PreviewRegistry>(() => {
1307
+ const directives = new Map<string, MarkdownDirectiveRenderer>();
1308
+ for (const d of extensions?.directives ?? []) directives.set(d.name, d);
1309
+ const fences = new Map<string, MarkdownFenceRenderer>();
1310
+ for (const f of extensions?.fences ?? []) fences.set(f.lang, f);
1311
+ // `::toc` + `::bibliography` / `::references` — internal directives whose
1312
+ // renderers read the outline / citation context (provided below).
1313
+ if (toc) {
1314
+ directives.set("toc", {
1315
+ name: "toc",
1316
+ kinds: ["leaf", "container"],
1317
+ render: ({ attributes }) => <TableOfContents title={attributes.title || undefined} />,
1318
+ });
1319
+ }
1320
+ if (resolveCitation) {
1321
+ const renderBibliography: MarkdownDirectiveRenderer["render"] = ({ attributes }) => (
1322
+ <Bibliography title={attributes.title || undefined} />
1323
+ );
1324
+ directives.set("bibliography", {
1325
+ name: "bibliography",
1326
+ kinds: ["leaf", "container"],
1327
+ render: renderBibliography,
1328
+ });
1329
+ directives.set("references", {
1330
+ name: "references",
1331
+ kinds: ["leaf", "container"],
1332
+ render: renderBibliography,
1333
+ });
1334
+ }
1335
+ if (evaluateIteration) {
1336
+ // `:::iterate` / `:::pivot` — the body is the per-cell TEMPLATE (captured
1337
+ // raw via `rawBodyNames`); cells render through a nested `MarkdownPreview`
1338
+ // that inherits the dialect features (depth-capped against runaway loops).
1339
+ const iterationDirective = (name: "iterate" | "pivot"): MarkdownDirectiveRenderer => ({
1340
+ name,
1341
+ kinds: ["container"],
1342
+ render: ({ attributes, rawBody }) => (
1343
+ <IterationDirective
1344
+ spec={specFromDirective(name, attributes, rawBody)}
1345
+ evaluate={evaluateIteration}
1346
+ interpolate={interpolate}
1347
+ // Cells render through a nested preview that inherits the dialect
1348
+ // features. Extracted to `IterationCell` so `MarkdownPreview` isn't
1349
+ // referenced inside its own initializer (TS2786 / forwardRef cycle).
1350
+ renderCell={(md) => (
1351
+ <IterationCell
1352
+ markdown={md}
1353
+ config={{
1354
+ evaluateIteration,
1355
+ interpolate,
1356
+ evaluate,
1357
+ extensions,
1358
+ footnotes,
1359
+ math,
1360
+ resolveCitation,
1361
+ citationStyle,
1362
+ }}
1363
+ />
1364
+ )}
1365
+ />
1366
+ ),
1367
+ });
1368
+ directives.set("iterate", iterationDirective("iterate"));
1369
+ directives.set("pivot", iterationDirective("pivot"));
1370
+ }
1371
+ if (evaluate && !fences.has("calc")) {
1372
+ fences.set("calc", {
1373
+ lang: "calc",
1374
+ render: ({ source }) => <CalcBlock source={source} evaluate={evaluate} />,
1375
+ });
1376
+ }
1377
+ if (evaluate && !directives.has("calc")) {
1378
+ directives.set("calc", {
1379
+ name: "calc",
1380
+ kinds: ["inline"],
1381
+ // `textValue` is the verbatim expression (markdown chars preserved);
1382
+ // fall back to the rendered label only if positions were unavailable.
1383
+ render: ({ textValue, children }) => (
1384
+ <CalcInline source={textValue ?? flattenNodeText(children)} evaluate={evaluate} />
1385
+ ),
1386
+ });
1387
+ }
1388
+ return { directives, fences };
1389
+ }, [
1390
+ extensions,
1391
+ evaluate,
1392
+ toc,
1393
+ resolveCitation,
1394
+ citationStyle,
1395
+ evaluateIteration,
1396
+ interpolate,
1397
+ footnotes,
1398
+ math,
1399
+ ]);
1400
+
1401
+ // Stable key over the registered directive NAMES (space-joined): the parser only
1402
+ // needs the known-set, so the plugin array rebuilds on name changes, not on a
1403
+ // new `extensions` identity each render.
1404
+ // `calc` joins the set when `evaluate` is supplied (so `:calc[…]` parses);
1405
+ // `toc` / `bibliography` / `references` join when those features are enabled.
1406
+ const directiveNamesKey = [
1407
+ ...(extensions?.directives ?? []).map((d) => d.name),
1408
+ ...(evaluate ? ["calc"] : []),
1409
+ ...(toc ? ["toc"] : []),
1410
+ ...(resolveCitation ? ["bibliography", "references"] : []),
1411
+ ...(evaluateIteration ? ["iterate", "pivot"] : []),
1412
+ ].join(" ");
1413
+
1414
+ const plugins = useMemo<PluggableList>(() => {
1415
+ const directiveNames = directiveNamesKey ? directiveNamesKey.split(" ") : [];
1416
+ // `:::iterate`/`:::pivot` bodies are captured RAW (as templates) rather than
1417
+ // pre-rendered — so they don't render their `{{token}}` source before interpolation.
1418
+ const rawBodyNames = evaluateIteration ? ["iterate", "pivot"] : [];
1419
+ let list: PluggableList = [
1420
+ ...baseRemarkPlugins,
1421
+ ...buildMarkdownPlugins({ directiveNames, rawBodyNames }),
1422
+ ];
1423
+ // Academic transforms run after the directive pipeline. `remarkMath` must
1424
+ // precede `remarkBrandMath` (it produces the math nodes the latter rewrites).
1425
+ if (math) list = [...list, remarkMath, remarkBrandMath];
1426
+ if (footnotes) list = [...list, remarkBrandFootnotes];
1427
+ if (resolveCitation) list = [...list, remarkBrandCitations];
1428
+ // Wikilinks and transclusions are added BEFORE resolveUrl so any href they
1429
+ // produce (wikilinks) flows through the URL resolver as a normal link would.
1430
+ // Transclusion embeds produce raw HTML nodes (not mdast links) so order
1431
+ // relative to resolveUrl is irrelevant, but we keep them together for clarity.
1432
+ if (resolveWikilink) list = [...list, remarkResolveWikilinks(resolveWikilink)];
1433
+ if (resolveTransclusion) list = [...list, remarkResolveTransclusions()];
1434
+ if (resolveUrl) list = [...list, remarkResolveUrls(resolveUrl)];
1435
+ return list;
1436
+ // `directiveNamesKey` already folds in `calc`/`toc`/`bibliography` when those
1437
+ // are set, so the plugin array tracks them without depending on identities.
1438
+ }, [
1439
+ resolveUrl,
1440
+ resolveWikilink,
1441
+ resolveTransclusion,
1442
+ directiveNamesKey,
1443
+ math,
1444
+ footnotes,
1445
+ resolveCitation,
1446
+ evaluateIteration,
1447
+ ]);
1448
+
1449
+ const streamdown = (
1450
+ <Streamdown
1451
+ // Render as ONE block. Streamdown's default block-splitter (a streaming
1452
+ // optimization) severs a multi-line `:::` container directive from its
1453
+ // child content (e.g. a `:::timeline` from its list), so the directive
1454
+ // arrives empty. The preview re-renders the whole doc anyway, so a single
1455
+ // block is both correct and fine for authoring-sized documents.
1456
+ parseMarkdownIntoBlocksFn={singleBlock}
1457
+ remarkPlugins={plugins}
1458
+ rehypePlugins={rehypePlugins}
1459
+ allowedTags={allowedTags}
1460
+ components={components}
1461
+ >
1462
+ {markdown}
1463
+ </Streamdown>
1464
+ );
1465
+ // Academic contexts wrap the renderer only when their feature is on, so inline
1466
+ // cites + the bibliography share numbering and `::toc` reads the heading slugs.
1467
+ const withCitations = citations ? (
1468
+ <CitationProvider order={citations.order} byKey={citations.byKey} style={citationStyle}>
1469
+ {streamdown}
1470
+ </CitationProvider>
1471
+ ) : (
1472
+ streamdown
1473
+ );
1474
+ const body = outline ? (
1475
+ <TocProvider items={outline}>{withCitations}</TocProvider>
1476
+ ) : (
1477
+ withCitations
1478
+ );
1479
+
1480
+ return (
1481
+ <div
1482
+ ref={ref}
1483
+ data-testid="markdown-preview"
1484
+ // Reading rhythm (proximity grammar): headings carry 2–2.5× the space
1485
+ // ABOVE vs below — uniform block spacing reads like a teleprinter. The
1486
+ // `!` beats Streamdown's internal space-y sibling rule.
1487
+ className={cn(
1488
+ "text-body text-foreground [&_pre]:my-3",
1489
+ "[&_h1]:!mt-10 [&_h2]:!mt-9 [&_h3]:!mt-7 [&_h4]:!mt-6",
1490
+ "[&_:is(h1,h2,h3,h4)+*]:!mt-3 [&_:is(h1,h2,h3,h4):first-child]:!mt-0",
1491
+ className,
1492
+ )}
1493
+ {...props}
1494
+ >
1495
+ <AnnotationsContext.Provider value={shifted}>
1496
+ <SearchContext.Provider value={search}>
1497
+ <HeadingActionsContext.Provider value={headingActions ?? null}>
1498
+ <RegistryContext.Provider value={registry}>
1499
+ <LinkPreviewContext.Provider value={renderLinkPreview ?? null}>
1500
+ <TransclusionResolverContext.Provider value={resolveTransclusion ?? null}>
1501
+ <TransclusionDepthContext.Provider value={0}>
1502
+ {body}
1503
+ </TransclusionDepthContext.Provider>
1504
+ </TransclusionResolverContext.Provider>
1505
+ </LinkPreviewContext.Provider>
1506
+ </RegistryContext.Provider>
1507
+ </HeadingActionsContext.Provider>
1508
+ </SearchContext.Provider>
1509
+ </AnnotationsContext.Provider>
1510
+ </div>
1511
+ );
1512
+ },
1513
+ );
1514
+
1515
+ /** The dialect features an iterated cell's nested preview inherits. */
1516
+ interface IterationCellConfig {
1517
+ evaluateIteration?: EvaluateIteration;
1518
+ interpolate?: InterpolateTemplate;
1519
+ evaluate?: EvaluateCalc;
1520
+ extensions?: MarkdownExtensions;
1521
+ footnotes?: boolean;
1522
+ math?: boolean;
1523
+ resolveCitation?: ResolveCitation;
1524
+ citationStyle: CitationStyle;
1525
+ }
1526
+
1527
+ /**
1528
+ * Renders one `:::iterate` cell's resolved markdown via a nested `MarkdownPreview`.
1529
+ * Defined OUTSIDE `MarkdownPreview` so the component isn't referenced inside its
1530
+ * own initializer (the forwardRef self-reference TS2786 — same reason transclusion
1531
+ * renders its own pipeline).
1532
+ */
1533
+ function IterationCell({ markdown, config }: { markdown: string; config: IterationCellConfig }) {
1534
+ return (
1535
+ <MarkdownPreview stripFrontmatter={false} {...config}>
1536
+ {markdown}
1537
+ </MarkdownPreview>
1538
+ );
1539
+ }
1540
+
1541
+ export type { MarkdownAnnotation, MarkdownAnnotationKind } from "../lib/markdown/diff";