-
Notifications
You must be signed in to change notification settings - Fork 264
Expand file tree
/
Copy pathmarkdownRenderer.tsx
More file actions
268 lines (241 loc) · 10.4 KB
/
markdownRenderer.tsx
File metadata and controls
268 lines (241 loc) · 10.4 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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
'use client';
import { CodeSnippet } from '@/app/components/codeSnippet';
import { SearchQueryParams } from '@/lib/types';
import { cn, createPathWithQueryParams } from '@/lib/utils';
import type { Element, Root } from "hast";
import { Schema as SanitizeSchema } from 'hast-util-sanitize';
import { CopyIcon, ExternalLinkIcon, SearchIcon } from 'lucide-react';
import type { Heading, Nodes } from "mdast";
import { findAndReplace } from 'mdast-util-find-and-replace';
import { useRouter } from 'next/navigation';
import React, { useCallback, useMemo, forwardRef, memo } from 'react';
import Markdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import type { PluggableList, Plugin } from "unified";
import { visit } from 'unist-util-visit';
import { CodeBlock } from './codeBlock';
import { FILE_REFERENCE_REGEX } from '@/features/chat/constants';
import { createFileReference } from '@/features/chat/utils';
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
import isEqual from "fast-deep-equal/react";
export const REFERENCE_PAYLOAD_ATTRIBUTE = 'data-reference-payload';
const annotateCodeBlocks: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node, _index, parent) => {
if (node.tagName !== 'code' || !parent || !('tagName' in parent)) {
return;
}
if (parent.tagName === 'pre') {
node.properties.isBlock = true;
parent.properties.isBlock = true;
} else {
node.properties.isBlock = false;
}
})
}
}
// @see: https://unifiedjs.com/learn/guide/create-a-remark-plugin/
function remarkReferencesPlugin() {
return function (tree: Nodes) {
findAndReplace(tree, [
FILE_REFERENCE_REGEX,
(_, repo: string, fileName: string, startLine?: string, endLine?: string) => {
// Create display text
let displayText = fileName.split('/').pop() ?? fileName;
const fileReference = createFileReference({
repo: repo,
path: fileName,
startLine,
endLine,
});
if (fileReference.range) {
displayText += `:${fileReference.range.startLine}-${fileReference.range.endLine}`;
}
return {
type: 'html',
// @note: if you add additional attributes to this span, make sure to update the rehypeSanitize plugin to allow them.
//
// @note: we attach the reference id to the DOM element as a class name since there may be multiple reference elements
// with the same id (i.e., referencing the same file & range).
value: `<span
role="button"
class="${fileReference.id}"
className="font-mono cursor-pointer text-xs px-1 py-[1.5px] rounded-md transition-all duration-150 bg-chat-citation"
title="Click to navigate to code"
${REFERENCE_PAYLOAD_ATTRIBUTE}="${encodeURIComponent(JSON.stringify(fileReference))}"
>${displayText}</span>`
}
}
])
}
}
/**
* A remark plugin that converts `html` MDAST nodes into `text` nodes,
* preserving angle-bracketed content like `<id>` as visible text. Without this,
* `<id>` is parsed as an HTML tag and then stripped by sanitization.
*
* This plugin must run BEFORE remarkReferencesPlugin so that the file-reference
* HTML nodes created by that plugin are left intact for rehypeRaw to process.
*/
function remarkPreserveHtml() {
return function (tree: Nodes) {
visit(tree, 'html', (node, index, parent) => {
if (index !== undefined && parent && 'children' in parent) {
(parent.children as Nodes[])[index] = {
type: 'text',
value: (node as { value: string }).value,
};
}
});
};
}
const remarkTocExtractor = () => {
return function (tree: Nodes) {
visit(tree, 'heading', (node: Heading) => {
const textContent = node.children
.filter((child) => child.type === 'text')
.map((child) => child.value)
.join('');
const id = textContent.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, '-');
// Add id to the heading node for linking
node.data = node.data || {};
node.data.hProperties = node.data.hProperties || {};
node.data.hProperties.id = id;
});
};
}
interface MarkdownRendererProps {
content: string;
className?: string;
/**
* When true, angle-bracketed text like `<id>` is preserved as visible text
* instead of being parsed as HTML. File references (@file:{...}) are unaffected.
*/
escapeHtml?: boolean;
}
const MarkdownRendererComponent = forwardRef<HTMLDivElement, MarkdownRendererProps>(({ content, className, escapeHtml = false }, ref) => {
const router = useRouter();
const remarkPlugins = useMemo((): PluggableList => {
return [
remarkGfm,
...(escapeHtml ? [remarkPreserveHtml] : []),
remarkReferencesPlugin,
remarkTocExtractor,
];
}, [escapeHtml]);
const rehypePlugins = useMemo((): PluggableList => {
return [
rehypeRaw,
[
rehypeSanitize,
{
...defaultSchema,
attributes: {
...defaultSchema.attributes,
span: [...(defaultSchema.attributes?.span ?? []), 'role', 'className', 'data*'],
},
strip: [],
} satisfies SanitizeSchema,
],
annotateCodeBlocks,
];
}, []);
const renderPre = useCallback(({ children, node, ...rest }: React.JSX.IntrinsicElements['pre'] & { node?: Element }) => {
if (node?.properties && node.properties.isBlock === true) {
return children;
}
return (
<pre {...rest}>
{children}
</pre>
)
}, []);
const renderAnchor = useCallback(({ href, children, ...rest }: React.JSX.IntrinsicElements['a']) => {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5"
{...rest}
>
{children}
<ExternalLinkIcon className="inline w-3 h-3 mb-0.5 opacity-60" />
</a>
);
}, []);
const renderCode = useCallback(({ className, children, node, ...rest }: React.JSX.IntrinsicElements['code'] & { node?: Element }) => {
const text = children?.toString().trimEnd() ?? '';
if (node?.properties && node.properties.isBlock === true) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : undefined;
return (
<CodeBlock
code={text}
language={language}
/>
)
}
return (
<span className="group/code relative inline-block [text-decoration:inherit]">
<CodeSnippet
className={className}
{...rest}
>
{children}
</CodeSnippet>
<span className="absolute z-20 bottom-0 left-0 transform translate-y-full opacity-0 group-hover/code:opacity-100 hover:opacity-100 transition-all delay-300 duration-100 pointer-events-none group-hover/code:pointer-events-auto hover:pointer-events-auto block">
{/* Invisible bridge to prevent hover gap */}
<span className="absolute -top-2 left-0 right-0 h-2 block"></span>
<span className="bg-background border rounded-md p-0.5 flex gap-0.5">
<button
className="flex items-center justify-center w-5 h-5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors duration-150"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const url = createPathWithQueryParams(`/${SINGLE_TENANT_ORG_DOMAIN}/search`, [SearchQueryParams.query, `"${text}"`])
router.push(url);
}}
title="Search for snippet"
>
<SearchIcon className="w-3 h-3" />
</button>
<button
className="flex items-center justify-center w-5 h-5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors duration-150"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(text);
}}
title="Copy snippet"
>
<CopyIcon className="w-3 h-3" />
</button>
</span>
</span>
</span>
)
}, [router]);
return (
<div
ref={ref}
className={cn("prose dark:prose-invert prose-p:text-foreground prose-li:text-foreground prose-li:marker:text-foreground prose-headings:mt-6 prose-ol:mt-3 prose-ul:mt-3 prose-p:mb-3 prose-code:before:content-none prose-code:after:content-none prose-hr:my-5 max-w-none [&>*:first-child]:mt-0", className)}
>
<Markdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={{
pre: renderPre,
code: renderCode,
a: renderAnchor,
}}
>
{content}
</Markdown>
</div>
);
});
MarkdownRendererComponent.displayName = 'MarkdownRenderer';
export const MarkdownRenderer = memo(MarkdownRendererComponent, isEqual);