mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-09 05:30:28 +08:00
explorer: Fix math rendering in markdown
The sanitizer allowlist added in 4c31a8134 strips all of KaTeX's markup,
leaving only the flat text of each formula. We now typeset math after
sanitizing instead of loosening the allowlist for spans and styles.
This commit is contained in:
@@ -22,7 +22,6 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import dompurify from "dompurify";
|
||||
import { afterUpdate } from "svelte";
|
||||
import { toDom } from "hast-util-to-dom";
|
||||
|
||||
@@ -41,7 +40,7 @@
|
||||
canonicalizeGithubImageUrl,
|
||||
isCommit,
|
||||
} from "@app/lib/utils";
|
||||
import { Renderer, markdown, sanitizeConfig } from "@app/lib/markdown";
|
||||
import { Renderer, renderMarkdown } from "@app/lib/markdown";
|
||||
import { parseFrontmatter } from "@app/lib/frontmatter";
|
||||
|
||||
export let content: string;
|
||||
@@ -111,18 +110,11 @@
|
||||
}
|
||||
|
||||
function render(content: string): string {
|
||||
return dompurify.sanitize(
|
||||
markdown({
|
||||
katex: true,
|
||||
emojis: true,
|
||||
footnotes: true,
|
||||
linkify: true,
|
||||
}).parse(content, {
|
||||
renderer: new Renderer($activeUnloadedRouteStore),
|
||||
breaks,
|
||||
}) as string,
|
||||
sanitizeConfig,
|
||||
) as string;
|
||||
return renderMarkdown(
|
||||
content,
|
||||
new Renderer($activeUnloadedRouteStore),
|
||||
breaks,
|
||||
);
|
||||
}
|
||||
|
||||
afterUpdate(async () => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { Config } from "dompurify";
|
||||
import type { MarkedExtension, Tokens } from "marked";
|
||||
import type {
|
||||
MarkedExtension,
|
||||
RendererExtension,
|
||||
TokenizerExtension,
|
||||
Tokens,
|
||||
} from "marked";
|
||||
import type { Route } from "@app/lib/router";
|
||||
|
||||
import dompurify from "dompurify";
|
||||
import katex from "katex";
|
||||
import escape from "lodash/escape";
|
||||
import footnoteMarkedExtension from "marked-footnote";
|
||||
import katexMarkedExtension from "marked-katex-extension";
|
||||
@@ -175,7 +182,28 @@ const emojiExtension = markedEmoji({
|
||||
|
||||
const footnoteExtension = footnoteMarkedExtension({ refMarkers: true });
|
||||
const linkifyExtension = linkifyMarkedExtension({}, { fuzzyLink: false });
|
||||
const katexExtension = katexMarkedExtension({ throwOnError: false });
|
||||
|
||||
// KaTeX's markup doesn't survive sanitization, so math renders as a
|
||||
// placeholder holding the TeX source, and `renderMath` swaps in KaTeX's
|
||||
// output once the HTML is sanitized.
|
||||
const MATH_CLASS = "math";
|
||||
const MATH_DISPLAY_CLASS = "math-display";
|
||||
|
||||
const mathExtension: MarkedExtension = {
|
||||
extensions: (
|
||||
katexMarkedExtension().extensions as (TokenizerExtension &
|
||||
RendererExtension)[]
|
||||
).map(extension => ({
|
||||
...extension,
|
||||
renderer: ({ text, displayMode }: Tokens.Generic) => {
|
||||
const classes = displayMode
|
||||
? `${MATH_CLASS} ${MATH_DISPLAY_CLASS}`
|
||||
: MATH_CLASS;
|
||||
const newline = extension.level === "block" ? "\n" : "";
|
||||
return `<code class="${classes}">${escape(text)}</code>${newline}`;
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
export function markdown(options: MarkedOptions): Marked {
|
||||
return new Marked(
|
||||
@@ -185,8 +213,37 @@ export function markdown(options: MarkedOptions): Marked {
|
||||
...[
|
||||
...(options.emojis ? [emojiExtension] : []),
|
||||
...(options.footnotes ? [footnoteExtension] : []),
|
||||
...(options.katex ? [katexExtension] : []),
|
||||
...(options.katex ? [mathExtension] : []),
|
||||
...(options.linkify ? [linkifyExtension] : []),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function renderMath(html: string): string {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html;
|
||||
for (const element of template.content.querySelectorAll(
|
||||
`code.${MATH_CLASS}`,
|
||||
)) {
|
||||
element.outerHTML = katex.renderToString(element.textContent ?? "", {
|
||||
displayMode: element.classList.contains(MATH_DISPLAY_CLASS),
|
||||
throwOnError: false,
|
||||
});
|
||||
}
|
||||
return template.innerHTML;
|
||||
}
|
||||
|
||||
export function renderMarkdown(
|
||||
content: string,
|
||||
renderer: Renderer,
|
||||
breaks: boolean,
|
||||
): string {
|
||||
const html = markdown({
|
||||
katex: true,
|
||||
emojis: true,
|
||||
footnotes: true,
|
||||
linkify: true,
|
||||
}).parse(content, { renderer, breaks }) as string;
|
||||
|
||||
return renderMath(dompurify.sanitize(html, sanitizeConfig));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { Renderer, renderMarkdown } from "@app/lib/markdown";
|
||||
|
||||
function render(content: string): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
element.innerHTML = renderMarkdown(
|
||||
content,
|
||||
new Renderer({ resource: "booting" }),
|
||||
false,
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("math", () => {
|
||||
test("renders inline math with KaTeX", () => {
|
||||
const math = render("inline $3x^{-1}+(1+x)^2$ math").querySelector(
|
||||
"p .katex",
|
||||
);
|
||||
expect(math?.querySelector(".katex-html")).not.toBeNull();
|
||||
expect(math?.querySelector("annotation")?.textContent).toBe(
|
||||
"3x^{-1}+(1+x)^2",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders block math in display mode", () => {
|
||||
const element = render("$$\n\\sum_{k=1}^n a_k\n$$\n");
|
||||
expect(element.querySelector(".katex-display .katex-html")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("keeps sanitizing the rest of the document", () => {
|
||||
const element = render(
|
||||
'$x$ <span style="position:fixed">overlay</span><img src=x onerror="alert(1)">',
|
||||
);
|
||||
expect(element.querySelector(".katex")).not.toBeNull();
|
||||
expect(element.querySelector("[style*=fixed]")).toBeNull();
|
||||
expect(element.querySelector("[onerror]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,7 @@ test("math", async ({ page }) => {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page.getByText("The Cauchy-Schwarz Inequality")).toBeVisible();
|
||||
await expect(page.locator(".katex-display .katex-html").first()).toBeVisible();
|
||||
await expect(page).toHaveScreenshot({ fullPage: true });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user