// // Copyright 2025 DXOS.org // import { xmlLanguage } from '@codemirror/lang-xml'; import { type Extension } from '@codemirror/state'; import { type ParseWrapper, parseMixed } from '@lezer/common'; import { type BlockParser } from '@lezer/markdown'; import { escapeRegExpSource } from '../../../util/index.ts'; import { createMarkdownExtensions } from '../markdown/index.ts'; import { type XmlWidgetRegistry } from './xml-tags.ts'; export type ExtendedMarkdownOptions = { registry?: XmlWidgetRegistry; }; /** * Extended markdown parser with mixed parser for custom rendering of XML tags. */ export const extendedMarkdown = ({ registry }: ExtendedMarkdownOptions = {}): Extension => { return [ createMarkdownExtensions({ extensions: [ { wrap: mixedParser(registry), parseBlock: [ // Disable SetextHeading since it causes flickering when parsing/rendering tasks in chunks. { name: 'SetextHeading', parse: () => false, }, // Custom XML block parser that keeps registered tags as a single HTMLBlock // even when their content contains blank lines. ...xmlBlockParsers(registry), ], }, ], }), ]; }; /** * Creates block parsers for registered XML tags that may contain blank lines. * * By default, the markdown parser treats custom HTML tags as type 6 HTML blocks, * which end at blank lines. This causes tags like `...\n\n...` * to be split into separate blocks, preventing the XML mixed parser from seeing the * full element. This custom parser consumes all lines (including blanks) until the * matching closing tag, emitting a single HTMLBlock. */ const xmlBlockParsers = (registry?: XmlWidgetRegistry): BlockParser[] => { const customTags = Object.keys(registry ?? {}); if (customTags.length === 0) { return []; } const tagPattern = customTags.map(escapeRegExpSource).join('|'); const selfClosePattern = new RegExp(`^\\s*<(${tagPattern})(\\s[^>]*)?\\/>\\s*$`); const openPattern = new RegExp(`^\\s*<(${tagPattern})(\\s[^>]*)?\\/?>`); return [ { name: 'XMLBlock', before: 'HTMLBlock', parse: (cx, line) => { const match = openPattern.exec(line.text); if (!match) { return false; } // Self-closing tag (e.g., `` or ``). if (selfClosePattern.test(line.text)) { const end = cx.lineStart + line.text.length; cx.addElement(cx.elt('HTMLBlock', cx.lineStart, end)); cx.nextLine(); return true; } // Self-closing tag with trailing text (e.g., ` text`). // Let markdown parse it as a normal paragraph. if (match[0].trimEnd().endsWith('/>')) { return false; } const tagName = match[1]; const closeTag = ``; const start = cx.lineStart; // Check if closing tag is on the same line. if (line.text.includes(closeTag)) { cx.addElement(cx.elt('HTMLBlock', start, start + line.text.length)); cx.nextLine(); return true; } // Consume lines (including blank lines) until the closing tag. let end = cx.lineStart + line.text.length; while (cx.nextLine()) { end = cx.lineStart + line.text.length; if (line.text.includes(closeTag)) { cx.addElement(cx.elt('HTMLBlock', start, end)); cx.nextLine(); return true; } } // Unclosed tag (e.g., still streaming) — emit what we have. cx.addElement(cx.elt('HTMLBlock', start, end)); return true; }, }, ]; }; /** * Configure mixed parser to recognize custom tags. */ const mixedParser = (registry?: XmlWidgetRegistry): ParseWrapper => { const customTags = Object.keys(registry ?? {}); const tagPattern = new RegExp(`<(${customTags.map(escapeRegExpSource).join('|')})`); return parseMixed((node, input) => { switch (node.name) { // Ignore XML inside of fenced and inline code. case 'FencedCode': case 'InlineCode': { return null; } // Parse multi-line HTML blocks. // case 'XMLBlock': case 'HTMLBlock': { return { parser: xmlLanguage.parser, }; } // Parse paragraphs that contain custom XML tags. // TODO(burdon): Entire paragraph should be parsed as XML. case 'Paragraph': { const content = input.read(node.from, node.to); if (tagPattern.test(content)) { return { parser: xmlLanguage.parser, }; } return null; } } return null; }); };