UNPKG

@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
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, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;"); } // ─── 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 { // ![alt](url) or ![alt](url "title") // 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 `![name](url)` 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); }