// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. // @ts-check /// /***** LaTeX passthrough plug-in for the markdown-it parser ***** When converting Markdown to HTML that may contain LaTeX, but wanting to leave the LaTeX untouched, there are a number of hazards. These include: - Markdown escapes in the LaTeX, such as double escapes ("\\" and lines ending with a "\"), will be processed, corrupting the LaTeX. This sequence is common in \begin{bmatrix} structures (which is common in Quantum). - The LaTeX may be processed for Markdown sequences. Common examples here include finding two "_" chars in the LaTeX and converting this to emphasis "" tags around the content. Disabling Markdown escapes can avoid the former, but not the latter, which requires re-writing the LaTeX if possible to avoid, and can be subtle to catch. To avoid these issues, this plug-in detects $..$ and $$..$$ content in raw Markdown and inline HTML, and passes it through the Markdown parser as-is. */ // To help make type checking annotations cleaner /** @typedef {import("markdown-it/dist/markdown-it.js")} MarkdownIt */ /** @typedef {import("markdown-it/dist/markdown-it.js").StateInline} StateInline */ // Below code to locate LaTeX blocks largely taken from @vscode/markdown-it-katex // See https://github.com/microsoft/vscode-markdown-it-katex/blob/9f3e1dff0fa2e011c63cb6a05fa6e80b7624538f/src/index.ts /** * @param {MarkdownIt} md */ function plugin(md) { // Add rules to extract LaTeX md.inline.ruler.after("escape", "math_inline", inlineMath); md.inline.ruler.after("escape", "math_inline_block", inlineMathBlock); // Just render the LaTeX 'as-is' md.renderer.rules.math_inline = (tokens, idx) => { return "$" + escapeHtml(tokens[idx].content) + "$"; }; md.renderer.rules.math_block = (tokens, idx) => { return "$$" + escapeHtml(tokens[idx].content) + "$$"; }; } /** * @param {string} unsafe */ function escapeHtml(unsafe) { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * @param {string} char * @returns boolean */ function isWhitespace(char) { return /^\s$/u.test(char); } /** * @param {string} char * @returns boolean */ function isWordCharacterOrNumber(char) { return /^[\w\d]$/u.test(char); } /** * @param {StateInline} state * @param {number} pos */ function isValidInlineDelim(state, pos) { const prevChar = state.src[pos - 1]; const char = state.src[pos]; const nextChar = state.src[pos + 1]; if (char !== "$") { return { can_open: false, can_close: false }; } let canOpen = false; let canClose = false; if ( prevChar !== "$" && prevChar !== "\\" && (prevChar === undefined || isWhitespace(prevChar) || !isWordCharacterOrNumber(prevChar)) ) { canOpen = true; } if ( nextChar !== "$" && (nextChar == undefined || isWhitespace(nextChar) || !isWordCharacterOrNumber(nextChar)) ) { canClose = true; } return { can_open: canOpen, can_close: canClose }; } /** * @param {*} state * @param {number} pos */ function isValidBlockDelim(state, pos) { const prevChar = state.src[pos - 1]; const char = state.src[pos]; const nextChar = state.src[pos + 1]; const nextCharPlus1 = state.src[pos + 2]; if ( char === "$" && prevChar !== "$" && prevChar !== "\\" && nextChar === "$" && nextCharPlus1 !== "$" ) { return { can_open: true, can_close: true }; } return { can_open: false, can_close: false }; } /** * @param {*} state * @param {boolean} silent */ function inlineMath(state, silent) { if (state.src[state.pos] !== "$") { return false; } let res = isValidInlineDelim(state, state.pos); if (!res.can_open) { if (!silent) { state.pending += "$"; } state.pos += 1; return true; } // First check for and bypass all properly escaped delimieters // This loop will assume that the first leading backtick can not // be the first character in state.src, which is known since // we have found an opening delimieter already. let start = state.pos + 1; let match = start; let pos; while ((match = state.src.indexOf("$", match)) !== -1) { // Found potential $, look for escapes, pos will point to // first non escape when complete pos = match - 1; while (state.src[pos] === "\\") { pos -= 1; } // Even number of escapes, potential closing delimiter found if ((match - pos) % 2 == 1) { break; } match += 1; } // No closing delimiter found. Consume $ and continue. if (match === -1) { if (!silent) { state.pending += "$"; } state.pos = start; return true; } // Check if we have empty content, ie: $$. Do not parse. if (match - start === 0) { if (!silent) { state.pending += "$$"; } state.pos = start + 1; return true; } // Check for valid closing delimiter res = isValidInlineDelim(state, match); if (!res.can_close) { if (!silent) { state.pending += "$"; } state.pos = start; return true; } if (!silent) { const token = state.push("math_inline", "math", 0); token.markup = "$"; token.content = state.src.slice(start, match); } state.pos = match + 1; return true; } /** * @param {StateInline} state * @param {boolean} silent */ function inlineMathBlock(state, silent) { var start, match, token, res, pos; if (state.src.slice(state.pos, state.pos + 2) !== "$$") { return false; } res = isValidBlockDelim(state, state.pos); if (!res.can_open) { if (!silent) { state.pending += "$$"; } state.pos += 2; return true; } // First check for and bypass all properly escaped delimieters // This loop will assume that the first leading backtick can not // be the first character in state.src, which is known since // we have found an opening delimieter already. start = state.pos + 2; match = start; while ((match = state.src.indexOf("$$", match)) !== -1) { // Found potential $$, look for escapes, pos will point to // first non escape when complete pos = match - 1; while (state.src[pos] === "\\") { pos -= 1; } // Even number of escapes, potential closing delimiter found if ((match - pos) % 2 == 1) { break; } match += 2; } // No closing delimiter found. Consume $$ and continue. if (match === -1) { if (!silent) { state.pending += "$$"; } state.pos = start; return true; } // Check if we have empty content, ie: $$$$. Do not parse. if (match - start === 0) { if (!silent) { state.pending += "$$$$"; } state.pos = start + 2; return true; } // Check for valid closing delimiter res = isValidBlockDelim(state, match); if (!res.can_close) { if (!silent) { state.pending += "$$"; } state.pos = start; return true; } if (!silent) { token = state.push("math_block", "math", 0); token.block = true; token.markup = "$$"; token.content = state.src.slice(start, match); } state.pos = match + 2; return true; } export { plugin };