/** * 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.
,
${escapeHtml(match[2])}`;
rest = rest.slice(match.index + match[0].length);
}
return restoreEscapes(out);
}
/** True when a line begins a new block construct (ends a paragraph). */
function isBlockStart(line: string): boolean {
return (
HEADING_RE.test(line) ||
FENCE_RE.test(line) ||
HR_RE.test(line) ||
QUOTE_RE.test(line) ||
BULLET_RE.test(line) ||
ORDERED_RE.test(line)
);
}
interface ListItem {
lines: string[];
checked: boolean | null;
}
/**
* One list region → ${escapeHtml(buffer.join('\n'))}`);
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(`${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); }