From fe8f5e7613f411495b818f8e87d99ff770418463 Mon Sep 17 00:00:00 2001 From: Alex Kuzmin Date: Tue, 2 Jun 2026 22:18:22 +0800 Subject: [PATCH] Fix Markdown footnotes rendering inline code with math --- components/ui/markdown.tsx | 138 ++++++++++++++++++----------- tests/components/markdown.test.tsx | 24 +++++ 2 files changed, 108 insertions(+), 54 deletions(-) create mode 100644 tests/components/markdown.test.tsx diff --git a/components/ui/markdown.tsx b/components/ui/markdown.tsx index bdb7342c..deb6bec0 100644 --- a/components/ui/markdown.tsx +++ b/components/ui/markdown.tsx @@ -401,6 +401,78 @@ const MathText = ({ text }: { text: string }) => { } } +const getMarkdownElementTag = (element: React.ReactElement): string | null => { + if (typeof element.type === "string") { + return element.type + } + + const node = (element.props as { node?: { tagName?: unknown } })?.node + return typeof node?.tagName === "string" ? node.tagName : null +} + +const shouldSkipMathChildren = (element: React.ReactElement): boolean => { + const tagName = getMarkdownElementTag(element) + return tagName === "code" || tagName === "pre" +} + +const getTextContent = (children: React.ReactNode): string => { + return React.Children.toArray(children) + .map((child) => { + if (typeof child === "string" || typeof child === "number") { + return String(child) + } + + if (React.isValidElement(child)) { + return getTextContent( + (child.props as { children?: React.ReactNode }).children + ) + } + + return "" + }) + .join("") +} + +const containsMathInChildren = (children: React.ReactNode): boolean => { + return React.Children.toArray(children).some((child) => { + if (typeof child === "string") { + return containsMath(child) + } + + if (React.isValidElement(child) && !shouldSkipMathChildren(child)) { + return containsMathInChildren( + (child.props as { children?: React.ReactNode }).children + ) + } + + return false + }) +} + +const renderMathInChildren = (children: React.ReactNode): React.ReactNode => { + return React.Children.map(children, (child) => { + if (typeof child === "string") { + return containsMath(child) ? : child + } + + if (React.isValidElement(child) && !shouldSkipMathChildren(child)) { + const childProps = child.props as { children?: React.ReactNode } + + if (childProps.children === undefined) { + return child + } + + return React.cloneElement( + child as React.ReactElement<{ children?: React.ReactNode }>, + undefined, + renderMathInChildren(childProps.children) + ) + } + + return child + }) +} + const rehypeProcessBrTags = () => { return (tree: any) => { const visit = (node: any) => { @@ -535,18 +607,7 @@ const HeadingLink = ({ if (typeof children === "string") { return generateSectionId(children) } - const text = React.Children.toArray(children) - .map((child) => { - if (typeof child === "string") return child - if ( - React.isValidElement(child) && - typeof child.props?.children === "string" - ) { - return child.props.children - } - return "" - }) - .join("") + const text = getTextContent(children) return generateSectionId(text) }, [children]) @@ -661,15 +722,7 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ } // For other paragraphs, continue with normal processing - const text = childArray - .map((child) => { - if (typeof child === "string") return child - if (React.isValidElement(child) && child.props?.children) { - return child.props.children - } - return "" - }) - .join("") + const text = getTextContent(children) let isMathOnly = false if (text.trim().startsWith("$$") && text.trim().endsWith("$$")) { @@ -679,14 +732,14 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ } } - if (containsMath(text)) { + if (containsMathInChildren(children)) { return (

- + {renderMathInChildren(children)}

) } @@ -699,22 +752,13 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ }, // Handle math in list items li: ({ node, children, ...props }) => { - const text = React.Children.toArray(children) - .map((child) => { - if (typeof child === "string") return child - // @ts-expect-error - children props vary - if (child?.props?.children) return child.props.children - return "" - }) - .join("") - - if (containsMath(text)) { + if (containsMathInChildren(children)) { return (
  • - + {renderMathInChildren(children)}
  • ) } @@ -775,14 +819,7 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ const { node, children, ...rest } = props // Convert children to text to check for math - const text = React.Children.toArray(children) - .map((child) => { - if (typeof child === "string") return child - // @ts-expect-error - children props vary - if (child?.props?.children) return child.props.children - return "" - }) - .join("") + const text = getTextContent(children) // Handle line breaks in table cells by replacing
    with actual line breaks const hasBrTags = typeof text === "string" && text.includes("
    ") @@ -792,10 +829,10 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ typeof text === "string" && (text.includes("
    - + {renderMathInChildren(children)} ) } @@ -824,14 +861,7 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ } // Convert children to text to check for math - const text = React.Children.toArray(children) - .map((child) => { - if (typeof child === "string") return child - // @ts-expect-error - children props vary - if (child?.props?.children) return child.props.children - return "" - }) - .join("") + const text = getTextContent(children) // Handle line breaks in table headers by replacing
    with actual line breaks const hasBrTags = typeof text === "string" && text.includes("
    ") @@ -840,10 +870,10 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ const hasHtmlContent = typeof text === "string" && (text.includes("
    - + {renderMathInChildren(children)} ) } diff --git a/tests/components/markdown.test.tsx b/tests/components/markdown.test.tsx new file mode 100644 index 00000000..35ccfb5b --- /dev/null +++ b/tests/components/markdown.test.tsx @@ -0,0 +1,24 @@ +import { Markdown } from "@/components/ui/markdown" +import { render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +describe("Markdown", () => { + it("preserves inline code in footnotes that also contain math", async () => { + const { container } = render( + + { + "See the schedule score.[^score]\n\n[^score]: For a schedule $s$, the scorer computes `score(s)`." + } + + ) + + await waitFor(() => { + expect(container).toHaveTextContent("For a schedule") + }) + + expect(container.textContent).not.toContain("[object Object]") + + const code = screen.getByText("score(s)") + expect(code.tagName.toLowerCase()).toBe("code") + }) +})