vue-markdown-unified
Version:
Vue3 VNode to render markdown
283 lines (275 loc) • 10.3 kB
JavaScript
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 };