import type { BlokModules } from '../types-internal/blok-modules'; import type { OutputBlockData } from '../../types/data-formats/output-data'; import type { SanitizerConfigBuilder } from '../components/modules/paste/sanitizer-config'; import type { ToolRegistry } from '../components/modules/paste/tool-registry'; import type { HandlerContext } from '../components/modules/paste/types'; import type { PasteHandler } from '../components/modules/paste/handlers/base'; import { BasePasteHandler } from '../components/modules/paste/handlers/base'; import { Block } from '../components/block'; import { normalizeTableChildParents } from '../components/utils/data-model-transform'; /** * Patterns that indicate text is likely Markdown rather than plain text. * Each must be unlikely to appear in normal prose. */ const MARKDOWN_SIGNALS: RegExp[] = [ /^#{1,6}\s/m, // ATX headings: # Heading /^```/m, // Fenced code blocks /\|\s*---/, // GFM table separator: | --- | /^- \[[ x]\]/m, // Task list items: - [ ] or - [x] /^[ \t]*[-*+][ \t]+\S/m, // Unordered list item: - / * / + marker + content /^[ \t]*\d{1,9}[.)][ \t]+\S/m, // Ordered list item: 1. / 1) marker + content /^ {0,3}> \S/m, // Blockquote: `> text` (a space then content, so `->`/`=>`/`>>>` never match) // Link text excludes `[`/`]` and the URL excludes `[` so neither scan can // run past the next candidate start: `/\[.+?\]\(.+?\)/` retried from every // `[` and froze the tab on hostile paste. Costs link text containing // brackets — a signal, not a parser. /\[[^\][\n]+\]\([^)[\n]+\)/, // Markdown links: [text](url) /\*\*.+?\*\*/, // Bold: **text** /!\[/, // Image: ![ /\$\$[\s\S]+?\$\$/, // Block math: $$...$$ /(? pattern.test(text)); } /** * Paste handler that detects and converts Markdown text. * Priority 30: between TextHandler (10) and HtmlHandler (40). * Lazy-loads the converter on first use. * * Uses BlockManager.insertMany() to insert converted blocks directly, * preserving all block data (list depth, table cells, etc.) that * would be lost if mapped through the DOM-based paste pipeline. */ export class MarkdownHandler extends BasePasteHandler implements PasteHandler { constructor( Blok: BlokModules, toolRegistry: ToolRegistry, sanitizerBuilder: SanitizerConfigBuilder ) { super(Blok, toolRegistry, sanitizerBuilder); } canHandle(data: unknown): number { if (typeof data !== 'string' || !data.trim()) { return 0; } return hasMarkdownSignals(data) ? 30 : 0; } async handle(data: unknown, context: HandlerContext): Promise { if (typeof data !== 'string') { return false; } const rawOutputBlocks = await this.convert(data); if (rawOutputBlocks === null || !rawOutputBlocks.length) { return false; } const { BlockManager, Caret } = this.Blok; // Inline markdown fragment pasted mid-text: a single-line input that // converts to exactly one paragraph block is INLINE content (e.g. // `**bold**`, `[text](url)`), not a block. When the caret sits inside a // NON-EMPTY block, Notion merges the converted rich text at the caret // instead of dropping a sibling paragraph below. Block-level markdown // (headings, lists, code, multi-line) keeps converting to blocks. const currentBlock = BlockManager.currentBlock; const isSingleLine = !/\r?\n/.test(data); const convertsToInlineParagraph = rawOutputBlocks.length === 1 && rawOutputBlocks[0].type === 'paragraph'; if ( isSingleLine && convertsToInlineParagraph && currentBlock !== undefined && !currentBlock.isEmpty && currentBlock.currentInput != null ) { const text = (rawOutputBlocks[0].data as { text?: string }).text ?? ''; const content = document.createElement('div'); content.innerHTML = text; const event = this.composePasteEvent('tag', { data: content }); await this.processInlinePaste( { content, tool: rawOutputBlocks[0].type, isBlock: false, event }, false ); return true; } // Defense-in-depth: backfill `parent` on table cell children so that any // future regression in mdast-to-blocks (or external converter) cannot // produce the dodopizza shape (children referenced by table cells but // lacking explicit parent), which would render them at page bottom. const outputBlocks = normalizeTableChildParents(rawOutputBlocks); // Replace empty default block if present const shouldReplace = context.canReplaceCurrentBlock && currentBlock !== undefined && currentBlock.isEmpty; const insertIndex = shouldReplace ? BlockManager.currentBlockIndex : BlockManager.currentBlockIndex + 1; // Container membership: when the caret sits inside a container child (e.g. a // callout/toggle body) the converted top-level blocks must stay inside that // container instead of ejecting to the root. Mirrors BasePasteHandler's // contextParentId capture. The container itself is NOT part of the inserted // set, so we reparent AFTER insertMany via setBlockParent (insertMany would // otherwise clear a parentId that points outside its input). const childContainer = currentBlock?.holder?.querySelector('[data-blok-toggle-children]') ?? null; const isInContainerTitle = childContainer !== null && !childContainer.contains(currentBlock?.currentInput ?? null); const contextParentId = isInContainerTitle ? (currentBlock?.id ?? null) : (currentBlock?.parentId ?? null); // Compose Block instances from OutputBlockData const composed = outputBlocks.map(({ id, type, data: blockData, parent }) => ({ block: BlockManager.composeBlock({ id, tool: type, data: blockData, parentId: parent, // Markdown import materialises a document the caller already wrote — // its containers arrive with their children, so none may self-seed. origin: 'paste', }), hasParent: parent !== undefined && parent !== null, })); const blocksToInsert = composed.map(({ block }) => block); BlockManager.insertMany(blocksToInsert, insertIndex); // Reparent every top-level produced block into the surrounding container so // the paste stays nested (hierarchical children like table cells already // carry their own parent and are left untouched). for (const { block, hasParent } of composed) { if (contextParentId === null || hasParent) { continue; } BlockManager.setBlockParent(block, contextParentId); } // Remove the replaced empty block if (shouldReplace && currentBlock !== undefined) { await BlockManager.removeBlock(currentBlock, false); } // Set caret to end of last inserted block const lastBlock = blocksToInsert[blocksToInsert.length - 1]; if (lastBlock instanceof Block) { Caret.setToBlock(lastBlock, Caret.positions.END); } return true; } /** * Convert, or return null so `handle` DECLINES the paste. A throw here (bad * markdown, failed chunk load) must not escape routeToHandlers — otherwise * the pipeline never reaches TextHandler and the paste silently does * nothing. Only conversion is guarded: a throw after insertMany must not * report false and get re-pasted as plain text on top of the inserted blocks. */ private async convert(data: string): Promise { try { const { markdownToBlocks } = await import('./index'); return await markdownToBlocks(data); } catch (e) { console.warn('MarkdownHandler: markdown conversion failed, falling back to plain text', e); return null; } } }