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.
pull/6058/head
boojack 2 weeks ago
parent 3b601b8416
commit 10200606db

@ -19,7 +19,7 @@ import { CodeBlock } from "./CodeBlock";
import { SANITIZE_SCHEMA } from "./constants";
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
import { Mention } from "./Mention";
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
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";
@ -28,6 +28,10 @@ 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 {
@ -49,7 +53,7 @@ function getMentionUsername(node: Element, children?: React.ReactNode): string {
return "";
}
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: MemoMarkdownRendererProps) => {
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
const markdownComponents: Components = {
input: ({ node, ...inputProps }) => {
if (node && isTaskListItemElement(node)) {
@ -107,7 +111,22 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: Memo
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
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} />,

@ -58,6 +58,11 @@ export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_P
*/
export const SANITIZE_SCHEMA = {
...defaultSchema,
// Don't re-prefix `id`/`name` attributes. remark-rehype already namespaces footnote ids with
// `user-content-` and emits matching `#user-content-…` hrefs; the default schema's clobbering
// would prepend a *second* `user-content-` to the ids only (not the hrefs), breaking in-page
// navigation. Leaving ids untouched keeps footnote (and heading) anchors pointing at real targets.
clobber: [],
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img || []), "height", "width"],

@ -33,6 +33,12 @@ const MemoContent = (props: MemoContentProps) => {
"[&_.katex-display]:max-w-full",
"[&_.katex-display]:overflow-x-auto",
"[&_.katex-display]:overflow-y-hidden",
// Footnotes: quiet GitHub-style footer — thin separator, smaller muted text, unobtrusive links.
"[&_.footnotes]:mt-4 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes]:pt-2",
"[&_.footnotes]:text-sm [&_.footnotes]:text-muted-foreground",
// GitHub renders footnote ref/backref links without an underline (underline on hover only).
"[&_[data-footnote-ref]]:no-underline [&_[data-footnote-ref]:hover]:underline",
"[&_.data-footnote-backref]:no-underline [&_.data-footnote-backref:hover]:underline",
showCompactMode === "ALL" && "overflow-hidden",
contentClassName,
)}
@ -40,7 +46,12 @@ const MemoContent = (props: MemoContentProps) => {
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
<MemoMarkdownRenderer content={content} resolvedMentionUsernames={resolvedMentionUsernames} />
<MemoMarkdownRenderer
content={content}
resolvedMentionUsernames={resolvedMentionUsernames}
memoName={props.memoName}
compact={Boolean(props.compact)}
/>
{showCompactMode === "ALL" && (
<div
className={cn(

@ -0,0 +1,54 @@
import { Link } from "react-router-dom";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface AnchorLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
href: string;
/** Resource name of the enclosing memo (e.g. `memos/abc123`), when known. */
memoName?: string;
/** Whether the memo is rendered as a collapsed feed card. */
compact?: boolean;
children: React.ReactNode;
}
/**
* Renders in-page anchors (footnote references/backrefs, heading links any `href="#…"`).
*
* When the target lives in a fully-rendered memo (detail, share, preview, expanded feed card),
* we scroll to it within that memo's own container scoped so duplicate footnote ids across a
* feed can't send us to the wrong memo. When the memo is a collapsed feed card the footnote is
* below the fold, so we fall back to navigating to the memo detail page (with the hash), where
* MemoDetail scrolls the target into view.
*/
export const AnchorLink = ({ href, memoName, compact, children, className, node: _node, ...props }: AnchorLinkProps) => {
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (compact) return; // Let the link navigate to the detail page.
const id = decodeURIComponent(href.slice(1));
if (!id) return;
// Scope the lookup to this memo's own container so duplicate footnote ids elsewhere in a feed
// can't steal the scroll.
const root = event.currentTarget.closest("[data-memo-content]");
const target = root?.querySelector(`#${CSS.escape(id)}`);
if (target) {
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const classes = cn(markdownStyles.link, className);
if (memoName) {
return (
<Link to={`/${memoName}${href}`} onClick={handleClick} className={classes} {...props}>
{children}
</Link>
);
}
return (
<a href={href} onClick={handleClick} className={classes} {...props}>
{children}
</a>
);
};

@ -1,3 +1,4 @@
export { AnchorLink } from "./AnchorLink";
export { Blockquote } from "./Blockquote";
export { Heading } from "./Heading";
export { HorizontalRule } from "./HorizontalRule";

@ -2,6 +2,8 @@ import type React from "react";
export interface MemoContentProps {
content: string;
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
memoName?: string;
compact?: boolean;
className?: string;
contentClassName?: string;

@ -36,6 +36,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
>
<MemoContent
key={memo.name}
memoName={memo.name}
content={memo.content}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}

@ -1,6 +1,6 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { ArrowUpLeftFromCircleIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
import MemoCommentSection from "@/components/MemoCommentSection";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
@ -57,11 +57,17 @@ const MemoDetail = () => {
enabled: !!memo,
});
// Scroll to the hash target once it's in the DOM. The effect re-runs as the memo loads (footnote
// anchors) and as comments arrive (comment anchors), since the target may render in either; the
// ref guards against re-scrolling the same hash on every later comments page-load.
const scrolledHashRef = useRef("");
useEffect(() => {
if (!hash || comments.length === 0) return;
const el = document.getElementById(hash.slice(1));
el?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [hash, comments]);
if (!hash || scrolledHashRef.current === hash) return;
const el = document.getElementById(decodeURIComponent(hash.slice(1)));
if (!el) return;
scrolledHashRef.current = hash;
el.scrollIntoView({ behavior: "smooth", block: "center" });
}, [hash, memo, comments]);
if (isShareMode) {
const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated);

Loading…
Cancel
Save