import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; interface MarkdownProps { children: string; className?: string; inline?: boolean; } // The mention rule the server notifies by (see utils/chatText.ts): an `@` not // preceded by a word character, so an email address isn't a mention. const MENTION_RE = /(? { if (!node.children) return; // Link text is already a link, whatever it says. if (node.type === "link" || node.type === "linkReference") return; node.children = node.children.flatMap((child) => { if (child.type !== "text" || !child.value?.includes("@")) { walk(child); return [child]; } const out: MdNode[] = []; let last = 0; for (const m of child.value.matchAll(MENTION_RE)) { if (m.index > last) { out.push({ type: "text", value: child.value.slice(last, m.index) }); } out.push({ type: "link", url: `/users/${m[1]}`, children: [{ type: "text", value: m[0] }], }); last = m.index + m[0].length; } if (last === 0) return [child]; if (last < child.value.length) { out.push({ type: "text", value: child.value.slice(last) }); } return out; }); }; return (tree: MdNode) => walk(tree); } /** * A single newline is a line break, as it is in chat everywhere: markdown's * own rule folds it into a space, so a message typed on three lines with * Shift+Enter came out as one. (Code keeps its newlines; it has no text * nodes.) */ function remarkBreaks() { const walk = (node: MdNode) => { if (!node.children) return; node.children = node.children.flatMap((child) => { if (child.type !== "text" || !child.value?.includes("\n")) { walk(child); return [child]; } return child.value.split("\n").flatMap((part, i) => i === 0 ? [{ type: "text", value: part }] : [{ type: "break" }, { type: "text", value: part }] ); }); }; return (tree: MdNode) => walk(tree); } const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks]; /** The path of a link into this app — relative, or absolute to this origin — * or null for anywhere else. */ function internalPath(href: string | undefined): string | null { if (!href) return null; if (href.startsWith("/") && !href.startsWith("//")) { // Files and attachments are the API's, not routes: a new tab, as before. return href.startsWith("/api/") ? null : href; } try { const url = new URL(href); if (url.origin !== globalThis.location.origin) return null; if (url.pathname.startsWith("/api/")) return null; return url.pathname + url.search + url.hash; } catch { return null; } } type Components = React.ComponentProps["components"]; // Static components objects — defined once at module scope to avoid recreation on every render const MARKDOWN_COMPONENTS: Components = { // Mentions and links to other pages here stay in the app; the rest open // in a new tab. a: ({ href, children: linkChildren }) => { const path = internalPath(href); if (path) return {linkChildren}; return ( {linkChildren} ); }, img: ({ src, alt }) => ( {alt ), // A wide GFM table scrolls inside its own box instead of the page. table: ({ children: tableChildren }) => (
{tableChildren}
), }; // Inline renderings are line-clamped teasers inside cards, where an embedded // image blows the layout apart. Drop images there — the full description is // one click away on the detail page. const INLINE_MARKDOWN_COMPONENTS: Components = { ...MARKDOWN_COMPONENTS, img: () => null, }; export function Markdown( { children, className, inline = false }: MarkdownProps, ) { return (
{children}
); }