-
Notifications
You must be signed in to change notification settings - Fork 113
Expand file tree
/
Copy pathMarkdownCore.tsx
More file actions
161 lines (152 loc) · 5.86 KB
/
MarkdownCore.tsx
File metadata and controls
161 lines (152 loc) · 5.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
import React, { useMemo } from "react";
import { Streamdown } from "streamdown";
import type { Pluggable } from "unified";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkBreaks from "remark-breaks";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { harden } from "rehype-harden";
import "katex/dist/katex.min.css";
import { normalizeMarkdown } from "./MarkdownStyles";
import { markdownComponents } from "./MarkdownComponents";
import { INTERNAL_INLINE_SKILL_HREF_PREFIX, remarkInlineSkillLinks } from "./inlineSkillMarkdown";
interface MarkdownCoreProps {
content: string;
children?: React.ReactNode; // For cursor or other additions
/**
* Enable incomplete markdown parsing for streaming content.
* When true, the remend library will attempt to "repair" unclosed markdown
* syntax (e.g., adding closing ** for bold). This is useful during streaming
* but can cause bugs with content like $__variable (adds trailing __).
* Default: false for completed content, true during streaming.
*/
parseIncompleteMarkdown?: boolean;
/**
* Preserve single newlines as line breaks (like GitHub-flavored markdown).
* When true, single newlines in text become <br> elements instead of being
* collapsed to spaces. Useful for user-authored content where newlines
* are intentional. Default: false.
*/
preserveLineBreaks?: boolean;
}
// Plugin arrays are defined at module scope to maintain stable references.
// Streamdown treats new array references as changes requiring full re-parse.
const REMARK_PLUGINS: Pluggable[] = [
[remarkGfm, {}],
[remarkMath, { singleDollarTextMath: false }],
remarkInlineSkillLinks,
];
// Same as above, but with remarkBreaks to preserve single newlines as <br>.
// Used for user-authored content where newlines are intentional (e.g., user messages).
const REMARK_PLUGINS_WITH_BREAKS: Pluggable[] = [
[remarkGfm, {}],
remarkBreaks,
[remarkMath, { singleDollarTextMath: false }],
remarkInlineSkillLinks,
];
const INTERNAL_INLINE_SKILL_SANITIZE_PROTOCOL = INTERNAL_INLINE_SKILL_HREF_PREFIX.slice(0, -1);
// Schema for rehype-sanitize that allows safe HTML elements.
// Extends the default schema to support KaTeX math and collapsible sections.
const sanitizeSchema = {
...defaultSchema,
tagNames: [
...(defaultSchema.tagNames ?? []),
// KaTeX MathML elements
"math",
"mrow",
"mi",
"mo",
"mn",
"msup",
"msub",
"mfrac",
"munder",
"mover",
"mtable",
"mtr",
"mtd",
"mspace",
"mtext",
"semantics",
"annotation",
"munderover",
"msqrt",
"mroot",
"mpadded",
"mphantom",
"menclose",
// Collapsible sections (GitHub-style)
"details",
"summary",
],
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), INTERNAL_INLINE_SKILL_SANITIZE_PROTOCOL],
},
attributes: {
...defaultSchema.attributes,
// KaTeX uses style for coloring and positioning
span: [...(defaultSchema.attributes?.span ?? []), "style"],
// MathML elements need various attributes
math: ["xmlns", "display"],
annotation: ["encoding"],
// Allow class on all elements for styling
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "class"],
},
};
const REHYPE_PLUGINS: Pluggable[] = [
rehypeRaw, // Parse HTML elements first
[rehypeSanitize, sanitizeSchema], // Sanitize HTML to prevent XSS (strips dangerous elements/attributes)
[
harden, // Additional URL filtering for links and images
{
// SECURITY: Treat markdown content as untrusted. We rely on rehype-harden to
// block dangerous URL schemes (e.g. javascript:, file:, vbscript:, data: in
// links). Data images are allowed explicitly below.
allowedImagePrefixes: ["*", "/"],
allowedLinkPrefixes: ["*"],
allowedProtocols: [INTERNAL_INLINE_SKILL_HREF_PREFIX],
// rehype-harden requires a defaultOrigin when any allowlist is provided.
// We use a stable placeholder origin so relative URLs can be resolved.
defaultOrigin: "https://mux.invalid",
allowDataImages: true,
},
],
[rehypeKatex, { errorColor: "var(--color-muted-foreground)" }], // Render math
];
/**
* Core markdown rendering component that handles all markdown processing.
* This is the single source of truth for markdown configuration.
*
* Memoized to prevent expensive re-parsing when content hasn't changed.
*/
export const MarkdownCore = React.memo<MarkdownCoreProps>(
({ content, children, parseIncompleteMarkdown = false, preserveLineBreaks = false }) => {
// Memoize the normalized content to avoid recalculating on every render
const normalizedContent = useMemo(() => normalizeMarkdown(content), [content]);
return (
<>
<Streamdown
components={markdownComponents}
remarkPlugins={preserveLineBreaks ? REMARK_PLUGINS_WITH_BREAKS : REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
parseIncompleteMarkdown={parseIncompleteMarkdown}
// Use "static" mode for completed content to bypass useTransition() deferral.
// After ORPC migration, async event boundaries let React deprioritize transitions indefinitely.
mode={parseIncompleteMarkdown ? "streaming" : "static"}
// space-y-2: reduce from default space-y-4 (16px) to space-y-2 (8px).
// The viewport-aware fade-in is handled by the parent .markdown-content
// element (mask-image gradient gated on data-streaming); see globals.css.
className="space-y-2"
controls={{ table: false, code: true, mermaid: true }} // Disable table copy/download, keep code/mermaid controls
>
{normalizedContent}
</Streamdown>
{children}
</>
);
}
);
MarkdownCore.displayName = "MarkdownCore";