From 02d10015832341e4332937f7b8ac5e76ff7671ae Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 14 May 2026 15:02:54 +1000 Subject: [PATCH] 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 --- src/app/components/editor/input.ts | 90 +++++++++-- src/app/plugins/markdown/block/parser.ts | 12 +- src/app/plugins/markdown/block/rules.ts | 191 +++++++++++++++++------ src/app/styles/CustomHtml.css.ts | 11 ++ 4 files changed, 229 insertions(+), 75 deletions(-) diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 1af30a7..56da589 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -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 = []; 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, })), diff --git a/src/app/plugins/markdown/block/parser.ts b/src/app/plugins/markdown/block/parser.ts index ed16a32..b4aa1c3 100644 --- a/src/app/plugins/markdown/block/parser.ts +++ b/src/app/plugins/markdown/block/parser.ts @@ -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
because want to preserve empty lines diff --git a/src/app/plugins/markdown/block/rules.ts b/src/app/plugins/markdown/block/rules.ts index ad1af37..231397c 100644 --- a/src/app/plugins/markdown/block/rules.ts +++ b/src/app/plugins/markdown/block/rules.ts @@ -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 `
${g2}
`; + return `
${g2}
`; }, }; @@ -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 `
  • ${txt}

  • `; - }) - .join(''); - - const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; - const startAtt = listStart ? ` start="${listStart}"` : ''; - const typeAtt = listType ? ` type="${listType}"` : ''; - return `
      ${lines}
    `; - }, -}; - 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 `' : ''; +} + +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 += ''; + + // 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 += ''; + + while (stack.length > line.indent + 1) { + html += closeList(stack.pop()!); + html += ''; + } + + if (line.listType !== stack[stack.length - 1]) { + html += closeList(stack.pop()!); + + html += openList(line); + stack.push(line.listType); + } + } + + html += `
  • ${content}

    `; + + // LAST ITEM cleanup + if (!next) { + html += '
  • '; + + 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 `
  • ${txt}

  • `; - }) - .join(''); + const lines = parseLines(listText); - return ``; + const html = buildList(lines, parseInline); + + return html; }, }; diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index ba7b921..51f841b 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -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, + }, + }, }, ]);