UNPKG

vue-markdown-unified

Version:

Vue3 VNode to render markdown

283 lines (275 loc) 10.3 kB
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'; import { unified } from 'unified'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import { computed, ref, Fragment } from 'vue'; import { jsxs, jsx } from 'vue/jsx-runtime'; import { rehypeGithubAlerts } from 'rehype-github-alerts'; import rehypeRaw from 'rehype-raw'; import rehypeKatex from 'rehype-katex'; import rehypePrism from 'rehype-prism'; import rehypeSanitize from 'rehype-sanitize'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import remarkMath from 'remark-math'; import { renderToString } from 'katex'; function markdownRehypePlugins(options) { const { allowHtml, rehypePlugins, enableLatex, enableSanitize } = options; const plugins = computed(() => [ [rehypePrism, { plugins: ["line-numbers"] }], rehypeGithubAlerts, allowHtml && rehypeRaw, enableLatex && [rehypeKatex, { displayMode: true }], enableSanitize && rehypeSanitize ].filter(Boolean)); return computed(() => [ ...plugins.value, ...rehypePlugins || [] ]); } function markdownRemarkPlugins(options) { const { remarkPlugins, isBreaks, enableLatex } = options; const plugins = computed(() => [ [remarkGfm, { singleTilde: false }], isBreaks && remarkBreaks, enableLatex && remarkMath, ].filter(Boolean)); return computed(() => [ ...plugins.value, ...remarkPlugins || [] ]); } /** * Converts LaTeX bracket delimiters to dollar sign delimiters. * Converts \[...\] to $$...$$ and \(...\) to $...$ * Preserves code blocks during the conversion. * * @param text The input string containing LaTeX expressions * @returns The string with LaTeX bracket delimiters converted to dollar sign delimiters */ function convertLatexDelimiters(text) { const pattern = /(```[\S\s]*?```|`.*?`)|\\\[([\S\s]*?[^\\])\\]|\\\((.*?)\\\)/g; return text.replaceAll(pattern, (match, codeBlock, squareBracket, roundBracket) => { if (codeBlock !== undefined) { return codeBlock; } else if (squareBracket !== undefined) { return `$$${squareBracket}$$`; } else if (roundBracket !== undefined) { return `$${roundBracket}$`; } return match; }); } /** * Escapes mhchem commands in LaTeX expressions to ensure proper rendering. * * @param text The input string containing LaTeX expressions with mhchem commands * @returns The string with escaped mhchem commands */ function escapeMhchemCommands(text) { return text.replaceAll('$\\ce{', '$\\\\ce{').replaceAll('$\\pu{', '$\\\\pu{'); } /** * Escapes pipe characters within LaTeX expressions to prevent them from being interpreted * as table column separators in markdown tables. * * @param text The input string containing LaTeX expressions * @returns The string with pipe characters escaped in LaTeX expressions */ function escapeLatexPipes(text) { // According to the failing test, we should not escape pipes in LaTeX expressions // This function is now a no-op but is kept for backward compatibility return text; } /** * Escapes underscores within \text{...} commands in LaTeX expressions * that are not already escaped. * For example, \text{node_domain} becomes \text{node\_domain}, * but \text{node\_domain} remains \text{node\_domain}. * * @param text The input string potentially containing LaTeX expressions * @returns The string with unescaped underscores escaped within \text{...} commands */ function escapeTextUnderscores(text) { return text.replaceAll(/\\text{([^}]*)}/g, (match, textContent) => { // textContent is the content within the braces, e.g., "node_domain" or "already\_escaped" // Replace underscores '_' with '\_' only if they are NOT preceded by a backslash '\'. // The (?<!\\) is a negative lookbehind assertion that ensures the character before '_' is not a '\'. const escapedTextContent = textContent.replaceAll(/(?<!\\)_/g, '\\_'); return `\\text{${escapedTextContent}}`; }); } /** * Preprocesses LaTeX content by performing multiple operations: * 1. Protects code blocks from processing * 2. Protects existing LaTeX expressions * 3. Escapes dollar signs that likely represent currency * 4. Converts LaTeX delimiters * 5. Escapes mhchem commands and pipes * * @param content The input string containing LaTeX expressions * @returns The processed string with proper LaTeX formatting */ function preprocessLaTeX(str) { let content = str; // Step 6: Apply additional escaping functions content = convertLatexDelimiters(content); content = escapeMhchemCommands(content); content = escapeLatexPipes(content); content = escapeTextUnderscores(content); return content; } /** * Extracts the LaTeX formula after the last $$ delimiter if there's an odd number of $$ delimiters. * * @param text The input string containing LaTeX formulas * @returns The content after the last $$ if there's an odd number of $$, otherwise an empty string */ const extractIncompleteFormula = (text) => { // Count the number of $$ delimiters const dollarsCount = (text.match(/\$\$/g) || []).length; // If odd number of $$ delimiters, extract content after the last $$ if (dollarsCount % 2 === 1) { const match = text.match(/\$\$([^]*)$/); return match ? match[1] : ''; } // If even number of $$ delimiters, return empty string return ''; }; /** * Checks if the last LaTeX formula in the text is renderable. * Only validates the formula after the last $$ if there's an odd number of $$. * * @param text The input string containing LaTeX formulas * @returns True if the last formula is renderable or if there's no incomplete formula */ const isLastFormulaRenderable = (text) => { const formula = extractIncompleteFormula(text); // If no incomplete formula, return true if (!formula) return true; // Try to render the last formula try { renderToString(formula, { displayMode: true, throwOnError: true, }); return true; } catch (error) { console.log(`LaTeX formula rendering error: ${error}`); return false; } }; function fixMarkdownBold(text) { let asteriskCount = 0; let boldMarkerCount = 0; let result = ''; let inCodeBlock = false; let inInlineCode = false; for (let i = 0; i < text.length; i++) { const char = text[i]; // Handle code blocks if (text.slice(i, i + 3) === '```') { inCodeBlock = !inCodeBlock; result += '```'; i += 2; continue; } // Handle inline code if (char === '`') { inInlineCode = !inInlineCode; result += '`'; continue; } // Process asterisks only if not in code if (char === '*' && !inInlineCode && !inCodeBlock) { asteriskCount++; if (asteriskCount === 2) { boldMarkerCount++; } if (asteriskCount > 2) { result += char; continue; } // Add space before opening bold marker if needed if (asteriskCount === 2 && boldMarkerCount % 2 === 1) { const nextChar = i + 1 < text.length ? text[i + 1] : ''; const isNextCharSymbol = /[\p{P}\p{S}]/u.test(nextChar); if (isNextCharSymbol) { // 已经向 result 写入了第一个 '*',先删掉它,然后输出 ' **' result = `${result.slice(0, -1)} **`; continue; } } // Add space after closing bold marker if needed if (asteriskCount === 2 && boldMarkerCount % 2 === 0) { const prevChar = i > 0 ? text[i - 2] : ''; const isPrevCharSymbol = /[\p{P}\p{S}]/u.test(prevChar); result += i + 1 < text.length && text[i + 1] !== ' ' && isPrevCharSymbol ? '* ' : '*'; } else { result += '*'; } } else { result += char; asteriskCount = 0; } } return result; } function markdownContent(content, options) { const { enableLatex, animated } = options; const validContent = ref(''); const prevProcessedContent = ref({ current: '' }); // Process LaTeX expressions if (enableLatex) { content = preprocessLaTeX(content); } let processedContent = fixMarkdownBold(content); // Special handling for LaTeX content when animated if (animated && enableLatex) { const isRenderable = isLastFormulaRenderable(processedContent); if (!isRenderable && validContent) { processedContent = validContent.value; } } // Only update state if content changed (prevents unnecessary re-renders) if (processedContent !== prevProcessedContent.value.current) { validContent.value = processedContent; prevProcessedContent.value.current = processedContent; } return processedContent; } const emptyRemarkRehypeOptions = { allowDangerousHtml: true }; function Markdown(content, options = {}) { const remarkRehypeOptions = options.remarkRehypeOptions ? { ...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions } : emptyRemarkRehypeOptions; const remarkPlugins = markdownRemarkPlugins(options); const rehypePlugins = markdownRehypePlugins(options); const processor = unified() .use(remarkParse) .use(remarkPlugins.value) .use(remarkRehype, remarkRehypeOptions) .use(rehypePlugins.value); const escapedContent = markdownContent(content || '', options); const hast = processor.runSync(processor.parse(escapedContent)); const vnode = toJsxRuntime(hast, { Fragment, components: options.components, jsx, jsxs, elementAttributeNameCase: 'html', ignoreInvalidStyle: true, passKeys: true, passNode: true }); return vnode; } export { Markdown };