import { calloutKind, TOLARIA_CALLOUT_BLOCK_TYPE } from './calloutMarkdown' interface TextStyles { [style: string]: string | boolean | undefined } interface InlineItem { type?: string text?: string href?: string props?: Record styles?: TextStyles content?: InlineItem[] [key: string]: unknown } interface TableCellLike { content?: InlineItem[] [key: string]: unknown } type TableCellValue = string | TableCellLike interface TableRowLike { cells?: TableCellValue[] [key: string]: unknown } interface TableContentLike { type?: string rows?: TableRowLike[] [key: string]: unknown } interface BlockLike { type?: string content?: InlineItem[] | TableContentLike | unknown props?: Record children?: BlockLike[] [key: string]: unknown } export interface BlockNoteDirectMarkdownMetrics { blockCount: number cacheHits: number cacheMisses: number durationMs: number fallbackReason: string | null } export interface BlockNoteDirectMarkdownResult { markdown: string metrics: BlockNoteDirectMarkdownMetrics supported: boolean } export interface DirectMarkdownCapableSerializer { blocksToMarkdownLossy: (blocks: unknown[]) => string blocksToMarkdownDirect?: (blocks: unknown[]) => BlockNoteDirectMarkdownResult __tolariaDirectMarkdownCache?: WeakMap> __tolariaLastDirectMarkdownMetrics?: BlockNoteDirectMarkdownMetrics } type MarkdownLinePrefix = { marker: string indent: string } interface SerializeContext { cache?: WeakMap> cacheHits: number cacheMisses: number fallbackReason: string | null numberedStack: number[] } const DIRECT_MARKDOWN_METHOD = 'blocksToMarkdownDirect' const ESCAPE_TEXT_RE = /([\\`*_{}<>()#!])/g const ESCAPE_TABLE_CELL_RE = /[|\n\r]/g const TEXT_CONTENT_BLOCK_TYPES = new Set([ 'bulletListItem', 'checkListItem', 'numberedListItem', 'paragraph', ]) const MEDIA_BLOCK_TYPES = new Set(['audio', 'file', 'image', 'video']) type BlockMarkdownHandler = (block: BlockLike, context: SerializeContext) => string | null function now(): number { return globalThis.performance?.now?.() ?? Date.now() } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } function blockObject(value: unknown): BlockLike | null { return isRecord(value) ? value as BlockLike : null } function contentArray(content: unknown): InlineItem[] { return Array.isArray(content) ? content as InlineItem[] : [] } function tableContent(content: unknown): TableContentLike | null { return isRecord(content) && content.type === 'tableContent' && Array.isArray(content.rows) ? content as TableContentLike : null } function escapeText(text: string): string { return text.replace(ESCAPE_TEXT_RE, '\\$1') } function escapeLinkTarget(target: string): string { return target.includes(' ') || target.includes(')') ? `<${target.replace(/>/g, '%3E')}>` : target } function inlineText(item: InlineItem): string { if (item.type === 'text') return item.text ?? '' if (item.type === 'link') return linkMarkdown(item) if (item.type === 'wikilink') return wikilinkMarkdown(item) if (item.type === 'mathInline') return item.props?.latex ? `$${item.props.latex}$` : '' if (Array.isArray(item.content)) return serializeInlineContent(item.content) return '' } function linkMarkdown(item: InlineItem): string { const content = Array.isArray(item.content) ? serializeInlineContent(item.content) : escapeText(item.text ?? item.props?.title ?? item.props?.href ?? '') const href = item.props?.href ?? item.href return href ? `[${content}](${escapeLinkTarget(href)})` : content } function wikilinkMarkdown(item: InlineItem): string { const target = item.props?.target return target ? `[[${target}]]` : '' } function wrapInlineMarkdown(text: string, marker: string): string { if (!text) return text return `${marker}${text}${marker}` } function styledTextMarkdown(item: InlineItem): string { let text = escapeText(item.text ?? '') const styles = item.styles ?? {} if (styles.code === true) return codeSpan(item.text ?? '') if (styles.bold === true) text = wrapInlineMarkdown(text, '**') if (styles.italic === true) text = wrapInlineMarkdown(text, '*') if (styles.strike === true) text = wrapInlineMarkdown(text, '~~') return text } function codeSpan(text: string): string { const marker = text.includes('`') ? '``' : '`' const needsPadding = marker === '``' && (text.startsWith('`') || text.endsWith('`')) return needsPadding ? `${marker} ${text} ${marker}` : `${marker}${text}${marker}` } function serializeInlineItem(item: InlineItem): string { return item.type === 'text' ? styledTextMarkdown(item) : inlineText(item) } export function serializeInlineContent(content: InlineItem[] | undefined): string { return content?.map(serializeInlineItem).join('') ?? '' } function blockPrefix(block: BlockLike, depth: number, context: SerializeContext): MarkdownLinePrefix | null { const indent = ' '.repeat(depth) if (block.type === 'numberedListItem') { const next = context.numberedStack[depth] ?? Number(block.props?.start ?? 1) context.numberedStack[depth] = next + 1 return { indent, marker: `${next}. ` } } context.numberedStack[depth] = 1 if (block.type === 'bulletListItem') return { indent, marker: '- ' } if (block.type === 'checkListItem') return { indent, marker: block.props?.checked === true ? '- [x] ' : '- [ ] ' } return null } function advanceCachedBlockContext(block: BlockLike, depth: number, context: SerializeContext): void { if (block.type === 'numberedListItem') { const next = context.numberedStack[depth] ?? Number(block.props?.start ?? 1) context.numberedStack[depth] = next + 1 } else { context.numberedStack[depth] = 1 } context.numberedStack.length = depth + 1 } function prependLinePrefix(markdown: string, prefix: MarkdownLinePrefix): string { const lines = markdown.split('\n') return lines.map((line, index) => ( index === 0 ? `${prefix.indent}${prefix.marker}${line}` : `${prefix.indent} ${line}` )).join('\n') } function codeBlockMarkdown(block: BlockLike): string { const language = typeof block.props?.language === 'string' ? block.props.language : '' const code = serializeInlineContent(contentArray(block.content)).replace(/\n$/u, '') const fence = code.includes('```') ? '~~~' : '```' return `${fence}${language === 'text' ? '' : language}\n${code}\n${fence}` } function mediaLabel(name: string, url: string): string { return name || url.split('/').pop() || url } function mediaUrl(block: BlockLike): string { return typeof block.props?.url === 'string' ? block.props.url : '' } function mediaMarkdown(block: BlockLike): string { const url = mediaUrl(block) const name = typeof block.props?.name === 'string' ? block.props.name : '' if (!url) return name const label = mediaLabel(name, url) return block.type === 'image' ? `![${escapeText(label)}](${escapeLinkTarget(url)})` : `[${escapeText(label)}](${escapeLinkTarget(url)})` } function quoteMarkdown(block: BlockLike): string { const text = serializeInlineContent(contentArray(block.content)) return text.split('\n').map(line => `> ${line}`).join('\n') } function calloutMarkdown(block: BlockLike): string { const kind = calloutKind(block.props?.kind) const text = serializeInlineContent(contentArray(block.content)) || 'Write a note' return `[!${kind}] ${text}` .split('\n') .map(line => `> ${line}`) .join('\n') } function tableCellMarkdown(cell: TableCellValue): string { const text = typeof cell === 'string' ? cell : serializeInlineContent(contentArray(cell.content)) return text.replace(ESCAPE_TABLE_CELL_RE, character => character === '|' ? '\\|' : ' ') } function tableMarkdown(block: BlockLike): string | null { const content = tableContent(block.content) const rows = content?.rows ?? [] if (rows.length === 0) return '' const cellRows = rows.map(row => row.cells?.map(tableCellMarkdown) ?? []) const width = Math.max(...cellRows.map(row => row.length), 0) if (width === 0) return '' const normalizedRows = cellRows.map(row => Array.from({ length: width }, (_, index) => row[index] ?? '')) const [head, ...body] = normalizedRows return [ `| ${head.join(' | ')} |`, `| ${Array.from({ length: width }, () => '---').join(' | ')} |`, ...body.map(row => `| ${row.join(' | ')} |`), ].join('\n') } function inlineBlockMarkdown(block: BlockLike): string { return serializeInlineContent(contentArray(block.content)) } function headingMarkdown(block: BlockLike): string { const level = Math.max(1, Math.min(6, Number(block.props?.level ?? 1))) return `${'#'.repeat(level)} ${serializeInlineContent(contentArray(block.content))}`.trimEnd() } function unsupportedBlockMarkdown(block: BlockLike, context: SerializeContext): null { context.fallbackReason = typeof block.type === 'string' ? `unsupported:${block.type}` : 'unsupported:unknown' return null } const BLOCK_MARKDOWN_HANDLERS: Record = { codeBlock: codeBlockMarkdown, divider: () => '---', heading: headingMarkdown, quote: quoteMarkdown, table: tableMarkdown, [TOLARIA_CALLOUT_BLOCK_TYPE]: calloutMarkdown, } function blockMarkdownWithoutChildren(block: BlockLike, context: SerializeContext): string | null { if (typeof block.type !== 'string') return unsupportedBlockMarkdown(block, context) if (TEXT_CONTENT_BLOCK_TYPES.has(block.type)) return inlineBlockMarkdown(block) if (MEDIA_BLOCK_TYPES.has(block.type)) return mediaMarkdown(block) return BLOCK_MARKDOWN_HANDLERS[block.type]?.(block, context) ?? unsupportedBlockMarkdown(block, context) } function serializeChildren(block: BlockLike, depth: number, context: SerializeContext): string { const children = Array.isArray(block.children) ? block.children : [] if (children.length === 0) return '' const childDepth = depth + 1 context.numberedStack[childDepth] = 1 const markdown = serializeBlockList(children, childDepth, context) context.numberedStack.length = childDepth return markdown } function blockCacheKey(block: BlockLike, depth: number, context: SerializeContext): string { return [ depth, block.type ?? '', context.numberedStack[depth] ?? '', ].join(':') } function cachedBlockMarkdown(block: BlockLike, cacheKey: string, context: SerializeContext): string | null { const cached = context.cache?.get(block as object)?.get(cacheKey) if (cached === undefined) return null context.cacheHits++ return cached } function storeCachedBlockMarkdown( block: BlockLike, cacheKey: string, markdown: string, context: SerializeContext, ): void { if (!context.cache) return const existing = context.cache.get(block as object) if (existing) { existing.set(cacheKey, markdown) return } context.cache.set(block as object, new Map([[cacheKey, markdown]])) } function renderUncachedBlock(block: BlockLike, depth: number, context: SerializeContext): string | null { context.cacheMisses++ const ownMarkdown = blockMarkdownWithoutChildren(block, context) if (ownMarkdown === null) return null const prefix = blockPrefix(block, depth, context) const ownWithPrefix = prefix ? prependLinePrefix(ownMarkdown, prefix) : ownMarkdown const childMarkdown = serializeChildren(block, depth, context) return childMarkdown ? `${ownWithPrefix}\n${childMarkdown}` : ownWithPrefix } function serializeBlock(block: BlockLike, depth: number, context: SerializeContext): string | null { const cacheKey = blockCacheKey(block, depth, context) const cached = cachedBlockMarkdown(block, cacheKey, context) if (cached !== null) { advanceCachedBlockContext(block, depth, context) return cached } const markdown = renderUncachedBlock(block, depth, context) if (markdown === null) return null storeCachedBlockMarkdown(block, cacheKey, markdown, context) return markdown } function serializeBlockList(blocks: BlockLike[], depth: number, context: SerializeContext): string { const chunks: string[] = [] for (const value of blocks) { const block = blockObject(value) if (!block) { context.fallbackReason = 'unsupported:non-object-block' return '' } const markdown = serializeBlock(block, depth, context) if (markdown === null) return '' if (markdown) chunks.push(markdown) } return chunks.join('\n\n') } export function blocksToMarkdownDirect( blocks: unknown[], cache?: WeakMap>, ): BlockNoteDirectMarkdownResult { const startedAt = now() const context: SerializeContext = { cache, cacheHits: 0, cacheMisses: 0, fallbackReason: null, numberedStack: [], } const markdown = serializeBlockList(blocks as BlockLike[], 0, context) const durationMs = now() - startedAt return { markdown, supported: context.fallbackReason === null, metrics: { blockCount: blocks.length, cacheHits: context.cacheHits, cacheMisses: context.cacheMisses, durationMs, fallbackReason: context.fallbackReason, }, } } export function installBlockNoteDirectMarkdown(editor: DirectMarkdownCapableSerializer): void { if (typeof editor.blocksToMarkdownDirect === 'function') return const cache = new WeakMap>() editor.__tolariaDirectMarkdownCache = cache editor.blocksToMarkdownDirect = (blocks: unknown[]) => { const result = blocksToMarkdownDirect(blocks, cache) editor.__tolariaLastDirectMarkdownMetrics = result.metrics return result } } export function serializeBlockNoteMarkdown( editor: DirectMarkdownCapableSerializer, blocks: unknown[], ): string { const direct = editor[DIRECT_MARKDOWN_METHOD]?.(blocks) if (direct?.supported) return direct.markdown return editor.blocksToMarkdownLossy(blocks) }