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:
Ajay Bura 2026-05-14 15:02:54 +10:00 committed by GitHub
commit 02d1001583
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 227 additions and 73 deletions

View file

@ -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,
})),

View file

@ -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

View file

@ -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;
},
};

View file

@ -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,
},
},
},
]);