feat: allow codeblock plaintext inside codeblock and nested lists markdown (#2930)
* fix crash when editing message with empty trailing heading * remove unused imports * allow codeblock plaintext inside codeblock by extending fence count * allow nested list in markdown
This commit is contained in:
parent
64468dfb1b
commit
02d1001583
4 changed files with 227 additions and 73 deletions
|
|
@ -255,10 +255,67 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
const parseListNode = (
|
|
||||||
|
const parseListMarkdown = (
|
||||||
node: Element,
|
node: Element,
|
||||||
processText: ProcessTextCallback
|
processText: ProcessTextCallback,
|
||||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
depth = 0
|
||||||
|
): ParagraphElement[] => {
|
||||||
|
const md = isTag(node) && node.name === 'ul' ? '*' : '-';
|
||||||
|
const prefix = node.attribs['data-md'] ?? md;
|
||||||
|
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||||
|
const [digitOrChar] = prefix.match(/^[\da-zA-Z]/) ?? [];
|
||||||
|
|
||||||
|
const digit = digitOrChar ? parseInt(digitOrChar, 10) : undefined;
|
||||||
|
|
||||||
|
const lines: ParagraphElement[] = [];
|
||||||
|
let lineNo = digit === undefined || Number.isNaN(digit) ? digitOrChar ?? 1 : digit;
|
||||||
|
const pushLine = (line: InlineElement[]) => {
|
||||||
|
lines.push({
|
||||||
|
type: BlockType.Paragraph,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: `${Array(depth + 1).join(' ')}${starOrHyphen ? `${starOrHyphen} ` : `${lineNo}. `}`,
|
||||||
|
},
|
||||||
|
...line,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (typeof lineNo === 'string') {
|
||||||
|
lineNo = String.fromCharCode(lineNo.charCodeAt(0) + 1);
|
||||||
|
} else {
|
||||||
|
lineNo += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
node.children.forEach((child) => {
|
||||||
|
if (isText(child)) {
|
||||||
|
pushLine([{ text: processText(child.data) }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTag(child)) {
|
||||||
|
if (child.name === 'ul' || child.name === 'ol') {
|
||||||
|
lines.push(...parseListMarkdown(child, processText, depth + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (child.name === 'li') {
|
||||||
|
child.children.forEach((c) => {
|
||||||
|
if (isTag(c) && (c.name === 'ul' || c.name === 'ol')) {
|
||||||
|
lines.push(...parseListMarkdown(c, processText, depth + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushLine(getInlineElement(c, processText));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushLine(getInlineElement(child, processText));
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
const parseListLines = (children: ChildNode[], processText: ProcessTextCallback) => {
|
||||||
const listLines: Array<InlineElement[]> = [];
|
const listLines: Array<InlineElement[]> = [];
|
||||||
let lineHolder: InlineElement[] = [];
|
let lineHolder: InlineElement[] = [];
|
||||||
|
|
||||||
|
|
@ -269,7 +326,7 @@ const parseListNode = (
|
||||||
lineHolder = [];
|
lineHolder = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
node.children.forEach((child) => {
|
children.forEach((child) => {
|
||||||
if (isText(child)) {
|
if (isText(child)) {
|
||||||
lineHolder.push({ text: processText(child.data) });
|
lineHolder.push({ text: processText(child.data) });
|
||||||
return;
|
return;
|
||||||
|
|
@ -292,24 +349,23 @@ const parseListNode = (
|
||||||
});
|
});
|
||||||
appendLine();
|
appendLine();
|
||||||
|
|
||||||
const mdSequence = node.attribs['data-md'];
|
return listLines;
|
||||||
if (mdSequence !== undefined) {
|
};
|
||||||
const prefix = mdSequence || '-';
|
const parseListNode = (
|
||||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
node: Element,
|
||||||
return listLines.map((lineChildren) => ({
|
processText: ProcessTextCallback
|
||||||
type: BlockType.Paragraph,
|
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||||
children: [
|
if (node.attribs['data-md'] !== undefined) {
|
||||||
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
|
return parseListMarkdown(node, processText);
|
||||||
...lineChildren,
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lines = parseListLines(node.childNodes, processText);
|
||||||
|
|
||||||
if (node.name === 'ol') {
|
if (node.name === 'ol') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: BlockType.OrderedList,
|
type: BlockType.OrderedList,
|
||||||
children: listLines.map((lineChildren) => ({
|
children: lines.map((lineChildren) => ({
|
||||||
type: BlockType.ListItem,
|
type: BlockType.ListItem,
|
||||||
children: lineChildren,
|
children: lineChildren,
|
||||||
})),
|
})),
|
||||||
|
|
@ -320,7 +376,7 @@ const parseListNode = (
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: BlockType.UnorderedList,
|
type: BlockType.UnorderedList,
|
||||||
children: listLines.map((lineChildren) => ({
|
children: lines.map((lineChildren) => ({
|
||||||
type: BlockType.ListItem,
|
type: BlockType.ListItem,
|
||||||
children: lineChildren,
|
children: lineChildren,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import { replaceMatch } from '../internal';
|
import { replaceMatch } from '../internal';
|
||||||
import {
|
import { BlockQuoteRule, CodeBlockRule, ESC_BLOCK_SEQ, HeadingRule, ListRule } from './rules';
|
||||||
BlockQuoteRule,
|
|
||||||
CodeBlockRule,
|
|
||||||
ESC_BLOCK_SEQ,
|
|
||||||
HeadingRule,
|
|
||||||
OrderedListRule,
|
|
||||||
UnorderedListRule,
|
|
||||||
} from './rules';
|
|
||||||
import { runBlockRule } from './runner';
|
import { runBlockRule } from './runner';
|
||||||
import { BlockMDParser } from './type';
|
import { BlockMDParser } from './type';
|
||||||
|
|
||||||
|
|
@ -23,8 +16,7 @@ export const parseBlockMD: BlockMDParser = (text, parseInline) => {
|
||||||
|
|
||||||
if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline);
|
if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline);
|
||||||
if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline);
|
if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline);
|
||||||
if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline);
|
if (!result) result = runBlockRule(text, ListRule, parseBlockMD, parseInline);
|
||||||
if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline);
|
|
||||||
if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
|
if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
|
||||||
|
|
||||||
// replace \n with <br/> because want to preserve empty lines
|
// replace \n with <br/> because want to preserve empty lines
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,22 @@ export const HeadingRule: BlockMDRule = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const CODEBLOCK_MD_1 = '```';
|
// opening fence: 3 or more backticks
|
||||||
const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
|
// capture the exact fence length in group 1
|
||||||
|
// optional info string in group 2
|
||||||
|
// code content in group 3
|
||||||
|
// closing fence must match the exact same fence sequence via \1
|
||||||
|
const CODEBLOCK_REG_1 = /^(`{3,})(?!`)(\S*)\n((?:.*\n)+?)\1 *(?!.)\n?/m;
|
||||||
export const CodeBlockRule: BlockMDRule = {
|
export const CodeBlockRule: BlockMDRule = {
|
||||||
match: (text) => text.match(CODEBLOCK_REG_1),
|
match: (text) => text.match(CODEBLOCK_REG_1),
|
||||||
html: (match) => {
|
html: (match) => {
|
||||||
const [, g1, g2] = match;
|
const [, fence, g1, g2] = match;
|
||||||
// use last identifier after dot, e.g. for "example.json" gets us "json" as language code.
|
// use last identifier after dot, e.g. for "example.json" gets us "json" as language code.
|
||||||
const langCode = g1 ? g1.substring(g1.lastIndexOf('.') + 1) : null;
|
const langCode = g1 ? g1.substring(g1.lastIndexOf('.') + 1) : null;
|
||||||
const filename = g1 !== langCode ? g1 : null;
|
const filename = g1 !== langCode ? g1 : null;
|
||||||
const classNameAtt = langCode ? ` class="language-${langCode}"` : '';
|
const classNameAtt = langCode ? ` class="language-${langCode}"` : '';
|
||||||
const filenameAtt = filename ? ` data-label="${filename}"` : '';
|
const filenameAtt = filename ? ` data-label="${filename}"` : '';
|
||||||
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}${filenameAtt}>${g2}</code></pre>`;
|
return `<pre data-md="${fence}"><code${classNameAtt}${filenameAtt}>${g2}</code></pre>`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -48,55 +52,146 @@ export const BlockQuoteRule: BlockMDRule = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDERED_LIST_MD_1 = '-';
|
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 UNORDERED_LIST_MD_1 = '*';
|
||||||
const U_LIST_ITEM_PREFIX = /^\* */;
|
const LIST_ITEM_REG = /^( *)([-*]|[\da-zA-Z]\.) +(.+)$/;
|
||||||
const U_LIST_TRAILING_NEWLINE = /\n$/;
|
type ListType = 'ol' | 'ul';
|
||||||
const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
|
|
||||||
export const UnorderedListRule: BlockMDRule = {
|
function getListType(marker: string): ListType {
|
||||||
match: (text) => text.match(UNORDERED_LIST_REG_1),
|
return marker === '*' ? 'ul' : 'ol';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrderedMeta(marker: string) {
|
||||||
|
const startMatch = marker.match(/^(\d)\./);
|
||||||
|
const typeMatch = marker.match(/^([aAiI])\./);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startMatch?.[1],
|
||||||
|
type: typeMatch?.[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedLine {
|
||||||
|
indent: number;
|
||||||
|
marker: string;
|
||||||
|
content: string;
|
||||||
|
listType: ListType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLines(text: string): ParsedLine[] {
|
||||||
|
return text
|
||||||
|
.replace(/\n$/, '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const match = line.match(LIST_ITEM_REG);
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, spaces, marker, content] = match;
|
||||||
|
|
||||||
|
return {
|
||||||
|
indent: spaces.length,
|
||||||
|
marker,
|
||||||
|
content,
|
||||||
|
listType: getListType(marker),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as ParsedLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openList(line: ParsedLine) {
|
||||||
|
if (line.listType === 'ul') {
|
||||||
|
return `<ul data-md="${UNORDERED_LIST_MD_1}">`;
|
||||||
|
}
|
||||||
|
const { type, start } = getOrderedMeta(line.marker);
|
||||||
|
const dataMdAtt = `data-md="${type || start || ORDERED_LIST_MD_1}"`;
|
||||||
|
const startAtt = start ? ` start="${start}"` : '';
|
||||||
|
const typeAtt = type ? ` type="${type}"` : '';
|
||||||
|
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeList(listType: ListType) {
|
||||||
|
return listType === 'ul' ? '</ul>' : '</ol>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildList(lines: ParsedLine[], parseInline?: (s: string) => string): string {
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
const stack: ('ul' | 'ol')[] = [];
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const prev = lines[index - 1];
|
||||||
|
const next = lines[index + 1];
|
||||||
|
|
||||||
|
const content = parseInline ? parseInline(line.content) : line.content;
|
||||||
|
|
||||||
|
// FIRST ITEM
|
||||||
|
if (!prev) {
|
||||||
|
html += openList(line);
|
||||||
|
stack.push(line.listType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEEPER INDENT > open nested list
|
||||||
|
else if (line.indent > prev.indent) {
|
||||||
|
html += openList(line);
|
||||||
|
stack.push(line.listType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAME LEVEL
|
||||||
|
else if (line.indent === prev.indent) {
|
||||||
|
html += '</li>';
|
||||||
|
|
||||||
|
// different list type
|
||||||
|
if (line.listType !== prev.listType) {
|
||||||
|
html += closeList(stack.pop()!);
|
||||||
|
|
||||||
|
html += openList(line);
|
||||||
|
stack.push(line.listType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOING BACK UP
|
||||||
|
else if (line.indent < prev.indent) {
|
||||||
|
html += '</li>';
|
||||||
|
|
||||||
|
while (stack.length > line.indent + 1) {
|
||||||
|
html += closeList(stack.pop()!);
|
||||||
|
html += '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.listType !== stack[stack.length - 1]) {
|
||||||
|
html += closeList(stack.pop()!);
|
||||||
|
|
||||||
|
html += openList(line);
|
||||||
|
stack.push(line.listType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<li><p>${content}</p>`;
|
||||||
|
|
||||||
|
// LAST ITEM cleanup
|
||||||
|
if (!next) {
|
||||||
|
html += '</li>';
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
html += closeList(stack.pop()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_REG_1 = /^(?: *(?:[-*]|[\da-zA-Z]\.) +.+\n?)+/m;
|
||||||
|
export const ListRule: BlockMDRule = {
|
||||||
|
match: (text) => text.match(LIST_REG_1),
|
||||||
html: (match, parseInline) => {
|
html: (match, parseInline) => {
|
||||||
const [listText] = match;
|
const [listText] = match;
|
||||||
|
|
||||||
const lines = listText
|
const lines = parseLines(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>`;
|
const html = buildList(lines, parseInline);
|
||||||
|
|
||||||
|
return html;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,12 +120,23 @@ export const CodeBlockBottomShadow = style({
|
||||||
background: `linear-gradient(to top, #00000022, #00000000)`,
|
background: `linear-gradient(to top, #00000022, #00000000)`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BaseList = style({});
|
||||||
export const List = style([
|
export const List = style([
|
||||||
|
BaseList,
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
MarginSpaced,
|
MarginSpaced,
|
||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
paddingLeft: config.space.S600,
|
paddingLeft: config.space.S600,
|
||||||
|
selectors: {
|
||||||
|
'& &': {
|
||||||
|
marginTop: config.space.S200,
|
||||||
|
marginBottom: config.space.S200,
|
||||||
|
},
|
||||||
|
'li:last-child &': {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue