pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/components/markdown.tsx

240 lines9,087 bytesCodeBlame
1import {
2 AlertTriangle,
3 Check,
4 Copy,
5 Info,
6 Lightbulb,
7 Link2,
8 MessageSquareWarning,
9 OctagonAlert,
10} from "lucide-react";
11import { type ReactNode, isValidElement, useEffect, useRef, useState } from "react";
12import ReactMarkdown from "react-markdown";
13import { Link } from "react-router";
14import rehypeRaw from "rehype-raw";
15import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
16import remarkGfm from "remark-gfm";
17
18import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins";
19
20/** The text inside a React tree, for anchors and copying. */
21function textOf(node: ReactNode): string {
22 if (node == null || typeof node === "boolean") return "";
23 if (typeof node === "string" || typeof node === "number") return String(node);
24 if (Array.isArray(node)) return node.map(textOf).join("");
25 if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);
26 return "";
27}
28
29/** An anchor id for a heading, so sections can be linked to. */
30function slug(children: ReactNode): string {
31 return textOf(children)
32 .toLowerCase()
33 .replace(/[^a-z0-9]+/g, "-")
34 .replace(/^-|-$/g, "");
35}
36
37/** What raw HTML may stay: GitHub's own allow-list, as `rehype-sanitize` ships it. */
38const SCHEMA = {
39 ...defaultSchema,
40 attributes: {
41 ...defaultSchema.attributes,
42 // Fenced blocks say their language in a class.
43 code: [...(defaultSchema.attributes?.code ?? []), ["className", /^language-./]],
44 },
45};
46
47const ALERT: Record<AlertKind, { title: string; icon: ReactNode; tone: string }> = {
48 note: { title: "Note", icon: <Info size={15} />, tone: "border-info/60 [&_.alert-title]:text-info" },
49 tip: { title: "Tip", icon: <Lightbulb size={15} />, tone: "border-accent/60 [&_.alert-title]:text-accent" },
50 important: {
51 title: "Important",
52 icon: <MessageSquareWarning size={15} />,
53 tone: "border-merged/60 [&_.alert-title]:text-merged",
54 },
55 warning: { title: "Warning", icon: <AlertTriangle size={15} />, tone: "border-warn/60 [&_.alert-title]:text-warn" },
56 caution: { title: "Caution", icon: <OctagonAlert size={15} />, tone: "border-danger/60 [&_.alert-title]:text-danger" },
57};
58
59function Heading({ level, children }: { level: 1 | 2 | 3 | 4; children: ReactNode }) {
60 const id = slug(children);
61 const Tag = `h${level}` as const;
62 return (
63 <Tag id={id} className="group relative scroll-mt-20">
64 {children}
65 {id && (
66 <a
67 href={`#${id}`}
68 aria-label="Link to this section"
69 className="ml-2 inline-flex align-middle text-faint no-underline opacity-0 transition-opacity group-hover:opacity-100 hover:text-fg"
70 >
71 <Link2 size={14} />
72 </a>
73 )}
74 </Tag>
75 );
76}
77
78/**
79 * A fenced code block: plain at once, coloured in the browser when its
80 * language is known, with a button to copy it.
81 */
82function CodeBlock({ language, code }: { language: string | null; code: string }) {
83 const [html, setHtml] = useState<string[] | null>(null);
84 const [copied, setCopied] = useState(false);
85 const block = useRef<HTMLPreElement>(null);
86 useEffect(() => {
87 if (!language) return;
88 let cancelled = false;
89 const element = block.current;
90 if (!element) return;
91 const observer = new IntersectionObserver(
92 ([entry]) => {
93 if (!entry?.isIntersecting) return;
94 observer.disconnect();
95 void import("../lib/shiki")
96 .then(async ({ getHighlighter, languageNamed, linesToHtml }) => {
97 const lang = languageNamed(language);
98 if (!lang) return null;
99 return linesToHtml(await getHighlighter(), code, lang);
100 })
101 .then((lines) => {
102 if (!cancelled && lines) setHtml(lines);
103 })
104 .catch(() => {});
105 },
106 { rootMargin: "400px 0px" },
107 );
108 observer.observe(element);
109 return () => {
110 cancelled = true;
111 observer.disconnect();
112 };
113 }, [language, code]);
114 return (
115 <div className="group relative">
116 <pre ref={block}>
117 <code>
118 {html
119 ? html.map((line, index) => (
120 <span key={index} className="block" dangerouslySetInnerHTML={{ __html: line || " " }} />
121 ))
122 : code}
123 </code>
124 </pre>
125 <div className="absolute top-2 right-2 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
126 {language && <span className="font-mono text-[0.6875rem] text-faint">{language}</span>}
127 <button
128 type="button"
129 aria-label="Copy"
130 onClick={() => {
131 void navigator.clipboard?.writeText(code).then(() => {
132 setCopied(true);
133 setTimeout(() => setCopied(false), 1500);
134 });
135 }}
136 className="rounded-md border border-line bg-raised p-1.5 text-muted hover:text-fg"
137 >
138 {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />}
139 </button>
140 </div>
141 </div>
142 );
143}
144
145function isExternal(href: string) {
146 return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//");
147}
148
149/**
150 * Renders markdown the way people expect from a forge: GitHub flavoured
151 * markdown (tables, task lists, footnotes, strikethrough, autolinks), the
152 * HTML GitHub allows, alerts, highlighted code, heading anchors, and
153 * references (`#12`, `owner/repo#12`, `@name`, commit hashes) linked
154 * within `repo`. Anything an author writes is sanitized, so untrusted
155 * content is safe to pass in.
156 */
157export function Markdown({
158 source,
159 repo,
160 base,
161}: {
162 source: string;
163 /** The repository the text belongs to, for its references. */
164 repo?: MarkdownRepo;
165 /** Where relative links point, e.g. `/acme/web/blob/main/docs` for a file's own folder. */
166 base?: string;
167}) {
168 return (
169 <div className="prose">
170 <ReactMarkdown
171 remarkPlugins={[remarkGfm]}
172 rehypePlugins={[rehypeRaw, [rehypeSanitize, SCHEMA], rehypeAlerts, [rehypeReferences, { repo }]]}
173 components={{
174 h1: ({ children }) => <Heading level={1}>{children}</Heading>,
175 h2: ({ children }) => <Heading level={2}>{children}</Heading>,
176 h3: ({ children }) => <Heading level={3}>{children}</Heading>,
177 h4: ({ children }) => <Heading level={4}>{children}</Heading>,
178 a({ href = "", children, node }) {
179 const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef;
180 if (ref) {
181 return (
182 <Link
183 to={href}
184 prefetch="intent"
185 className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""}
186 >
187 {children}
188 </Link>
189 );
190 }
191 if (href.startsWith("#")) return <a href={href}>{children}</a>;
192 if (isExternal(href)) {
193 return (
194 <a href={href} rel="noreferrer nofollow ugc" target="_blank">
195 {children}
196 </a>
197 );
198 }
199 // A link within the site, or relative to the document's folder.
200 const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`;
201 return <Link to={to}>{children}</Link>;
202 },
203 blockquote({ children, node }) {
204 const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert;
205 if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>;
206 const alert = ALERT[kind];
207 return (
208 <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}>
209 <p className="alert-title flex items-center gap-2 text-sm font-medium">
210 {alert.icon}
211 {alert.title}
212 </p>
213 <div className="mt-1 [&>*+*]:mt-3">{children}</div>
214 </div>
215 );
216 },
217 pre({ children }) {
218 const code = Array.isArray(children) ? children[0] : children;
219 if (isValidElement<{ className?: string; children?: ReactNode }>(code)) {
220 const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null;
221 return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />;
222 }
223 return <pre>{children}</pre>;
224 },
225 input({ type, checked, disabled }) {
226 // Task list boxes: shown, not editable.
227 return type === "checkbox" ? (
228 <input type="checkbox" checked={checked} disabled={disabled} readOnly className="mr-1.5 translate-y-0.5 accent-accent" />
229 ) : null;
230 },
231 img({ src, alt }) {
232 return <img src={typeof src === "string" ? src : undefined} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />;
233 },
234 }}
235 >
236 {source}
237 </ReactMarkdown>
238 </div>
239 );
240}