g1t/apps/web/app/components/markdown.tsx
| 1 | import { |
| 2 | AlertTriangle, |
| 3 | Check, |
| 4 | Copy, |
| 5 | Info, |
| 6 | Lightbulb, |
| 7 | Link2, |
| 8 | MessageSquareWarning, |
| 9 | OctagonAlert, |
| 10 | } from "lucide-react"; |
| 11 | import { type ReactNode, isValidElement, useEffect, useRef, useState } from "react"; |
| 12 | import ReactMarkdown from "react-markdown"; |
| 13 | import { Link } from "react-router"; |
| 14 | import rehypeRaw from "rehype-raw"; |
| 15 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; |
| 16 | import remarkGfm from "remark-gfm"; |
| 17 | |
| 18 | import { Checkbox } from "./ui/checkbox"; |
| 19 | import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins"; |
| 20 | |
| 21 | /** The text inside a React tree, for anchors and copying. */ |
| 22 | function 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. */ |
| 31 | function 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. */ |
| 39 | const 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 | |
| 48 | const 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 | |
| 60 | function 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 | */ |
| 83 | function 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 | |
| 146 | function 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 | */ |
| 158 | export 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 | } |