/** * Renders the Markdown that `TextEditor` writes, wherever a dump's note, a * comment, a chat message or a description is shown. * * @module */ import { memo } from "react"; import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { userPath } from "../../../shared/paths.ts"; import { EVERYONE } from "../../../shared/mention.ts"; import { HANDLE_SOURCE } from "../../../shared/validation.ts"; import { isAttachmentUrl } from "../../../shared/attachment.ts"; /** Passed to {@link Markdown} by any component that shows a user's text. */ export interface MarkdownProps { children: string; className?: string; /** * Drops the images, for a line-clamped teaser in a card, where an image * would break the layout. The full text is one click away. */ inline?: boolean; /** * Shows `@everyone` as a mention, from a chat message's * `mentionsEveryone`. Without it, `@everyone` is plain text: anyone may type * it, and it reaches no one. */ everyone?: boolean; } /** * The rule of shared/mention.ts, which the server notifies by, applied to * parsed text: link syntax is already parsed away here, so only a preceding * word character (an email address) rules a mention out. */ const MENTION_RE = new RegExp(`(? }; } /** * Returns `children` with each `@name@host` that GFM took for an email address * (text ending in `@`, then a `mailto:` link of `name@host`) back in one text * node, which {@link remarkMentions} then reads as a remote user's handle. */ function unlinkHandles(children: MdNode[]): MdNode[] { const out: MdNode[] = []; for (const child of children) { const prev = out.at(-1); const only = child.children?.length === 1 ? child.children[0] : undefined; const address = child.type === "link" && only?.type === "text" ? only.value : undefined; if ( prev?.type === "text" && ((address && child.url === `mailto:${address}` && /(? { if (!node.children) return; // Link text is already a link, whatever it says. if (node.type === "link" || node.type === "linkReference") return; node.children = unlinkHandles(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)) { const isEveryone = m[1].toLowerCase() === EVERYONE; if (isEveryone && !everyone) continue; if (m.index > last) { out.push({ type: "text", value: child.value.slice(last, m.index) }); } out.push( isEveryone ? { type: "emphasis", data: { hName: "span", hProperties: { className: "md-everyone" }, }, children: [{ type: "text", value: m[0] }], } : { type: "link", url: userPath(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); } /** * Turns a single newline into a line break, as chat apps do. Markdown folds it * into a space, so a message typed on three lines with Shift+Enter would show * as one. Code keeps its newlines, since 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]; /** {@link REMARK_PLUGINS} for a text whose `@everyone` is a mention. */ const REMARK_PLUGINS_EVERYONE = [ remarkGfm, [remarkMentions, { everyone: true }], remarkBreaks, ] as React.ComponentProps["remarkPlugins"]; /** * Returns the path of a link into this app, relative or absolute to this * origin, or null for anywhere else. The API's files and attachments count as * elsewhere, since they aren't routes of the app. */ function internalPath(href: string | undefined): string | null { if (!href) return null; if (href.startsWith("/") && !href.startsWith("//")) { 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"]; /** * Renders an image of the text. An attachment gets the viewer's media token, * which the server asks for when the text linking it is private; any other * image is left as written. */ function MarkdownImage( { src, alt }: { src?: string | Blob; alt?: string }, ): React.JSX.Element { const { mediaToken } = useAuth(); let url = typeof src === "string" ? src : undefined; if (url && mediaToken && isAttachmentUrl(url)) { url = `${url}?token=${encodeURIComponent(mediaToken)}`; } return {alt; } /** * Overrides react-markdown's elements. It is built once, since a new object on * each render would re-render every element. */ const MARKDOWN_COMPONENTS: Components = { // Mentions and links to the app navigate in place, without a reload. The // rest open in a new tab. a: ({ href, children: linkChildren }) => { const path = internalPath(href); if (path) return {linkChildren}; return ( {linkChildren} ); }, img: ({ src, alt }) => , // A wide GFM table scrolls inside its own box instead of the page. table: ({ children: tableChildren }) => (
{tableChildren}
), }; /** Overrides react-markdown's elements for an `inline` rendering. */ const INLINE_MARKDOWN_COMPONENTS: Components = { ...MARKDOWN_COMPONENTS, img: () => null, }; /** * Renders GFM with `@mentions` as profile links and single newlines kept. It * is memoized because parsing runs the whole remark pipeline, and the text * rarely changes when its parent re-renders. */ export const Markdown: React.MemoExoticComponent< (props: MarkdownProps) => React.JSX.Element > = memo(function Markdown( { children, className, inline = false, everyone = false }: MarkdownProps, ) { return (
{children}
); });