A README no longer blinks while you read it: the Code page follows pushes and checks and revalidates as they arrive, and each time Markdown handed react-markdown a new set of components (a new function for every heading, link, picture and table), so React took the whole README down and put it back, reloading its pictures. Markdown now keeps the parsed text, the components (made once per link and picture base) and the rendered elements while those stay the same, so a refresh leaves the text untouched. The same holds wherever Markdown is drawn: issues, pull requests, comments and docs. Checked: the site's typecheck, its 901 tests and a production build.
1 file+97−880/1 viewed
| 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, useRef, useState } from "react"; | |
| 13 | + | import { type ReactNode, isValidElement, 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"; | |
| ⋯ | |||
| 204 | 204 | */ | |
| 205 | 205 | rawBase?: string; | |
| 206 | 206 | }) { | |
| 207 | + | // The same text renders to the same elements with the same components, so | |
| 208 | + | // a page that revalidates (a live refresh) leaves the text as it is | |
| 209 | + | // rather than mounting it again and reloading its pictures. | |
| 210 | + | 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]); | |
| 207 | 213 | return ( | |
| 208 | 214 | // A long word or address breaks rather than widening the page. | |
| 209 | − | <div className="prose wrap-break-word"> | |
| 210 | − | {renderMarkdownTree(treeOf(source, repo), { | |
| 211 | − | h1: ({ children }) => <Heading level={1}>{children}</Heading>, | |
| 212 | − | h2: ({ children }) => <Heading level={2}>{children}</Heading>, | |
| 213 | − | h3: ({ children }) => <Heading level={3}>{children}</Heading>, | |
| 214 | − | h4: ({ children }) => <Heading level={4}>{children}</Heading>, | |
| 215 | − | a({ href = "", children, node }) { | |
| 216 | − | const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef; | |
| 217 | − | if (ref === "mention") { | |
| 218 | − | // `@name` may be a person (with a card) or a workspace (none). | |
| 219 | − | const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, ""); | |
| 220 | − | return ( | |
| 221 | − | <UserCard username={name}> | |
| 222 | − | <Link to={href} prefetch="intent" className="font-medium"> | |
| 223 | − | {children} | |
| 224 | − | </Link> | |
| 225 | − | </UserCard> | |
| 226 | − | ); | |
| 227 | − | } | |
| 228 | − | if (ref) { | |
| 229 | − | return ( | |
| 230 | − | <Link | |
| 231 | − | to={href} | |
| 232 | − | prefetch="intent" | |
| 233 | − | className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""} | |
| 234 | − | > | |
| 235 | − | {children} | |
| 236 | − | </Link> | |
| 237 | − | ); | |
| 238 | − | } | |
| 239 | − | if (href.startsWith("#")) return <a href={href}>{children}</a>; | |
| 240 | − | if (isExternal(href)) { | |
| 241 | − | return ( | |
| 242 | − | <a href={href} rel="noreferrer nofollow ugc" target="_blank"> | |
| 243 | − | {children} | |
| 244 | − | </a> | |
| 245 | − | ); | |
| 246 | − | } | |
| 247 | − | // A link within the site, or relative to the document's folder. | |
| 248 | − | const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`; | |
| 249 | − | return <Link to={to}>{children}</Link>; | |
| 250 | − | }, | |
| 251 | − | blockquote({ children, node }) { | |
| 252 | − | const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert; | |
| 253 | − | if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>; | |
| 254 | − | const alert = ALERT[kind]; | |
| 255 | − | return ( | |
| 256 | − | <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}> | |
| 257 | − | <p className="alert-title flex items-center gap-2 text-sm font-medium"> | |
| 258 | − | {alert.icon} | |
| 259 | − | {alert.title} | |
| 260 | − | </p> | |
| 261 | − | <div className="mt-1 [&>*+*]:mt-3">{children}</div> | |
| 262 | − | </div> | |
| 263 | − | ); | |
| 264 | − | }, | |
| 265 | − | pre({ children }) { | |
| 266 | − | const code = Array.isArray(children) ? children[0] : children; | |
| 267 | − | if (isValidElement<{ className?: string; children?: ReactNode }>(code)) { | |
| 268 | − | const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null; | |
| 269 | − | return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />; | |
| 270 | − | } | |
| 271 | − | return <pre>{children}</pre>; | |
| 272 | − | }, | |
| 273 | − | // A wide table scrolls on its own, inside the text, not the page. | |
| 274 | − | table: ({ children }) => ( | |
| 275 | − | <div className="overflow-x-auto"> | |
| 276 | − | <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table> | |
| 277 | − | </div> | |
| 278 | − | ), | |
| 279 | − | input({ type, checked, disabled }) { | |
| 280 | − | // Task list boxes: shown, not editable. | |
| 281 | − | return type === "checkbox" ? ( | |
| 282 | − | <Checkbox | |
| 283 | − | checked={checked === true} | |
| 284 | − | disabled={disabled !== false} | |
| 285 | − | aria-label={checked ? "Done" : "Not done"} | |
| 286 | − | className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100" | |
| 287 | − | /> | |
| 288 | − | ) : null; | |
| 289 | − | }, | |
| 290 | − | img({ src, alt }) { | |
| 291 | − | const at = typeof src === "string" ? imageSource(src, rawBase) : undefined; | |
| 292 | − | return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; | |
| 293 | − | }, | |
| 294 | − | } satisfies Components)} | |
| 295 | − | </div> | |
| 215 | + | <div className="prose wrap-break-word">{rendered}</div> | |
| 296 | 216 | ); | |
| 297 | 217 | } | |
| 218 | + | ||
| 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> | |
| 288 | + | </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 | + | } | |