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