| 10 | 10 | | } from "lucide-react"; |
| 11 | 11 | | import type { Root } from "hast"; |
| 12 | 12 | | import type { Components } from "react-markdown"; |
| 13 | | − | import { type ReactNode, isValidElement, useEffect, useMemo, useRef, useState } from "react"; |
| 13 | + | import { type ReactNode, createContext, isValidElement, useContext, useEffect, useMemo, useRef, useState } from "react"; |
| 14 | 14 | | import { Link } from "react-router"; |
| 15 | 15 | | import rehypeRaw from "rehype-raw"; |
| 16 | 16 | | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; |
| ⋯ |
| 206 | 206 | | }) { |
| 207 | 207 | | // The same text renders to the same elements with the same components, so |
| 208 | 208 | | // a page that revalidates (a live refresh) leaves the text as it is |
| 209 | | − | // rather than mounting it again and reloading its pictures. |
| 209 | + | // rather than mounting it again and reloading its pictures. The |
| 210 | + | // components never change: where links and pictures point comes from |
| 211 | + | // context, so a new commit (a README's pictures are that commit's files) |
| 212 | + | // updates their addresses in place rather than replacing every element. |
| 210 | 213 | | const tree = useMemo(() => treeOf(source, repo), [source, repo?.namespace, repo?.name]); |
| 211 | | − | const components = useMemo(() => markdownComponents(base, rawBase), [base, rawBase]); |
| 212 | | − | const rendered = useMemo(() => renderMarkdownTree(tree, components), [tree, components]); |
| 214 | + | const rendered = useMemo(() => renderMarkdownTree(tree, COMPONENTS), [tree]); |
| 215 | + | const bases = useMemo(() => ({ base, rawBase }), [base, rawBase]); |
| 213 | 216 | | return ( |
| 214 | | − | // A long word or address breaks rather than widening the page. |
| 215 | | − | <div className="prose wrap-break-word">{rendered}</div> |
| 217 | + | <MarkdownBases.Provider value={bases}> |
| 218 | + | {/* A long word or address breaks rather than widening the page. */} |
| 219 | + | <div className="prose wrap-break-word">{rendered}</div> |
| 220 | + | </MarkdownBases.Provider> |
| 216 | 221 | | ); |
| 217 | 222 | | } |
| 218 | 223 | | |
| 219 | | − | /** How each element is drawn: links relative to `base`, pictures under `rawBase`. */ |
| 220 | | − | function markdownComponents(base: string | undefined, rawBase: string | undefined): Components { |
| 221 | | − | return { |
| 222 | | − | h1: ({ children }) => <Heading level={1}>{children}</Heading>, |
| 223 | | − | h2: ({ children }) => <Heading level={2}>{children}</Heading>, |
| 224 | | − | h3: ({ children }) => <Heading level={3}>{children}</Heading>, |
| 225 | | − | h4: ({ children }) => <Heading level={4}>{children}</Heading>, |
| 226 | | − | a({ href = "", children, node }) { |
| 227 | | − | const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef; |
| 228 | | − | if (ref === "mention") { |
| 229 | | − | // `@name` may be a person (with a card) or a workspace (none). |
| 230 | | − | const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, ""); |
| 231 | | − | return ( |
| 232 | | − | <UserCard username={name}> |
| 233 | | − | <Link to={href} prefetch="intent" className="font-medium"> |
| 234 | | − | {children} |
| 235 | | − | </Link> |
| 236 | | − | </UserCard> |
| 237 | | − | ); |
| 238 | | − | } |
| 239 | | − | if (ref) { |
| 240 | | − | return ( |
| 241 | | − | <Link |
| 242 | | − | to={href} |
| 243 | | − | prefetch="intent" |
| 244 | | − | className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""} |
| 245 | | − | > |
| 246 | | − | {children} |
| 247 | | − | </Link> |
| 248 | | − | ); |
| 249 | | − | } |
| 250 | | − | if (href.startsWith("#")) return <a href={href}>{children}</a>; |
| 251 | | − | if (isExternal(href)) { |
| 252 | | − | return ( |
| 253 | | − | <a href={href} rel="noreferrer nofollow ugc" target="_blank"> |
| 254 | | − | {children} |
| 255 | | − | </a> |
| 256 | | − | ); |
| 257 | | − | } |
| 258 | | − | // A link within the site, or relative to the document's folder. |
| 259 | | − | const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`; |
| 260 | | − | return <Link to={to}>{children}</Link>; |
| 261 | | − | }, |
| 262 | | − | blockquote({ children, node }) { |
| 263 | | − | const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert; |
| 264 | | − | if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>; |
| 265 | | − | const alert = ALERT[kind]; |
| 266 | | − | return ( |
| 267 | | − | <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}> |
| 268 | | − | <p className="alert-title flex items-center gap-2 text-sm font-medium"> |
| 269 | | − | {alert.icon} |
| 270 | | − | {alert.title} |
| 271 | | − | </p> |
| 272 | | − | <div className="mt-1 [&>*+*]:mt-3">{children}</div> |
| 273 | | − | </div> |
| 274 | | − | ); |
| 275 | | − | }, |
| 276 | | − | pre({ children }) { |
| 277 | | − | const code = Array.isArray(children) ? children[0] : children; |
| 278 | | − | if (isValidElement<{ className?: string; children?: ReactNode }>(code)) { |
| 279 | | − | const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null; |
| 280 | | − | return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />; |
| 281 | | − | } |
| 282 | | − | return <pre>{children}</pre>; |
| 283 | | − | }, |
| 284 | | − | // A wide table scrolls on its own, inside the text, not the page. |
| 285 | | − | table: ({ children }) => ( |
| 286 | | − | <div className="overflow-x-auto"> |
| 287 | | − | <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table> |
| 224 | + | /** Where a document's relative links (`base`) and pictures (`rawBase`) point. */ |
| 225 | + | const MarkdownBases = createContext<{ base?: string; rawBase?: string }>({}); |
| 226 | + | |
| 227 | + | /** A link: a reference or mention within the site, an anchor, an address elsewhere, or a file beside the document. */ |
| 228 | + | function MarkdownLink({ href = "", children, node }: { href?: string; children?: ReactNode; node?: { properties?: unknown } }) { |
| 229 | + | const { base } = useContext(MarkdownBases); |
| 230 | + | const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef; |
| 231 | + | if (ref === "mention") { |
| 232 | + | // `@name` may be a person (with a card) or a workspace (none). |
| 233 | + | const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, ""); |
| 234 | + | return ( |
| 235 | + | <UserCard username={name}> |
| 236 | + | <Link to={href} prefetch="intent" className="font-medium"> |
| 237 | + | {children} |
| 238 | + | </Link> |
| 239 | + | </UserCard> |
| 240 | + | ); |
| 241 | + | } |
| 242 | + | if (ref) { |
| 243 | + | return ( |
| 244 | + | <Link |
| 245 | + | to={href} |
| 246 | + | prefetch="intent" |
| 247 | + | className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""} |
| 248 | + | > |
| 249 | + | {children} |
| 250 | + | </Link> |
| 251 | + | ); |
| 252 | + | } |
| 253 | + | if (href.startsWith("#")) return <a href={href}>{children}</a>; |
| 254 | + | if (isExternal(href)) { |
| 255 | + | return ( |
| 256 | + | <a href={href} rel="noreferrer nofollow ugc" target="_blank"> |
| 257 | + | {children} |
| 258 | + | </a> |
| 259 | + | ); |
| 260 | + | } |
| 261 | + | // A link within the site, or relative to the document's folder. |
| 262 | + | const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`; |
| 263 | + | return <Link to={to}>{children}</Link>; |
| 264 | + | } |
| 265 | + | |
| 266 | + | /** A picture; a relative one is the document's commit's file. */ |
| 267 | + | function MarkdownImage({ src, alt }: { src?: unknown; alt?: string }) { |
| 268 | + | const { rawBase } = useContext(MarkdownBases); |
| 269 | + | const at = typeof src === "string" ? imageSource(src, rawBase) : undefined; |
| 270 | + | return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; |
| 271 | + | } |
| 272 | + | |
| 273 | + | /** |
| 274 | + | * How each element is drawn, made once: a component that is the same on |
| 275 | + | * every render keeps the elements it drew, where a new one (even an |
| 276 | + | * identical function) has React take them down and put them back. |
| 277 | + | */ |
| 278 | + | const COMPONENTS: Components = { |
| 279 | + | h1: ({ children }) => <Heading level={1}>{children}</Heading>, |
| 280 | + | h2: ({ children }) => <Heading level={2}>{children}</Heading>, |
| 281 | + | h3: ({ children }) => <Heading level={3}>{children}</Heading>, |
| 282 | + | h4: ({ children }) => <Heading level={4}>{children}</Heading>, |
| 283 | + | a: MarkdownLink, |
| 284 | + | blockquote({ children, node }) { |
| 285 | + | const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert; |
| 286 | + | if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>; |
| 287 | + | const alert = ALERT[kind]; |
| 288 | + | return ( |
| 289 | + | <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}> |
| 290 | + | <p className="alert-title flex items-center gap-2 text-sm font-medium"> |
| 291 | + | {alert.icon} |
| 292 | + | {alert.title} |
| 293 | + | </p> |
| 294 | + | <div className="mt-1 [&>*+*]:mt-3">{children}</div> |
| 288 | 295 | | </div> |
| 289 | | − | ), |
| 290 | | − | input({ type, checked, disabled }) { |
| 291 | | − | // Task list boxes: shown, not editable. |
| 292 | | − | return type === "checkbox" ? ( |
| 293 | | − | <Checkbox |
| 294 | | − | checked={checked === true} |
| 295 | | − | disabled={disabled !== false} |
| 296 | | − | aria-label={checked ? "Done" : "Not done"} |
| 297 | | − | className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100" |
| 298 | | − | /> |
| 299 | | − | ) : null; |
| 300 | | − | }, |
| 301 | | − | img({ src, alt }) { |
| 302 | | − | const at = typeof src === "string" ? imageSource(src, rawBase) : undefined; |
| 303 | | − | return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; |
| 304 | | − | }, |
| 305 | | − | } satisfies Components; |
| 306 | | − | } |
| 296 | + | ); |
| 297 | + | }, |
| 298 | + | pre({ children }) { |
| 299 | + | const code = Array.isArray(children) ? children[0] : children; |
| 300 | + | if (isValidElement<{ className?: string; children?: ReactNode }>(code)) { |
| 301 | + | const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null; |
| 302 | + | return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />; |
| 303 | + | } |
| 304 | + | return <pre>{children}</pre>; |
| 305 | + | }, |
| 306 | + | // A wide table scrolls on its own, inside the text, not the page. |
| 307 | + | table: ({ children }) => ( |
| 308 | + | <div className="overflow-x-auto"> |
| 309 | + | <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table> |
| 310 | + | </div> |
| 311 | + | ), |
| 312 | + | input({ type, checked, disabled }) { |
| 313 | + | // Task list boxes: shown, not editable. |
| 314 | + | return type === "checkbox" ? ( |
| 315 | + | <Checkbox |
| 316 | + | checked={checked === true} |
| 317 | + | disabled={disabled !== false} |
| 318 | + | aria-label={checked ? "Done" : "Not done"} |
| 319 | + | className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100" |
| 320 | + | /> |
| 321 | + | ) : null; |
| 322 | + | }, |
| 323 | + | img: MarkdownImage, |
| 324 | + | }; |