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