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