@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
1,341 lines (1,208 loc) • 35.4 kB
text/typescript
import { isVideoUrl } from "../../../util/string.js";
/**
* Custom markdown-to-HTML converter for BlockNote.
* Replaces the unified/remark/rehype pipeline with a direct, minimal implementation
* that handles exactly the markdown features BlockNote needs.
*/
// ─── HTML Escaping ───────────────────────────────────────────────────────────
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isAlphanumeric(char: string | undefined): boolean {
if (!char) {
return false;
}
return /\w/.test(char);
}
/**
* Returns true when an underscore delimiter at position `i` is "intraword",
* meaning the characters on both sides are alphanumeric (e.g. `snake_case`).
* In that case the underscore should NOT be treated as emphasis per CommonMark.
*/
function isIntraword(text: string, i: number, delimLen: number): boolean {
const before = i > 0 ? text[i - 1] : undefined;
const after = i + delimLen < text.length ? text[i + delimLen] : undefined;
return isAlphanumeric(before) && isAlphanumeric(after);
}
// ─── Inline Parser ───────────────────────────────────────────────────────────
type InlineTokenizer = (
text: string,
i: number,
) => { html: string; end: number } | null;
function tryBackslashEscape(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] !== "\\" || i + 1 >= text.length) {
return null;
}
const next = text[i + 1];
// Hard line break: backslash at end of line
if (next === "\n") {
return { html: "<br>\n", end: i + 2 };
}
// Escapable characters
if ("\\`*_{}[]()#+-.!~|>".includes(next)) {
return { html: escapeHtml(next), end: i + 2 };
}
return null;
}
function tryInlineCode(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] !== "`") {
return null;
}
return parseInlineCode(text, i);
}
function tryImage(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] !== "!" || text[i + 1] !== "[") {
return null;
}
return parseImage(text, i);
}
function tryLink(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] !== "[") {
return null;
}
return parseLink(text, i);
}
function tryStrikethrough(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] !== "~" || text[i + 1] !== "~") {
return null;
}
return parseDelimited(text, i, "~~", "<del>", "</del>");
}
function tryBoldItalic(
text: string,
i: number,
): { html: string; end: number } | null {
if (
(text[i] === "*" && text[i + 1] === "*" && text[i + 2] === "*") ||
(text[i] === "_" &&
text[i + 1] === "_" &&
text[i + 2] === "_" &&
!isIntraword(text, i, 3))
) {
const delimiter = text.substring(i, i + 3);
return parseDelimited(text, i, delimiter, "<strong><em>", "</em></strong>");
}
return null;
}
function tryBold(
text: string,
i: number,
): { html: string; end: number } | null {
if (
(text[i] === "*" && text[i + 1] === "*") ||
(text[i] === "_" && text[i + 1] === "_" && !isIntraword(text, i, 2))
) {
const delimiter = text.substring(i, i + 2);
return parseDelimited(text, i, delimiter, "<strong>", "</strong>");
}
return null;
}
function tryItalic(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] === "*" || (text[i] === "_" && !isIntraword(text, i, 1))) {
return parseDelimited(text, i, text[i], "<em>", "</em>");
}
return null;
}
function trySoftBreak(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] === "\n") {
return { html: "<br>\n", end: i + 1 };
}
return null;
}
// Inline raw HTML: pass through tags, comments, CDATA, processing
// instructions, and declarations verbatim so authors can mix HTML into
// markdown (e.g. `text <em>foo</em> more`). Anything that doesn't match
// these shapes falls through and gets HTML-escaped as plain text.
const INLINE_HTML_TAG_RE =
/^<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/;
const HTML_COMMENT_RE = /^<!--[\s\S]*?-->/;
const HTML_CDATA_RE = /^<!\[CDATA\[[\s\S]*?\]\]>/;
const HTML_PI_RE = /^<\?[\s\S]*?\?>/;
const HTML_DECL_RE = /^<![A-Za-z][\s\S]*?>/;
function tryInlineHtml(
text: string,
i: number,
): { html: string; end: number } | null {
if (text[i] !== "<") {
return null;
}
const rest = text.substring(i);
for (const re of [
HTML_COMMENT_RE,
HTML_CDATA_RE,
HTML_PI_RE,
HTML_DECL_RE,
INLINE_HTML_TAG_RE,
]) {
const m = rest.match(re);
if (m) {
return { html: m[0], end: i + m[0].length };
}
}
return null;
}
/** Characters that can start an inline syntax token. */
const SPECIAL_CHARS = new Set("\\`![~*_\n<");
/**
* Ordered array of inline tokenizers, tried in priority order.
* The first match wins.
*/
const inlineTokenizers: InlineTokenizer[] = [
tryBackslashEscape,
tryInlineCode,
tryImage,
tryLink,
tryStrikethrough,
tryBoldItalic, // *** / ___
tryBold, // ** / __
tryItalic, // * / _
tryInlineHtml,
trySoftBreak,
];
/**
* Parse inline markdown syntax and return HTML.
* Handles: bold, italic, bold+italic, strikethrough, inline code,
* links, images (with video detection), hard line breaks, backslash escapes.
*/
function parseInline(text: string): string {
let result = "";
let i = 0;
while (i < text.length) {
// Hard line break: 2+ trailing spaces immediately before a newline.
// (The other hard-break form, backslash + newline, is handled by
// tryBackslashEscape.) Strip the trailing spaces from the accumulated
// result before emitting the <br>.
if (
text[i] === "\n" &&
i >= 2 &&
text[i - 1] === " " &&
text[i - 2] === " "
) {
result = result.replace(/ +$/, "");
result += "<br>\n";
i++;
continue;
}
// Try each tokenizer in priority order
let matched = false;
if (SPECIAL_CHARS.has(text[i])) {
for (const tokenizer of inlineTokenizers) {
const r = tokenizer(text, i);
if (r) {
result += r.html;
i = r.end;
matched = true;
break;
}
}
}
if (!matched) {
// Batch consecutive plain-text characters and escape once
const runStart = i;
i++;
while (i < text.length && !SPECIAL_CHARS.has(text[i])) {
i++;
}
result += escapeHtml(text.substring(runStart, i));
}
}
return result;
}
function parseInlineCode(
text: string,
start: number,
): { html: string; end: number } | null {
// Count opening backticks
let openCount = 0;
let i = start;
while (i < text.length && text[i] === "`") {
openCount++;
i++;
}
// Find matching closing backticks
let j = i;
while (j < text.length) {
if (text[j] === "`") {
let closeCount = 0;
const closeStart = j;
while (j < text.length && text[j] === "`") {
closeCount++;
j++;
}
if (closeCount === openCount) {
let code = text.substring(i, closeStart);
// Per CommonMark: line endings inside a code span are converted to
// single spaces, then if the result starts AND ends with a space and
// is not all-spaces, one leading + trailing space is stripped (so
// `` ` `foo` ` `` is `<code>`foo`</code>`).
code = code.replace(/\n/g, " ");
if (
code.length >= 2 &&
code[0] === " " &&
code[code.length - 1] === " " &&
/[^ ]/.test(code)
) {
code = code.substring(1, code.length - 1);
}
return {
html: `<code>${escapeHtml(code)}</code>`,
end: j,
};
}
} else {
j++;
}
}
return null;
}
function parseImage(
text: string,
start: number,
): { html: string; end: number } | null {
//  or 
// Use balanced bracket matching to handle nested/escaped brackets in alt text
const altEnd = findClosingBracket(text, start + 1);
if (altEnd === -1) {
return null;
}
const altStart = start + 2; // after ![
if (text[altEnd + 1] !== "(") {
return null;
}
const urlStart = altEnd + 2;
const parenEnd = findClosingParen(text, urlStart - 1);
if (parenEnd === -1) {
return null;
}
const alt = text.substring(altStart, altEnd);
const { url, title } = parseDestinationAndTitle(
text.substring(urlStart, parenEnd),
);
if (isVideoUrl(url)) {
// Use the alt text as the video's display name (falling back to the
// title) so a video link written with the standard `` form
// round-trips into BlockNote's video block. Captioned videos go through
// raw `<figure>` HTML instead, see htmlToMarkdown.serializeMediaFigure.
const name = alt || title;
return {
html: `<video src="${escapeHtml(url)}"${name ? ` data-name="${escapeHtml(name)}"` : ""} data-url="${escapeHtml(url)}" controls></video>`,
end: parenEnd + 1,
};
}
const titleAttr = title !== undefined ? ` title="${escapeHtml(title)}"` : "";
return {
html: `<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}"${titleAttr}>`,
end: parenEnd + 1,
};
}
function parseLink(
text: string,
start: number,
): { html: string; end: number } | null {
// [text](url)
const textStart = start + 1;
const textEnd = findClosingBracket(text, start);
if (textEnd === -1) {
return null;
}
if (text[textEnd + 1] !== "(") {
return null;
}
const urlStart = textEnd + 2;
const parenEnd = findClosingParen(text, textEnd + 1);
if (parenEnd === -1) {
return null;
}
const linkText = text.substring(textStart, textEnd);
const { url, title } = parseDestinationAndTitle(
text.substring(urlStart, parenEnd),
);
const titleAttr = title !== undefined ? ` title="${escapeHtml(title)}"` : "";
return {
html: `<a href="${escapeHtml(url)}"${titleAttr}>${parseInline(linkText)}</a>`,
end: parenEnd + 1,
};
}
function findClosingBracket(text: string, openPos: number): number {
let depth = 0;
for (let i = openPos; i < text.length; i++) {
if (text[i] === "\\" && i + 1 < text.length) {
i++; // skip escaped
continue;
}
if (text[i] === "[") {
depth++;
}
if (text[i] === "]") {
depth--;
if (depth === 0) {
return i;
}
}
}
return -1;
}
function findClosingParen(text: string, openPos: number): number {
let depth = 0;
for (let i = openPos; i < text.length; i++) {
if (text[i] === "\\" && i + 1 < text.length) {
i++;
continue;
}
if (text[i] === "(") {
depth++;
}
if (text[i] === ")") {
depth--;
if (depth === 0) {
return i;
}
}
}
return -1;
}
/**
* Parse the inside of `(...)` from a link/image (the URL and optional title).
* Handles three URL forms:
* - bare: `/uri` or `/uri "title"`
* - angle-bracket: `<url>` or `<url> "title"` (brackets are stripped)
* And three title-quote forms: `"..."`, `'...'`, `(...)`.
*/
function parseDestinationAndTitle(raw: string): {
url: string;
title?: string;
} {
raw = raw.trim();
let url: string;
let rest: string;
if (raw.startsWith("<")) {
const close = raw.indexOf(">");
if (close === -1) {
// Unmatched `<` — treat the whole thing as the URL minus the `<`.
url = raw.substring(1);
rest = "";
} else {
url = raw.substring(1, close);
rest = raw.substring(close + 1).trim();
}
} else {
// Split at first unescaped whitespace.
let split = raw.length;
for (let i = 0; i < raw.length; i++) {
if (raw[i] === "\\" && i + 1 < raw.length) {
i++;
continue;
}
if (raw[i] === " " || raw[i] === "\t" || raw[i] === "\n") {
split = i;
break;
}
}
url = raw.substring(0, split);
rest = raw.substring(split).trim();
}
let title: string | undefined;
if (rest.length > 0) {
const titleMatch = rest.match(/^"([^"]*)"$|^'([^']*)'$|^\(([^)]*)\)$/);
if (titleMatch) {
title = titleMatch[1] ?? titleMatch[2] ?? titleMatch[3];
}
}
return { url, title };
}
function parseDelimited(
text: string,
start: number,
delimiter: string,
openTag: string,
closeTag: string,
): { html: string; end: number } | null {
const len = delimiter.length;
const afterOpen = start + len;
if (afterOpen >= text.length) {
return null;
}
// Opening delimiter must not be followed by whitespace
if (text[afterOpen] === " " || text[afterOpen] === "\t") {
return null;
}
// Find closing delimiter
let j = afterOpen;
while (j < text.length) {
// Skip escaped characters
if (text[j] === "\\" && j + 1 < text.length) {
j += 2;
continue;
}
if (text.substring(j, j + len) === delimiter) {
// Closing delimiter must not be preceded by whitespace
if (text[j - 1] === " " || text[j - 1] === "\t") {
j++;
continue;
}
// For single-char delimiters, don't accept closer if it's part of a
// multi-char run (e.g., don't treat the * in ** as italic closer)
if (
len === 1 &&
((j > 0 &&
text[j - 1] === delimiter[0] &&
!(j >= 2 && text[j - 2] === "\\")) ||
(j + len < text.length && text[j + len] === delimiter[0]))
) {
j++;
continue;
}
const inner = text.substring(afterOpen, j);
if (inner.length === 0) {
j++;
continue;
}
return {
html: openTag + parseInline(inner) + closeTag,
end: j + len,
};
}
j++;
}
return null;
}
// ─── Block-Level Types ───────────────────────────────────────────────────────
interface BlockToken {
type: string;
}
interface HeadingToken extends BlockToken {
type: "heading";
level: number;
content: string;
}
interface ParagraphToken extends BlockToken {
type: "paragraph";
content: string;
}
interface CodeBlockToken extends BlockToken {
type: "codeBlock";
language: string;
code: string;
}
interface BlockquoteToken extends BlockToken {
type: "blockquote";
content: string;
}
interface HorizontalRuleToken extends BlockToken {
type: "hr";
}
interface ListItemToken extends BlockToken {
type: "listItem";
listType: "bullet" | "ordered" | "task";
indent: number;
content: string;
start?: number; // for ordered lists
checked?: boolean; // for task lists
childContent?: string; // recursively parsed content within this item
}
interface TableToken extends BlockToken {
type: "table";
headers: string[];
rows: string[][];
alignments: ("left" | "center" | "right" | null)[];
}
interface RawHtmlToken extends BlockToken {
type: "rawHtml";
content: string;
}
type Token =
| HeadingToken
| ParagraphToken
| CodeBlockToken
| BlockquoteToken
| HorizontalRuleToken
| ListItemToken
| TableToken
| RawHtmlToken;
/**
* HTML block-level tag names (from the CommonMark type-6 list, plus `audio`
* which BlockNote serializes as raw HTML since markdown has no shorthand
* for it). When a line starts with `<` followed by one of these tag names,
* the run of non-blank lines is emitted verbatim as raw HTML rather than
* wrapped in a paragraph.
*/
const HTML_BLOCK_TAGS = new Set([
"address",
"article",
"aside",
"audio",
"base",
"basefont",
"blockquote",
"body",
"caption",
"center",
"col",
"colgroup",
"dd",
"details",
"dialog",
"dir",
"div",
"dl",
"dt",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"frame",
"frameset",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hr",
"html",
"iframe",
"legend",
"li",
"link",
"main",
"menu",
"menuitem",
"nav",
"noframes",
"ol",
"optgroup",
"option",
"p",
"param",
"section",
"source",
"summary",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"title",
"tr",
"track",
"ul",
]);
function isHtmlBlockStart(line: string): boolean {
// <!-- ..., <?..., <![CDATA[..., <!DOCTYPE, etc.
if (/^ {0,3}<(!--|\?|![A-Za-z]|!\[CDATA\[)/.test(line)) {
return true;
}
const m = line.match(/^ {0,3}<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s|\/?>|$)/);
if (!m) {
return false;
}
return HTML_BLOCK_TAGS.has(m[1].toLowerCase());
}
// ─── Block-Level Tokenizer ──────────────────────────────────────────────────
function tokenize(markdown: string): Token[] {
const lines = markdown.split("\n");
const tokens: Token[] = [];
let i = 0;
let prevLineWasBlank = true; // treat start of document as after blank
while (i < lines.length) {
const line = lines[i];
// Blank line — skip
if (line.trim() === "") {
prevLineWasBlank = true;
i++;
continue;
}
// Fenced code block (0-3 leading spaces allowed per CommonMark)
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (fenceMatch) {
const fence = fenceMatch[1];
const fenceChar = fence[0];
const fenceLen = fence.length;
const language = fenceMatch[2].trim();
const codeLines: string[] = [];
i++;
while (i < lines.length) {
const closingMatch = lines[i].match(
new RegExp(`^ {0,3}${fenceChar}{${fenceLen},}\\s*$`),
);
if (closingMatch) {
i++;
break;
}
codeLines.push(lines[i]);
i++;
}
tokens.push({
type: "codeBlock",
language: language || "",
code: codeLines.join("\n"),
});
prevLineWasBlank = false;
continue;
}
// ATX Heading.
// - Closing `#` sequence requires a preceding space (so `### foo###`
// keeps the trailing #s as text, while `### foo ###` strips them).
// - Trailing whitespace is always stripped from the heading content.
const headingMatch = line.match(/^(#{1,6})\s+(.+?)(?:\s+#+\s*|\s*)$/);
if (headingMatch) {
tokens.push({
type: "heading",
level: headingMatch[1].length,
content: headingMatch[2],
});
prevLineWasBlank = false;
i++;
continue;
}
// Horizontal rule: ---, ***, ___ (3+ chars, optionally with spaces)
if (/^(\s{0,3})([-*_])\s*(\2\s*){2,}$/.test(line)) {
// Setext H2: --- immediately after a paragraph (no blank line between)
const prevToken = tokens[tokens.length - 1];
if (
!prevLineWasBlank &&
line.trim().match(/^-+$/) &&
prevToken &&
prevToken.type === "paragraph"
) {
const para = prevToken as ParagraphToken;
tokens[tokens.length - 1] = {
type: "heading",
level: 2,
content: para.content,
};
prevLineWasBlank = false;
i++;
continue;
}
tokens.push({ type: "hr" });
prevLineWasBlank = false;
i++;
continue;
}
// Setext heading detection: check if next line is === or ---
if (i + 1 < lines.length) {
const nextLine = lines[i + 1];
if (/^={1,}\s*$/.test(nextLine) && line.trim().length > 0) {
tokens.push({
type: "heading",
level: 1,
content: line.trim(),
});
prevLineWasBlank = false;
i += 2;
continue;
}
// Setext H2 --- handled in HR section above
}
// Table: detect by looking for separator row
const tableResult = tryParseTable(lines, i);
if (tableResult) {
tokens.push(tableResult.token);
i = tableResult.nextLine;
prevLineWasBlank = false;
continue;
}
// Blockquote
if (/^\s{0,3}>/.test(line)) {
const quoteLines: string[] = [];
while (i < lines.length && /^\s{0,3}>/.test(lines[i])) {
// Remove the > prefix
quoteLines.push(lines[i].replace(/^\s{0,3}>\s?/, ""));
i++;
}
// Lazy continuation: collect non-blank lines that don't start a new
// block-level element (per CommonMark spec)
while (i < lines.length) {
const cur = lines[i];
if (cur.trim() === "") {
break;
}
// Stop on block-level markers
if (/^\s{0,3}>/.test(cur)) {
break;
} // new blockquote
if (/^(#{1,6})\s/.test(cur)) {
break;
} // heading
if (/^(`{3,}|~{3,})/.test(cur)) {
break;
} // code fence
if (/^(\s{0,3})([-*_])\s*(\2\s*){2,}$/.test(cur)) {
break;
} // hr
if (/^\s*([-*+]|\d+[.)])\s+/.test(cur)) {
break;
} // list item
if (/^\s*\|(.+\|)+\s*$/.test(cur)) {
break;
} // table
quoteLines.push(cur);
i++;
}
tokens.push({
type: "blockquote",
content: quoteLines.join("\n"),
});
prevLineWasBlank = false;
continue;
}
// List item (bullet, ordered, or task)
const listItemMatch = line.match(
/^(\s*)([-*+]|\d+[.)])(\s+)(\[[ xX]\] )?(.*)$/,
);
if (listItemMatch) {
const indent = listItemMatch[1].length;
const marker = listItemMatch[2];
const markerSpaces = listItemMatch[3];
const checkbox = listItemMatch[4];
const firstLineContent = listItemMatch[5];
let listType: "bullet" | "ordered" | "task";
let start: number | undefined;
let checked: boolean | undefined;
if (checkbox) {
listType = "task";
checked = checkbox.trim() !== "[ ]";
} else if (/^\d+[.)]$/.test(marker)) {
listType = "ordered";
start = parseInt(marker, 10);
} else {
listType = "bullet";
}
// Content indent = column where content actually starts
const contentIndent =
indent +
marker.length +
markerSpaces.length +
(checkbox ? checkbox.length : 0);
// Minimum indent for child content: anything indented past the marker
// (sub-lists can start at indent > marker position)
const minChildIndent = indent + 1;
// Helper to check if a line belongs to this list item
const belongsToItem = (lineStr: string): boolean => {
if (lineStr.trim() === "") {
return true;
} // blank lines checked separately
const lineInd = lineStr.match(/^\s*/)![0].length;
// Lines at contentIndent are continuation text
if (lineInd >= contentIndent) {
return true;
}
// Lines between marker and content column that start a sub-list
if (
lineInd >= minChildIndent &&
lineStr.match(/^\s*([-*+]|\d+[.)])\s+/)
) {
return true;
}
return false;
};
// Consume ALL subsequent lines that belong to this list item
i++;
const subLines: string[] = [];
while (i < lines.length) {
const cur = lines[i];
if (cur.trim() === "") {
// Blank line: include if followed by content that belongs to this item
let lookAhead = i + 1;
while (lookAhead < lines.length && lines[lookAhead].trim() === "") {
lookAhead++;
}
if (lookAhead < lines.length && belongsToItem(lines[lookAhead])) {
subLines.push("");
i++;
continue;
}
break;
}
if (!belongsToItem(cur)) {
break;
}
// Strip indent: for lines at contentIndent+, strip contentIndent chars;
// for sub-list lines between minChildIndent and contentIndent, strip minChildIndent
const lineIndent = cur.match(/^\s*/)![0].length;
if (lineIndent >= contentIndent) {
subLines.push(cur.substring(contentIndent));
} else {
// Sub-list item between minChildIndent and contentIndent
subLines.push(cur.substring(minChildIndent));
}
i++;
}
// Build the list item token
// If there are sub-lines, they become child content (recursively tokenized)
// Don't trim — preserve relative indentation of sub-lines
const childContent = subLines.join("\n").replace(/^\n+|\n+$/g, "");
tokens.push({
type: "listItem",
listType,
indent,
content: firstLineContent.trim(),
start,
checked,
childContent: childContent || undefined,
});
prevLineWasBlank = false;
continue;
}
// Block-level raw HTML: a line starting with `<tag>` (block-level tag),
// `<!-- ... -->`, `<?...?>`, `<!DOCTYPE ...>`, or `<![CDATA[...]]>`.
// Lines are emitted verbatim until the next blank line.
if (isHtmlBlockStart(line)) {
const htmlLines: string[] = [];
while (i < lines.length && lines[i].trim() !== "") {
htmlLines.push(lines[i]);
i++;
}
tokens.push({
type: "rawHtml",
content: htmlLines.join("\n"),
});
prevLineWasBlank = false;
continue;
}
// Paragraph (default)
const paraLines: string[] = [line];
i++;
while (i < lines.length) {
const nextLine = lines[i];
// Stop paragraph on blank line
if (nextLine.trim() === "") {
break;
}
// Stop on block-level element
if (/^(#{1,6})\s/.test(nextLine)) {
break;
}
if (/^(`{3,}|~{3,})/.test(nextLine)) {
break;
}
if (/^\s{0,3}>/.test(nextLine)) {
break;
}
if (/^(\s{0,3})([-*_])\s*(\2\s*){2,}$/.test(nextLine)) {
break;
}
if (/^\s*([-*+]|\d+[.)])\s+/.test(nextLine)) {
break;
}
if (/^\s*\|(.+\|)+\s*$/.test(nextLine)) {
break;
}
if (isHtmlBlockStart(nextLine)) {
break;
}
// Check if next-next line is setext marker
if (
i + 1 < lines.length &&
/^[=-]+\s*$/.test(lines[i + 1]) &&
nextLine.trim().length > 0
) {
break;
}
paraLines.push(nextLine);
i++;
}
// CommonMark allows up to 3 leading spaces of indent on paragraph lines.
// Also strip trailing whitespace from the final line so a trailing
// hard-break sequence (` \n` at end of paragraph) doesn't leak as
// literal trailing spaces in the rendered output.
tokens.push({
type: "paragraph",
content: paraLines
.map((l) => l.replace(/^ {1,3}/, ""))
.join("\n")
.replace(/[ \t]+$/, ""),
});
prevLineWasBlank = false;
}
return tokens;
}
function tryParseTable(
lines: string[],
start: number,
): { token: TableToken; nextLine: number } | null {
// A table needs at least a header row and a separator row
if (start + 1 >= lines.length) {
return null;
}
const headerLine = lines[start];
const separatorLine = lines[start + 1];
// Check separator line format: | --- | --- | or --- | --- (outer pipes optional)
// Must contain at least one pipe and only dashes, colons, pipes, and whitespace
if (
!separatorLine.includes("|") ||
!/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(separatorLine)
) {
return null;
}
// Check header line has at least one pipe (required to distinguish from plain text)
if (!headerLine.includes("|")) {
return null;
}
const headers = parsePipeCells(headerLine);
const alignments = parseAlignments(separatorLine);
const rows: string[][] = [];
let i = start + 2;
while (i < lines.length) {
const line = lines[i];
if (!line.includes("|")) {
break;
}
rows.push(parsePipeCells(line));
i++;
}
return {
token: {
type: "table",
headers,
rows,
alignments,
},
nextLine: i,
};
}
function parsePipeCells(line: string): string[] {
// Trim leading/trailing pipes and split
const trimmed = line.trim();
const withoutOuterPipes = trimmed.startsWith("|")
? trimmed.substring(1)
: trimmed;
const content = withoutOuterPipes.endsWith("|")
? withoutOuterPipes.substring(0, withoutOuterPipes.length - 1)
: withoutOuterPipes;
// Split by pipes, handling escaped pipes
const cells: string[] = [];
let current = "";
for (let i = 0; i < content.length; i++) {
if (
content[i] === "\\" &&
i + 1 < content.length &&
content[i + 1] === "|"
) {
current += "|";
i++;
} else if (content[i] === "|") {
cells.push(current.trim());
current = "";
} else {
current += content[i];
}
}
cells.push(current.trim());
return cells;
}
function parseAlignments(
separatorLine: string,
): ("left" | "center" | "right" | null)[] {
const cells = parsePipeCells(separatorLine);
return cells.map((cell) => {
const trimmed = cell.trim();
const left = trimmed.startsWith(":");
const right = trimmed.endsWith(":");
if (left && right) {
return "center";
}
if (right) {
return "right";
}
if (left) {
return "left";
}
return null;
});
}
// ─── HTML Emitter ────────────────────────────────────────────────────────────
function tokensToHtml(tokens: Token[]): string {
let html = "";
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
switch (token.type) {
case "heading": {
const t = token as HeadingToken;
html += `<h${t.level}>${parseInline(t.content)}</h${t.level}>`;
i++;
break;
}
case "paragraph": {
const t = token as ParagraphToken;
html += `<p>${parseInline(t.content)}</p>`;
i++;
break;
}
case "codeBlock": {
const t = token as CodeBlockToken;
const langAttr = t.language
? ` data-language="${escapeHtml(t.language)}"`
: "";
html += `<pre><code${langAttr}>${escapeHtml(t.code)}</code></pre>`;
i++;
break;
}
case "blockquote": {
const t = token as BlockquoteToken;
// Recursively parse blockquote content as markdown
const innerTokens = tokenize(t.content);
const innerHtml = tokensToHtml(innerTokens);
html += `<blockquote>${innerHtml}</blockquote>`;
i++;
break;
}
case "hr":
html += `<hr>`;
i++;
break;
case "listItem": {
// Collect consecutive list items and build nested list structure
const listHtml = emitListItems(tokens, i);
html += listHtml.html;
i = listHtml.nextIndex;
break;
}
case "table": {
const t = token as TableToken;
html += emitTable(t);
i++;
break;
}
case "rawHtml": {
const t = token as RawHtmlToken;
html += t.content;
i++;
break;
}
default:
i++;
}
}
return html;
}
function emitListItems(
tokens: Token[],
startIdx: number,
): { html: string; nextIndex: number } {
let html = "";
let i = startIdx;
let currentListType: "bullet" | "ordered" | null = null;
while (i < tokens.length && tokens[i].type === "listItem") {
const item = tokens[i] as ListItemToken;
const effectiveType = getEffectiveListType(item.listType);
// Check if we need to switch list type
if (currentListType !== null && currentListType !== effectiveType) {
// Close current list, open new one
html += `</${currentListType === "ordered" ? "ol" : "ul"}>`;
currentListType = null;
}
// Open list if needed
if (currentListType === null) {
if (effectiveType === "ordered") {
const startAttr =
item.start !== undefined && item.start !== 1
? ` start="${item.start}"`
: "";
html += `<ol${startAttr}>`;
} else {
html += `<ul>`;
}
currentListType = effectiveType;
}
// Emit list item
if (item.listType === "task") {
const checkedAttr = item.checked ? " checked" : "";
html += `<li><input type="checkbox" disabled${checkedAttr}><p>${parseInline(item.content)}</p>`;
} else {
html += `<li><p>${parseInline(item.content)}</p>`;
}
// Render child content (nested items, continuation paragraphs, etc.)
if (item.childContent) {
const childTokens = tokenize(item.childContent);
html += tokensToHtml(childTokens);
}
html += `</li>`;
i++;
}
// Close the list
if (currentListType !== null) {
html += `</${currentListType === "ordered" ? "ol" : "ul"}>`;
}
return { html, nextIndex: i };
}
function getEffectiveListType(
listType: "bullet" | "ordered" | "task",
): "bullet" | "ordered" {
return listType === "ordered" ? "ordered" : "bullet";
}
function emitTable(table: TableToken): string {
let html = "<table>";
// BlockNote tables have no required header row, but the markdown table
// syntax does. When we serialize a headerless BlockNote table to markdown
// we emit an empty header row; on re-parse, treat that empty header as
// "no header" so the round-trip is stable (issue #739).
const headerIsEmpty = table.headers.every((h) => h.trim() === "");
const colCount = table.headers.length;
if (!headerIsEmpty) {
html += "<thead><tr>";
for (let c = 0; c < colCount; c++) {
const align = table.alignments[c];
const alignAttr = align ? ` align="${align}"` : "";
html += `<th${alignAttr}>${parseInline(table.headers[c])}</th>`;
}
html += "</tr></thead>";
}
if (table.rows.length > 0) {
html += "<tbody>";
for (const row of table.rows) {
html += "<tr>";
for (let c = 0; c < colCount; c++) {
const cell = c < row.length ? row[c] : "";
const align = table.alignments[c];
const alignAttr = align ? ` align="${align}"` : "";
html += `<td${alignAttr}>${parseInline(cell)}</td>`;
}
html += "</tr>";
}
html += "</tbody>";
}
html += "</table>";
return html;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Convert a markdown string to an HTML string.
* This is a direct replacement for the unified/remark/rehype pipeline.
*/
export function markdownToHtml(markdown: string): string {
const tokens = tokenize(markdown);
return tokensToHtml(tokens);
}