Escape markdown sequences (#2208)

* escape inline markdown character

* fix typo

* improve document around custom markdown plugin and add escape sequence utils

* recover inline escape sequences on edit

* remove escape sequences from plain text body

* use `s` for strike-through instead of del

* escape block markdown sequences

* fix remove escape sequence was not removing all slashes from plain text

* recover block sequences on edit
This commit is contained in:
Ajay Bura
2025-02-21 19:19:24 +11:00
committed by GitHub
parent b63868bbb5
commit 7456c152b7
19 changed files with 764 additions and 476 deletions
+1
View File
@@ -0,0 +1 @@
export * from './parser';
+47
View File
@@ -0,0 +1,47 @@
import { replaceMatch } from '../internal';
import {
BlockQuoteRule,
CodeBlockRule,
ESC_BLOCK_SEQ,
HeadingRule,
OrderedListRule,
UnorderedListRule,
} from './rules';
import { runBlockRule } from './runner';
import { BlockMDParser } from './type';
/**
* Parses block-level markdown text into HTML using defined block rules.
*
* @param text - The markdown text to be parsed.
* @param parseInline - Optional function to parse inline elements.
* @returns The parsed HTML or the original text if no block-level markdown was found.
*/
export const parseBlockMD: BlockMDParser = (text, parseInline) => {
if (text === '') return text;
let result: string | undefined;
if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
// replace \n with <br/> because want to preserve empty lines
if (!result) {
result = text
.split('\n')
.map((lineText) => {
const match = lineText.match(ESC_BLOCK_SEQ);
if (!match) {
return parseInline?.(lineText) ?? lineText;
}
const [, g1] = match;
return replaceMatch(lineText, match, g1, (t) => [parseInline?.(t) ?? t]).join('');
})
.join('<br/>');
}
return result ?? text;
};
+100
View File
@@ -0,0 +1,100 @@
import { BlockMDRule } from './type';
const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m;
export const HeadingRule: BlockMDRule = {
match: (text) => text.match(HEADING_REG_1),
html: (match, parseInline) => {
const [, g1, g2] = match;
const level = g1.length;
return `<h${level} data-md="${g1}">${parseInline ? parseInline(g2) : g2}</h${level}>`;
},
};
const CODEBLOCK_MD_1 = '```';
const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
export const CodeBlockRule: BlockMDRule = {
match: (text) => text.match(CODEBLOCK_REG_1),
html: (match) => {
const [, g1, g2] = match;
const classNameAtt = g1 ? ` class="language-${g1}"` : '';
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}>${g2}</code></pre>`;
},
};
const BLOCKQUOTE_MD_1 = '>';
const QUOTE_LINE_PREFIX = /^> */;
const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/;
const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m;
export const BlockQuoteRule: BlockMDRule = {
match: (text) => text.match(BLOCKQUOTE_REG_1),
html: (match, parseInline) => {
const [blockquoteText] = match;
const lines = blockquoteText
.replace(BLOCKQUOTE_TRAILING_NEWLINE, '')
.split('\n')
.map((lineText) => {
const line = lineText.replace(QUOTE_LINE_PREFIX, '');
if (parseInline) return `${parseInline(line)}<br/>`;
return `${line}<br/>`;
})
.join('');
return `<blockquote data-md="${BLOCKQUOTE_MD_1}">${lines}</blockquote>`;
},
};
const ORDERED_LIST_MD_1 = '-';
const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */;
const O_LIST_START = /^([\d])\./;
const O_LIST_TYPE = /^([aAiI])\./;
const O_LIST_TRAILING_NEWLINE = /\n$/;
const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m;
export const OrderedListRule: BlockMDRule = {
match: (text) => text.match(ORDERED_LIST_REG_1),
html: (match, parseInline) => {
const [listText] = match;
const [, listStart] = listText.match(O_LIST_START) ?? [];
const [, listType] = listText.match(O_LIST_TYPE) ?? [];
const lines = listText
.replace(O_LIST_TRAILING_NEWLINE, '')
.split('\n')
.map((lineText) => {
const line = lineText.replace(O_LIST_ITEM_PREFIX, '');
const txt = parseInline ? parseInline(line) : line;
return `<li><p>${txt}</p></li>`;
})
.join('');
const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
const startAtt = listStart ? ` start="${listStart}"` : '';
const typeAtt = listType ? ` type="${listType}"` : '';
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>${lines}</ol>`;
},
};
const UNORDERED_LIST_MD_1 = '*';
const U_LIST_ITEM_PREFIX = /^\* */;
const U_LIST_TRAILING_NEWLINE = /\n$/;
const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
export const UnorderedListRule: BlockMDRule = {
match: (text) => text.match(UNORDERED_LIST_REG_1),
html: (match, parseInline) => {
const [listText] = match;
const lines = listText
.replace(U_LIST_TRAILING_NEWLINE, '')
.split('\n')
.map((lineText) => {
const line = lineText.replace(U_LIST_ITEM_PREFIX, '');
const txt = parseInline ? parseInline(line) : line;
return `<li><p>${txt}</p></li>`;
})
.join('');
return `<ul data-md="${UNORDERED_LIST_MD_1}">${lines}</ul>`;
},
};
export const UN_ESC_BLOCK_SEQ = /^\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +)/;
export const ESC_BLOCK_SEQ = /^\\(\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +))/;
+25
View File
@@ -0,0 +1,25 @@
import { replaceMatch } from '../internal';
import { BlockMDParser, BlockMDRule } from './type';
/**
* Parses block-level markdown text into HTML using defined block rules.
*
* @param text - The text to parse.
* @param rule - The markdown rule to run.
* @param parse - A function that run the parser on remaining parts..
* @param parseInline - Optional function to parse inline elements.
* @returns The text with the markdown rule applied or `undefined` if no match is found.
*/
export const runBlockRule = (
text: string,
rule: BlockMDRule,
parse: BlockMDParser,
parseInline?: (txt: string) => string
): string | undefined => {
const matchResult = rule.match(text);
if (matchResult) {
const content = rule.html(matchResult, parseInline);
return replaceMatch(text, matchResult, content, (txt) => [parse(txt, parseInline)]).join('');
}
return undefined;
};
+30
View File
@@ -0,0 +1,30 @@
import { MatchResult, MatchRule } from '../internal';
/**
* Type for a function that parses block-level markdown into HTML.
*
* @param text - The markdown text to be parsed.
* @param parseInline - Optional function to parse inline elements.
* @returns The parsed HTML.
*/
export type BlockMDParser = (text: string, parseInline?: (txt: string) => string) => string;
/**
* Type for a function that converts a block match to output.
*
* @param match - The match result.
* @param parseInline - Optional function to parse inline elements.
* @returns The output string after processing the match.
*/
export type BlockMatchConverter = (
match: MatchResult,
parseInline?: (txt: string) => string
) => string;
/**
* Type representing a block-level markdown rule that includes a matching pattern and HTML conversion.
*/
export type BlockMDRule = {
match: MatchRule; // A function that matches a specific markdown pattern.
html: BlockMatchConverter; // A function that converts the match to HTML.
};