SPB Git

Repository Public MIT

markdown.tsx 3.8 KB 84 lines tsx

1import ReactMarkdown, { defaultUrlTransform } from "react-markdown";2import rehypeHighlight from "rehype-highlight";3import remarkGfm from "remark-gfm";4import { CopyButton } from "./copy-button";56function text(node: React.ReactNode): string {7  if (typeof node === "string" || typeof node === "number") return String(node);8  if (Array.isArray(node)) return node.map(text).join("");9  if (node && typeof node === "object" && "props" in node) return text((node as { props: { children?: React.ReactNode } }).props.children);10  return "";11}12const slug = (children: React.ReactNode) => text(children).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || undefined;13const LANG: Record<string, string> = { sh: "shell", bash: "shell", zsh: "shell", shell: "shell", js: "javascript", mjs: "javascript", ts: "typescript", py: "python", yml: "yaml", md: "markdown" };1415/** Fenced code: a frame with the language and a copy button. The code itself stays plain text. */16function Pre({ children }: { children?: React.ReactNode }) {17  const child = Array.isArray(children) ? children[0] : children;18  const cls = (child && typeof child === "object" && "props" in child ? (child as { props: { className?: string } }).props.className : "") ?? "";19  const lang = /language-([\w+-]+)/.exec(cls)?.[1];20  const raw = text(children).replace(/\n$/, "");21  return (22    <div className="code-frame">23      <div className="code-head">24        <span className="font-mono text-[11px] text-faint">{lang ? LANG[lang] ?? lang : "text"}</span>25        <CopyButton text={raw} />26      </div>27      <pre tabIndex={0}>{children}</pre>28    </div>29  );30}3132/**33 * Safe Markdown. react-markdown never renders raw HTML, URLs go through its protocol allow-list,34 * images are dropped (no third-party requests from untrusted READMEs), and untrusted links are35 * marked nofollow/ugc and cannot use relative targets.36 */37export function Markdown({ source, trusted = false }: { source: string; trusted?: boolean }) {38  return (39    <div className="prose-registry">40      <ReactMarkdown41        remarkPlugins={[remarkGfm]}42        rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}43        disallowedElements={["img"]}44        urlTransform={(url) => {45          const u = defaultUrlTransform(url);46          if (trusted) return u;47          return /^(https?:|mailto:|#)/i.test(u) ? u : "";48        }}49        components={{50          a: ({ href, children }) => {51            if (!href) return <span>{children}</span>;52            const external = /^https?:/i.test(href);53            return (54              <a href={href} {...(external ? { rel: trusted ? "noopener" : "nofollow ugc noopener noreferrer", target: "_blank" } : {})}>55                {children}56              </a>57            );58          },59          pre: Pre,60          table: ({ children }) => (61            <div className="table-wrap" tabIndex={0} role="group" aria-label="Table">62              <table>{children}</table>63            </div>64          ),65          // Untrusted documents never own the page's h1: their headings are demoted one level.66          ...(trusted67            ? {68                h2: ({ children }: { children?: React.ReactNode }) => <h2 id={slug(children)}>{children}</h2>,69                h3: ({ children }: { children?: React.ReactNode }) => <h3 id={slug(children)}>{children}</h3>,70              }71            : {72                h1: ({ children }: { children?: React.ReactNode }) => <h2>{children}</h2>,73                h2: ({ children }: { children?: React.ReactNode }) => <h3>{children}</h3>,74                h3: ({ children }: { children?: React.ReactNode }) => <h4>{children}</h4>,75                h4: ({ children }: { children?: React.ReactNode }) => <h5>{children}</h5>,76              }),77        }}78      >79        {source}80      </ReactMarkdown>81    </div>82  );83}84
Files main
Menu

Theme