memos/web/src/components/MemoContent/MemoMarkdownRenderer.tsx
boojack 10200606db feat(markdown): render and navigate GFM footnotes
Footnote definitions previously vanished on display. Two underlying issues:

- rehype-sanitize re-clobbered `id` attributes with a second `user-content-`
  prefix while leaving hrefs untouched, so footnote refs/backrefs pointed at
  ids that didn't exist. Disable clobbering (ids are already namespaced by
  remark-rehype) so anchors match their targets.
- Footnote anchors went through the external Link (target="_blank"), opening a
  blank tab instead of navigating. Route in-page `#` anchors through a new
  AnchorLink: scroll within the memo when shown in full, else navigate to the
  memo detail page with the hash, where MemoDetail scrolls it into view.

Also style the footnotes section GitHub-style: thin separator, smaller muted
text, and un-underlined ref/backref links.
2026-06-29 22:46:41 +08:00

167 lines
6.2 KiB
TypeScript

import type { Element } from "hast";
import "katex/dist/katex.min.css";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { SANITIZE_SCHEMA } from "./constants";
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
import { Mention } from "./Mention";
import { AnchorLink, Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import { TrustedIframe } from "./TrustedIframe";
interface MemoMarkdownRendererProps {
content: string;
resolvedMentionUsernames: Set<string>;
/** Resource name of the memo (e.g. `memos/abc123`), used to target footnote links at the detail page. */
memoName?: string;
/** Whether the memo is rendered as a collapsed feed card. */
compact?: boolean;
}
function getMentionUsername(node: Element, children?: React.ReactNode): string {
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return dataMention;
}
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
if (typeof camelDataMention === "string" && camelDataMention !== "") {
return camelDataMention;
}
const text = Array.isArray(children) ? children.join("") : children;
if (typeof text === "string" && text.startsWith("@")) {
return text.slice(1).toLowerCase();
}
return "";
}
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
const markdownComponents: Components = {
input: ({ node, ...inputProps }) => {
if (node && isTaskListItemElement(node)) {
return <TaskListItem {...inputProps} node={node} />;
}
return <input {...inputProps} />;
},
span: ({ node, ...spanProps }) => {
if (node && isMentionElement(node)) {
const username = getMentionUsername(node, spanProps.children);
return <Mention {...spanProps} node={node} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
}
if (node && isTagElement(node)) {
return <Tag {...spanProps} node={node} />;
}
return <span {...spanProps} />;
},
h1: ({ children, ...props }) => (
<Heading level={1} {...props}>
{children}
</Heading>
),
h2: ({ children, ...props }) => (
<Heading level={2} {...props}>
{children}
</Heading>
),
h3: ({ children, ...props }) => (
<Heading level={3} {...props}>
{children}
</Heading>
),
h4: ({ children, ...props }) => (
<Heading level={4} {...props}>
{children}
</Heading>
),
h5: ({ children, ...props }) => (
<Heading level={5} {...props}>
{children}
</Heading>
),
h6: ({ children, ...props }) => (
<Heading level={6} {...props}>
{children}
</Heading>
),
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
hr: (props) => <HorizontalRule {...props} />,
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
ol: ({ children, ...props }) => (
<List ordered {...props}>
{children}
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
a: ({ children, href, ...props }) => {
// In-page anchors (footnote refs/backrefs, heading links) navigate within the memo rather
// than opening a new tab; everything else is treated as an external link.
if (typeof href === "string" && href.startsWith("#")) {
return (
<AnchorLink href={href} memoName={memoName} compact={compact} {...props}>
{children}
</AnchorLink>
);
}
return (
<Link href={href} {...props}>
{children}
</Link>
);
},
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
iframe: TrustedIframe,
img: (props) => <Image {...props} />,
pre: CodeBlock,
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
};
return (
<MarkdownRenderContext.Provider value={rootMarkdownRenderContext}>
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
rehypeHeadingId,
[rehypeKatex, { throwOnError: false, strict: false }],
]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</MarkdownRenderContext.Provider>
);
};