Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions src/pages/markdownPreview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
renderMarkdown,
resolveMarkdownTarget,
} from "./renderer";
import sanitizeMarkdownHtml from "./sanitize";

let previewController = null;
let peleModulePromise = null;
Expand All @@ -29,7 +30,12 @@ function escapeHtml(text) {
}

function getTargetElement(container, targetId) {
const decodedId = decodeURIComponent(targetId || "");
let decodedId = targetId || "";
try {
decodedId = decodeURIComponent(decodedId);
} catch {
// Keep the raw fragment when it contains an invalid percent escape.
}
if (!decodedId) return null;

const elements = container.querySelectorAll("[id], [name]");
Expand Down Expand Up @@ -344,7 +350,24 @@ function createMarkdownPreview(file) {
);
if (!mermaidBlocks.length) return;

const { renderAsync } = await getPele();
let renderAsync;
try {
({ renderAsync } = await getPele());
} catch (error) {
if (!previewState.disposed && version === previewState.renderVersion) {
console.warn("Failed to load Mermaid renderer:", error);
mermaidBlocks.forEach((block) => {
const source = block.textContent || "";
block.classList.add("mermaid-error");
block.innerHTML = `
<pre><code>${escapeHtml(source)}</code></pre>
<div class="mermaid-error-message">${escapeHtml(error?.message || "Failed to load Mermaid diagram renderer.")}</div>
`;
});
}
return;
}

if (previewState.disposed || version !== previewState.renderVersion) return;
await Promise.all(
mermaidBlocks.map(async (block, index) => {
Expand All @@ -368,7 +391,13 @@ function createMarkdownPreview(file) {
ADD_ATTR: ["data-id", "class"],
});
} catch (error) {
if (!block.isConnected) return;
if (
previewState.disposed ||
version !== previewState.renderVersion ||
!block.isConnected
) {
return;
}
block.classList.add("mermaid-error");
block.innerHTML = `
<pre><code>${escapeHtml(source)}</code></pre>
Expand All @@ -390,18 +419,22 @@ function createMarkdownPreview(file) {
renderMarkdown(markdownText, previewState.file),
];
if (hasMathContent(markdownText)) {
pendingRenderTasks.push(ensureMathStyles());
pendingRenderTasks.push(
ensureMathStyles().catch((error) => {
console.warn("Failed to load math styles:", error);
}),
);
}
const [{ html }] = await Promise.all(pendingRenderTasks);
const [{ html, katexMarker }] = await Promise.all(pendingRenderTasks);

if (previewState.disposed || version !== previewState.renderVersion) {
return;
}

previewState.content.innerHTML = DOMPurify.sanitize(html, {
FORBID_TAGS: ["style"],
ADD_TAGS: ["eq", "eqn"],
});
previewState.content.innerHTML = sanitizeMarkdownHtml(
html,
katexMarker,
);

const objectUrls = await resolveRenderedImages(
previewState.content,
Expand Down Expand Up @@ -452,6 +485,9 @@ function createMarkdownPreview(file) {
bind,
render,
page: $page,
get disposed() {
return previewState.disposed;
},
};

return controller;
Expand All @@ -460,7 +496,11 @@ function createMarkdownPreview(file) {
export default async function openMarkdownPreview(file, hash = "") {
if (!file) return null;

if (!previewController || previewController.page?.isConnected === false) {
if (
!previewController ||
previewController.disposed ||
previewController.page?.isConnected === false
) {
previewController = createMarkdownPreview(file);
}

Expand Down
42 changes: 39 additions & 3 deletions src/pages/markdownPreview/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ function escapeAttribute(value = "") {
.replace(/>/g, "&gt;");
}

function createKatexMarker() {
const values = new Uint32Array(2);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(values);
return `katex-${values[0].toString(36)}${values[1].toString(36)}`;
}

return `katex-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
}

function splitLinkTarget(target = "") {
const hashIndex = target.indexOf("#");
if (hashIndex === -1) {
Expand Down Expand Up @@ -166,6 +176,20 @@ function createMarkdownIt({ katex = null, markdownItTexmath = null } = {}) {
strict: "ignore",
},
});

// Tag generated math so raw HTML cannot spoof the sanitizer's KaTeX exception.
for (const [ruleName, renderMath] of Object.entries(md.renderer.rules)) {
if (!ruleName.startsWith("math_") || typeof renderMath !== "function") {
continue;
}

md.renderer.rules[ruleName] = (tokens, idx, options, env, self) => {
const rendered = renderMath(tokens, idx, options, env, self);
if (!env?.katexMarker) return rendered;
const tag = ruleName.includes("block") ? "div" : "span";
return `<${tag} data-acode-katex="${env.katexMarker}">${rendered}</${tag}>`;
};
}
}

md.use(markdownItEmoji);
Expand Down Expand Up @@ -231,9 +255,20 @@ async function getMarkdownIt(text = "") {

export async function renderMarkdown(text, file) {
const markdownText = text || "";
const md = await getMarkdownIt(markdownText);
const env = {};
env.markdownBaseUri = getMarkdownBaseUri(file);
let md;
try {
md = await getMarkdownIt(markdownText);
} catch (error) {
console.warn(
"Failed to load the math renderer; using plain markdown.",
error,
);
md = baseMarkdownIt;
}
const env = {
markdownBaseUri: getMarkdownBaseUri(file),
katexMarker: createKatexMarker(),
};
const tokens = md.parse(markdownText, env);

collectTokens(tokens, (token) => {
Expand All @@ -250,5 +285,6 @@ export async function renderMarkdown(text, file) {

return {
html: md.renderer.render(tokens, md.options, env),
katexMarker: env.katexMarker,
};
}
27 changes: 27 additions & 0 deletions src/pages/markdownPreview/sanitize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import DOMPurify from "dompurify";

export default function sanitizeMarkdownHtml(html, katexMarker = "") {
const katexSelector = katexMarker
? `[data-acode-katex="${katexMarker}"]`
: null;
const limitStyleAttributes = (node, data) => {
// Only generated KaTeX output carries the per-render marker.
if (
data.attrName === "style" &&
(!katexSelector || !node.closest?.(katexSelector))
) {
data.keepAttr = false;
}
};

DOMPurify.addHook("uponSanitizeAttribute", limitStyleAttributes);
try {
return DOMPurify.sanitize(html, {
FORBID_TAGS: ["style"],
ADD_TAGS: ["eq", "eqn"],
ADD_ATTR: ["data-acode-katex"],
});
} finally {
DOMPurify.removeHook("uponSanitizeAttribute", limitStyleAttributes);
}
}
Loading