/** * Markdown → HTML conversion, the import counterpart of ./markdown * (docToMarkdown). Dependency-free and scoped to the same structural subset * the serializer emits: headings, blockquotes, bullet/ordered/task lists * (nested), fenced code blocks, GFM tables, horizontal rules, and the inline * marks (**bold**, *italic*, ~~strike~~, `code`, [links](…), images). * * The output is intentionally plain HTML that the schema's parseDOM rules * understand (e.g.
, 
`); continue; } // Heading (with optional trailing #'s, per CommonMark). const heading = HEADING_RE.exec(line); if (heading) { const level = heading[1].length; const text = heading[2].replace(/\s+#+\s*$/, ''); out.push(`${inlineToHtml(text)}`); i += 1; continue; } // Horizontal rule (checked before lists: "---" vs "- item"). if (HR_RE.test(line)) { out.push('
'); i += 1; continue; } // Blockquote: consecutive quoted lines, including lazy continuations. if (QUOTE_RE.test(line)) { const buffer: string[] = []; while ( i < lines.length && (QUOTE_RE.test(lines[i]) || (buffer.length > 0 && lines[i].trim() !== '' && !isBlockStart(lines[i]))) ) { buffer.push(lines[i].replace(QUOTE_RE, '')); i += 1; } out.push(`
${parseBlocks(buffer)}
`); continue; } // List: the region runs until a blank line not followed by more list // content, or a non-indented non-list line. if (BULLET_RE.test(line) || ORDERED_RE.test(line)) { const buffer: string[] = []; while (i < lines.length) { const current = lines[i]; if (current.trim() === '') { const next = lines[i + 1]; if (next !== undefined && (BULLET_RE.test(next) || ORDERED_RE.test(next) || /^\s{2,}\S/.test(next))) { i += 1; continue; } break; } if ( !BULLET_RE.test(current) && !ORDERED_RE.test(current) && !/^\s{2,}\S/.test(current) ) { break; } buffer.push(current); i += 1; } out.push(parseListRegion(buffer)); continue; } // GFM table: a pipe row followed by a divider row. if ( line.includes('|') && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]) && lines[i + 1].includes('|') ) { const buffer: string[] = [lines[i], lines[i + 1]]; i += 2; while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') { buffer.push(lines[i]); i += 1; } out.push(parseTableRegion(buffer)); continue; } // Paragraph: collect until a blank line or the start of another block. const buffer: string[] = [line]; i += 1; while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) { buffer.push(lines[i]); i += 1; } out.push(`

${inlineToHtml(buffer.join('\n'))}

`); } return out.join(''); } /** Parses a Markdown string into schema-compatible HTML. */ export function markdownToHtml(markdown: string): string { return parseBlocks(markdown.replace(/\r\n?/g, '\n').split('\n')); } /** * Conservative sniff for "this plain text is probably Markdown": one block * construct or one unambiguous inline construct is required. HTML-looking * text is never treated as Markdown. */ export function looksLikeMarkdown(text: string): boolean { if (/<\/?[a-z][^>]*>/i.test(text)) return false; const block = /^\s{0,3}(#{1,6}\s+\S|```|(?:[-*+]|\d+[.)])\s+\S|>\s?\S|\|.+\|)/m; const inline = /\*\*[^*\n]+\*\*|~~[^~\n]+~~|\[[^\]\n]+\]\([^)\n]+\)|`[^`\n]+`/; return block.test(text) || inline.test(text); }