Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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