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,
|
||||
processText: ProcessTextCallback
|
||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||
processText: ProcessTextCallback,
|
||||
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[]> = [];
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
|
|
@ -269,7 +326,7 @@ const parseListNode = (
|
|||
lineHolder = [];
|
||||
};
|
||||
|
||||
node.children.forEach((child) => {
|
||||
children.forEach((child) => {
|
||||
if (isText(child)) {
|
||||
lineHolder.push({ text: processText(child.data) });
|
||||
return;
|
||||
|
|
@ -292,24 +349,23 @@ const parseListNode = (
|
|||
});
|
||||
appendLine();
|
||||
|
||||
const mdSequence = node.attribs['data-md'];
|
||||
if (mdSequence !== undefined) {
|
||||
const prefix = mdSequence || '-';
|
||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||
return listLines.map((lineChildren) => ({
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
|
||||
...lineChildren,
|
||||
],
|
||||
}));
|
||||
return listLines;
|
||||
};
|
||||
const parseListNode = (
|
||||
node: Element,
|
||||
processText: ProcessTextCallback
|
||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
return parseListMarkdown(node, processText);
|
||||
}
|
||||
|
||||
const lines = parseListLines(node.childNodes, processText);
|
||||
|
||||
if (node.name === 'ol') {
|
||||
return [
|
||||
{
|
||||
type: BlockType.OrderedList,
|
||||
children: listLines.map((lineChildren) => ({
|
||||
children: lines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
|
|
@ -320,7 +376,7 @@ const parseListNode = (
|
|||
return [
|
||||
{
|
||||
type: BlockType.UnorderedList,
|
||||
children: listLines.map((lineChildren) => ({
|
||||
children: lines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { replaceMatch } from '../internal';
|
||||
import {
|
||||
BlockQuoteRule,
|
||||
CodeBlockRule,
|
||||
ESC_BLOCK_SEQ,
|
||||
HeadingRule,
|
||||
OrderedListRule,
|
||||
UnorderedListRule,
|
||||
} from './rules';
|
||||
import { BlockQuoteRule, CodeBlockRule, ESC_BLOCK_SEQ, HeadingRule, ListRule } from './rules';
|
||||
import { runBlockRule } from './runner';
|
||||
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, BlockQuoteRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, ListRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
|
||||
|
||||
// replace \n with <br/> because want to preserve empty lines
|
||||
|
|
|
|||
|
|
@ -10,18 +10,22 @@ export const HeadingRule: BlockMDRule = {
|
|||
},
|
||||
};
|
||||
|
||||
const CODEBLOCK_MD_1 = '```';
|
||||
const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
|
||||
// opening fence: 3 or more backticks
|
||||
// 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 = {
|
||||
match: (text) => text.match(CODEBLOCK_REG_1),
|
||||
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.
|
||||
const langCode = g1 ? g1.substring(g1.lastIndexOf('.') + 1) : null;
|
||||
const filename = g1 !== langCode ? g1 : null;
|
||||
const classNameAtt = langCode ? ` class="language-${langCode}"` : '';
|
||||
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 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),
|
||||
const LIST_ITEM_REG = /^( *)([-*]|[\da-zA-Z]\.) +(.+)$/;
|
||||
type ListType = 'ol' | 'ul';
|
||||
|
||||
function getListType(marker: string): ListType {
|
||||
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) => {
|
||||
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('');
|
||||
const lines = parseLines(listText);
|
||||
|
||||
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)`,
|
||||
});
|
||||
|
||||
const BaseList = style({});
|
||||
export const List = style([
|
||||
BaseList,
|
||||
DefaultReset,
|
||||
MarginSpaced,
|
||||
{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
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