flagon-io/g1t

public

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

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

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