/** * Block-level Markdown serialization, shared by both inline backends. * * A block's inline content is an HTML fragment, and the two callers read it in * different ways: the editor walks a live DOM (`src/markdown/blocks-to-markdown.ts`), * while the view renderer must stay DOM-free and walks parse5 * (`src/view/blocks-to-markdown.ts`). Everything ABOVE that line — which tool * becomes which Markdown construct, how containers own their children, how a * table becomes a pipe grid — is identical, and lives here so the two cannot * drift. Parity is pinned by test/unit/markdown/blocks-to-markdown.parity.test.ts. * * PURITY CONTRACT: no DOM, no parse5, no editor imports. Only the injected * `InlineBackend` touches HTML. */ import type { BlockToolData } from '../../types'; export interface SerializableBlock { /** * Block id. Required for tools whose data references OTHER blocks by id — * `table`, whose cells hold their content as child block ids — and for every * container that renders its own children. */ id?: string; /** Id of the structural parent, used to resolve a block's descendants. */ parentId?: string | null; tool: string; data: BlockToolData; /** * Structural nesting depth (the parentId chain length), applied as leading * indentation so a Tab/drag-nested block serializes nested instead of flat — * matching Notion's Markdown export. Always honoured for `list` (list nesting * is structural now, with a fallback to the legacy flat `data.depth` for * imported lists that have no structural parent yet); for every other tool * only inside a list item, where four spaces continue the item instead of * opening an indented code block. */ indent?: number; /** * Ids this block names in its `content[]` that the document does not carry. * Resolution belongs to whoever built the document (the view's document * model); the core only reports what it is handed. */ unresolvedChildIds?: string[]; } /** Reads a block's inline HTML. The only part of serialization that needs a parser. */ export interface InlineBackend { /** * Inline HTML → inline Markdown (marks, links, `
`). * @param html - the block field's inline HTML * @param onLoss - called with the construct name of every mark the walk had * to unwrap because Markdown cannot express it (see {@link inlineLosses}). */ inlineToMarkdown(html: string, onLoss?: (construct: string) => void): string; } /** Inline marks whose tag alone names the loss. */ const NAMED_INLINE_LOSSES: Record = { u: 'underline', sup: 'superscript', sub: 'subscript', }; /** What each inline loss costs, in the report's plain language. */ const INLINE_LOSS_DETAILS: Record = { 'text-color': 'inline text colour has no Markdown equivalent; the text is kept, its colour is lost', highlight: 'inline highlighting has no Markdown equivalent; the text is kept, its highlight is lost', underline: 'inline underline has no Markdown equivalent; the text is kept as ordinary text', superscript: 'superscript has no Markdown equivalent; the text is kept as ordinary text', subscript: 'subscript has no Markdown equivalent; the text is kept as ordinary text', }; /** * Inline constructs Markdown cannot express, named by the element that carries * them. A backend reports these from the branch that unwraps an unknown tag — * the text survives, the mark does not. * @param tagName - lowercase tag name of the element being unwrapped * @param style - its `style` attribute, if any * @returns construct names to report, empty when the element loses nothing */ export const inlineLosses = (tagName: string, style: string | null): string[] => { const named = NAMED_INLINE_LOSSES[tagName]; if (named !== undefined) { return [named]; } /** * The marker tool writes both colour modes onto ONE ``, so an element * can carry two losses at once. A bare `` is the tool's plain * highlight; a bare `` decorates nothing and loses nothing. */ if (tagName !== 'mark' && tagName !== 'span') { return []; } const declarations = style ?? ''; const losses = [ /(^|;)\s*color\s*:/i.test(declarations) ? 'text-color' : '', /background-color\s*:/i.test(declarations) ? 'highlight' : '', ].filter((loss) => loss !== ''); return losses.length === 0 && tagName === 'mark' ? ['highlight'] : losses; }; /** A construct that could not be carried into Markdown as-is. */ export interface MarkdownDegradation { /** * What degraded. A block tool name (`callout`) on the way out; a Markdown * construct (`html`) on the way in. */ construct: string; /** `dropped` — nothing was emitted; `degraded` — emitted, but lossy. */ action: 'dropped' | 'degraded'; /** Plain-language explanation of what was lost. */ detail: string; } /** Number of spaces used per nesting level. */ const LIST_INDENT = ' '; /** * The code tool's "no language" id — `DEFAULT_LANGUAGE` in * src/tools/code/constants.ts, and the fallback `mdast-to-blocks.ts` writes for * an info-less fence. Inlined rather than imported: the core stays pure. */ const PLAIN_TEXT_LANGUAGE = 'plain text'; /** * Tools that render their own descendants and therefore CLAIM them: a claimed * block is never also emitted as a loose top-level line. Table cells hold their * content as child block ids; the rest are structural containers. */ const CONTAINER_TOOLS = new Set([ 'table', 'callout', 'toggle', /** Legacy alias of `toggle`; `column_list`'s is `columns`. Both still arrive from imports. */ 'toggleList', 'column_list', 'columns', 'column', ]); /** * Coerce an unknown value to a string, treating non-strings as empty. Block data * values are typed loosely, so this guards against stringifying objects. * @param value - value to coerce */ const asString = (value: unknown): string => (typeof value === 'string' ? value : ''); /** * Narrow an unknown value to a plain record. * @param value - value to check */ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; /** Lookup structures shared by every block in one serialization run. */ interface SerializationContext { /** Every block of the run, keyed by id. */ byId: Map; /** Structural children, keyed by parent id. */ childrenOf: Map; /** Reads inline HTML. */ inline: InlineBackend; /** Collects degradations; discarded when the caller asked for no report. */ warnings: MarkdownDegradation[]; /** Ids already on the render stack — breaks parent-reference cycles. */ active: Set; /** Inline losses already reported, so one kind is named once per document. */ inlineSeen: Set; } /** * Record a degradation. * @param context - the serialization context * @param construct - tool name that degraded * @param action - whether anything was emitted * @param detail - plain-language explanation */ const warn = ( context: SerializationContext, construct: string, action: MarkdownDegradation['action'], detail: string ): void => { context.warnings.push({ construct, action, detail }); }; /** * Convert one inline field, reporting the marks Markdown cannot carry. * * A kind is reported ONCE per document: the report carries no block location, * so a second identical line says nothing the first did not — and a document * that colours half its words would otherwise bury every other warning. * @param context - the serialization context * @param html - the field's inline HTML */ const inlineMarkdown = (context: SerializationContext, html: string): string => context.inline.inlineToMarkdown(html, (construct: string): void => { if (context.inlineSeen.has(construct)) { return; } context.inlineSeen.add(construct); warn(context, construct, 'degraded', INLINE_LOSS_DETAILS[construct] ?? `${construct} has no Markdown equivalent`); }); /** * Join loss names into a readable list: `a`, `a and b`, `a, b and c`. * @param items - loss names in report order */ const joinLosses = (items: string[]): string => items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`; /** * Report the presentation fields a block carries that Markdown cannot. * * One line per block, not per field: they degrade together (the block is * rendered as its plain Markdown construct) and a consumer acts on them the * same way. Only NON-DEFAULT values reach here — a field at its default loses * nothing, and warning about it would fire on nearly every document. * @param context - the serialization context * @param block - the block being serialized * @param rendering - what the block is rendered as * @param losses - the fields lost, in report order */ const warnPresentationLosses = ( context: SerializationContext, block: SerializableBlock, rendering: string, losses: string[] ): void => { if (losses.length === 0) { return; } warn( context, block.tool, 'degraded', `${block.tool} is rendered as ${rendering}; its ${joinLosses(losses)} ${losses.length === 1 ? 'is' : 'are'} lost` ); }; /** * Report children a container named but the document does not carry. * * A reference that resolves to nothing loses its content with nothing to show * for it. Staying silent here is what let a truncated article keep reporting * fidelity full. * @param context - the serialization context * @param block - the container that named them * @param count - how many references could not be resolved */ const warnUnresolvedChildren = (context: SerializationContext, block: SerializableBlock, count: number): void => { if (count === 0) { return; } const one = count === 1; warn( context, block.tool, 'dropped', `${count} child block reference${one ? '' : 's'} could not be resolved and ${one ? 'was' : 'were'} dropped` ); }; /** * Read a table block's cell grid, tolerating the legacy string-cell format. * @param data - the table block's data */ const readTableGrid = (data: BlockToolData): Array>> => { const { content } = data; if (!Array.isArray(content)) { return []; } return content.map((row) => { if (!Array.isArray(row)) { return []; } return row.map((cell: unknown) => (isRecord(cell) ? cell : { text: asString(cell) })); }); }; /** * Whether a cell spans more than the one grid position a pipe table gives it. * @param cell - one cell of the grid */ const isMerged = (cell: Record): boolean => (typeof cell.colspan === 'number' && cell.colspan > 1) || (typeof cell.rowspan === 'number' && cell.rowspan > 1); /** * The table fields a GFM pipe table cannot carry, in report order. * * Column widths and text size are left out on purpose: the tool writes * `colWidths` on every table it creates, so reporting it would warn on every * document that holds a table at all. * @param data - the table block's data * @param grid - the cell grid, already normalized */ const tablePresentationLosses = ( data: BlockToolData, grid: Array>> ): string[] => { const cells = grid.flat(); const coloured = cells.some((cell) => asString(cell.color) !== '' || asString(cell.textColor) !== ''); const placed = cells.some((cell) => typeof cell.placement === 'string' && cell.placement !== 'top-left'); return [ cells.some(isMerged) ? 'merged cells' : '', data.withHeadingColumn === true ? 'heading column' : '', coloured ? 'cell colours' : '', placed ? 'cell placement' : '', data.stretched === true ? 'full-width layout' : '', ].filter((loss) => loss !== ''); }; /** * Escape a cell's Markdown so it cannot break the pipe-table grid: `|` is * escaped and hard line breaks become `
` (GFM cells are single-line). * @param markdown - the cell's Markdown */ const escapeTableCell = (markdown: string): string => { const segments = markdown.split('|'); /** * A `\` run before a `|` must be doubled before the escaping `\` is added, * otherwise `a\|b` exports as `a\\|b` — a literal backslash plus a LIVE * delimiter — and re-importing splits the cell in two. */ return segments .map((segment, index) => (index === segments.length - 1 ? segment : doubleTrailingBackslashes(segment))) .join('\\|') .replace(/\n/g, '
'); }; /** * Double the trailing `\` run of a cell segment. * @param segment - cell Markdown between two `|` characters * @returns the segment with its trailing backslash run doubled */ const doubleTrailingBackslashes = (segment: string): string => { const run = Array.from(segment).reduce((count, character) => (character === '\\' ? count + 1 : 0), 0); return segment + '\\'.repeat(run); }; /** * Serialize one cell child block plus its structural descendants. * @param block - the cell's child block * @param context - the serialization context * @param depth - nesting depth relative to the cell */ const cellBlockLines = (block: SerializableBlock, context: SerializationContext, depth: number): string[] => { const lines = [blockToMarkdown({ ...block, indent: depth }, context)]; for (const child of context.childrenOf.get(block.id ?? '') ?? []) { lines.push(...cellBlockLines(child, context, depth + 1)); } return lines; }; /** * Serialize a table block as a GFM pipe table. * * Documented degradations (GFM pipe tables cannot express these): * - **Merged cells**: `colspan`/`rowspan` are dropped. The origin cell keeps its * content in place and the cells it covered serialize as empty, so the grid * stays rectangular. * - **Heading column** (`withHeadingColumn`): serialized as a plain column. * - **No heading row** (`withHeadings: false`): GFM requires a header, so an * EMPTY header row is emitted and every data row stays a data row (rather than * promoting the first row to a heading and lying about the data). * - **Multi-block cells**: joined with `
`, since a pipe-table cell is inline-only. * @param block - the table block * @param context - the serialization context (resolves cell child blocks by id) */ const tableToMarkdown = (block: SerializableBlock, context: SerializationContext): string => { const grid = readTableGrid(block.data); if (grid.length === 0) { return ''; } const columns = grid.reduce((max, row) => Math.max(max, row.length), 0); const unresolved: string[] = []; const rows = grid.map((row) => Array.from({ length: columns }, (_unused, index) => { const cell = row[index]; if (cell === undefined) { return ''; } const ids = Array.isArray(cell.blocks) ? cell.blocks.filter((id: unknown): id is string => typeof id === 'string') : []; const lines = ids.flatMap((id) => { const cellBlock = context.byId.get(id); if (cellBlock === undefined) { unresolved.push(id); return []; } return cellBlockLines(cellBlock, context, 0); }); const markdown = lines.length > 0 ? lines.join('\n') : inlineMarkdown(context, asString(cell.text)); return escapeTableCell(markdown).trim(); }) ); /** A cell pointing at a block that is not in the document loses its content. */ warnUnresolvedChildren(context, block, unresolved.length); warnPresentationLosses(context, block, 'a GFM pipe table', tablePresentationLosses(block.data, grid)); const withHeadings = block.data.withHeadings === true; const header = withHeadings ? rows[0] : Array.from({ length: columns }, () => ''); const body = withHeadings ? rows.slice(1) : rows; const delimiter = Array.from({ length: columns }, () => '---'); return [header, delimiter, ...body].map((row) => `| ${row.join(' | ')} |`).join('\n'); }; /** * Serialize an ordered run of blocks: empty results contribute nothing (so a * block with no Markdown representation leaves no stray blank line), and two * consecutive list items are joined tightly. * @param blocks - blocks to serialize, in document order * @param context - the serialization context */ const sequenceToMarkdown = (blocks: SerializableBlock[], context: SerializationContext): string => { const segments: Array<{ text: string; isList: boolean }> = []; for (const block of blocks) { const markdown = blockToMarkdown(block, context); if (markdown !== '') { segments.push({ text: markdown, isList: block.tool === 'list' }); } } /** * Collected then joined once. Concatenating onto an accumulator instead * re-allocates the whole document per block, which is quadratic: a long * article allocates tens of megabytes and trips the server runtime's * per-conversion memory limit outright. */ const parts: string[] = []; segments.forEach((segment, index) => { if (index > 0) { parts.push(segment.isList && segments[index - 1].isList ? '\n' : '\n\n'); } parts.push(segment.text); }); return parts.join(''); }; /** * Every descendant a container has to render itself, in document order. * * `collectOwnedIds` claims descendants transitively, so a block deeper than one * level is never emitted at top level — rendering only the direct children * dropped it with nothing to show for it. A nested container is included but * not descended into: it renders its own subtree, and walking past it would * emit those blocks twice. * @param block - the container block * @param context - the serialization context */ const ownedSubtree = (block: SerializableBlock, context: SerializationContext): SerializableBlock[] => { const collected: SerializableBlock[] = []; const seen = new Set(); /** * Append one parent's children, then their own, depth first. * @param parentId - id whose children to append */ const walk = (parentId: string): void => { if (seen.has(parentId)) { return; } seen.add(parentId); for (const child of context.childrenOf.get(parentId) ?? []) { collected.push(child); if (child.id !== undefined && !CONTAINER_TOOLS.has(child.tool)) { walk(child.id); } } }; walk(block.id ?? ''); return collected; }; /** * The id of the leading paragraph child a legacy container's `title` was * expanded into, so the container can render that title itself instead of * letting it through as an ordinary body paragraph. * @param block - the legacy container block * @param context - the serialization context * @param title - the container's `data.title` ('' when it has none) */ const legacyTitleChildId = ( block: SerializableBlock, context: SerializationContext, title: string ): string | undefined => { if (title === '') { return undefined; } const first = (context.childrenOf.get(block.id ?? '') ?? [])[0]; return first !== undefined && first.tool === 'paragraph' && asString(first.data.text) === title ? first.id : undefined; }; /** * Serialize a container's structural children as a Markdown run. * * Children are re-based to indent 0 relative to their container: a container * expresses its own nesting through its Markdown construct (a blockquote, a * bold summary), never through leading spaces, so carrying the absolute depth * inward would indent the body into a code block. * @param block - the container block * @param context - the serialization context * @param omitId - a child the container renders itself and must not repeat */ const childrenToMarkdown = ( block: SerializableBlock, context: SerializationContext, omitId?: string ): string => { const children = ownedSubtree(block, context).filter((child) => child.id !== omitId); /** * Re-base against the container's ORIGINAL indent, not the copy's: a nested * container (a column inside a column list) is itself rendered from a copy * whose indent was already zeroed, while its children still carry their * absolute depth. Subtracting the zeroed value would leave them indented. */ const original = (block.id === undefined ? undefined : context.byId.get(block.id)) ?? block; const base = Math.max(Number(original.indent ?? 0), 0) + 1; return sequenceToMarkdown( children.map((child) => ({ ...child, indent: Math.max(Math.max(Number(child.indent ?? 0), 0) - base, 0) })), context ); }; /** * Whether a block sits inside a list item. Four leading spaces continue a list * item, but outside one they are an indented code block — so a non-list block * only carries a flat indent when a list actually owns it. * @param block - the block being serialized * @param context - the serialization context * @param seen - parent ids already walked (cycle guard) */ const isUnderList = ( block: SerializableBlock, context: SerializationContext, seen: Set = new Set() ): boolean => { const { parentId } = block; if (typeof parentId !== 'string' || seen.has(parentId)) { return false; } seen.add(parentId); const parent = context.byId.get(parentId); if (parent === undefined) { return false; } return parent.tool === 'list' || isUnderList(parent, context, seen); }; /** * Prefix every line of a block of text, including empty ones — the shape a * Markdown blockquote requires to stay one quote rather than several. * @param text - text to prefix * @param prefix - the line prefix */ const prefixLines = (text: string, prefix: string): string => text.split('\n').map((line) => prefix + line).join('\n'); /** * The number an ordered item's marker carries. * * The list tool stores `data.start` on the FIRST item of a group and omits it * when it is 1 (`data-normalizer.ts`), never copying it onto the items that * follow — which matches Markdown, where an ordered list takes its numbering * from the first item alone, so `5.` then `1.` then `1.` renders 5, 6, 7. * CommonMark's marker is 0-999999999: anything outside that (or not a whole * number) is not a marker at all and would turn the item into a paragraph. * @param data - the list block's data * @returns the number to print before the dot */ const orderedMarkerNumber = (data: BlockToolData): number => { const { start } = data; return typeof start === 'number' && Number.isInteger(start) && start >= 0 && start <= 999999999 ? start : 1; }; /** Character references a legacy text field can carry, and what they stand for. */ const NAMED_CHARACTER_REFERENCES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', }; /** * Resolve HTML character references, leaving every tag as literal text. * * Only a legacy code block whose content sits in `data.text` reaches this — * `data.text` is the inline-HTML field everywhere else, so its `<` and `&` * arrive escaped (`escapeHtml` in mdast-to-blocks.ts is the exact inverse). * Tags are never parsed: that is what turned a code sample into a script sink. * One pass, so `&lt;` — an ESCAPED `<` — decodes once, not twice. A * reference without its `;` is left alone rather than guessed at. * @param text - possibly escaped text * @returns the text with its character references resolved */ const decodeCharacterReferences = (text: string): string => text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]*);/gi, (reference: string, body: string): string => { if (!body.startsWith('#')) { return NAMED_CHARACTER_REFERENCES[body.toLowerCase()] ?? reference; } const hex = body[1] === 'x' || body[1] === 'X'; const codePoint = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10); /** A surrogate half or an out-of-range value is not a character. */ const isCharacter = codePoint > 0 && codePoint <= 0x10ffff && (codePoint < 0xd800 || codePoint > 0xdfff); return isCharacter ? String.fromCodePoint(codePoint) : reference; }); /** * The image fields a Markdown image cannot carry, in report order. * * Defaults come from `types/tools/image.d.ts` (width 100, alignment centre, * frame 'none', rounded true) and count as "not set": an image that never left * them loses nothing, and reporting it would warn on almost every document. * `fileName` and the cached natural dimensions are metadata, not presentation, * so they are not losses. * @param data - the image block's data */ const imagePresentationLosses = (data: BlockToolData): string[] => { const width = typeof data.width === 'number' && data.width !== 100; const alignment = typeof data.alignment === 'string' && data.alignment !== 'center'; const frame = typeof data.frame === 'string' && data.frame !== 'none'; return [ /** The export shows the UNCROPPED image, so a crop is lost content, not chrome. */ isRecord(data.crop) ? 'crop' : '', width ? 'width' : '', alignment ? 'alignment' : '', typeof data.size === 'string' && data.size !== '' ? 'size preset' : '', frame ? 'frame' : '', data.rounded === false ? 'square corners' : '', ].filter((loss) => loss !== ''); }; /** * Serialize a single block to a Markdown line (or fenced/quoted block). * @param block - the block to serialize * @param context - the serialization context */ const blockToMarkdown = (block: SerializableBlock, context: SerializationContext): string => { /** Cycle guard: a parent-reference loop must not recurse forever. */ if (block.id !== undefined && context.active.has(block.id)) { return ''; } if (block.id !== undefined) { context.active.add(block.id); } /** * `content[]` is the canonical containment form, so a block-level reference * that resolves to nothing costs exactly what an unresolved table cell * reference costs — and said nothing about it. */ warnUnresolvedChildren(context, block, block.unresolvedChildIds?.length ?? 0); try { return blockMarkdownBody(block, context); } finally { if (block.id !== undefined) { context.active.delete(block.id); } } }; /** * The Markdown of one block, with the cycle guard already applied by * {@link blockToMarkdown}. Kept between `blockToMarkdown` and `buildContext` * because the Markdown serialization law scans that region for `case` labels. * @param block - the block to serialize * @param context - the serialization context */ const blockMarkdownBody = (block: SerializableBlock, context: SerializationContext): string => { const { data } = block; const text = inlineMarkdown(context, asString(data.text)); switch (block.tool) { // A pipe table must start at column 0 — a flat indent of 4 spaces would turn // it into an indented code block — so it is handled before `flatIndent`. case 'table': return tableToMarkdown(block, context); case 'list': { // List nesting is structural (parentId chain), carried in `indent` — // consistent with how Tab-nested text/headers serialize. Fall back to the // legacy flat `data.depth` for imported lists that have no structural parent // yet, so their indentation survives a copy-as-markdown. const structuralDepth = Math.max(Number(block.indent ?? 0), 0); const flatDepth = Math.max(Number(data.depth ?? 0), 0); const indent = LIST_INDENT.repeat(structuralDepth > 0 ? structuralDepth : flatDepth); if (data.style === 'ordered') { return `${indent}${orderedMarkerNumber(data)}. ${text}`; } if (data.style === 'checklist') { return `${indent}- [${data.checked ? 'x' : ' '}] ${text}`; } return `${indent}- ${text}`; } /** * A callout carries no `data.text` of its own — its content is child * blocks — so without this case it serialized to an EMPTY line while its * children escaped as loose siblings, indented four spaces into a code * block. Rendered as a blockquote: the emoji leads the body; type and * colours are lost. */ case 'callout': { warn(context, block.tool, 'degraded', 'callout is rendered as a blockquote; its emoji styling, colours and type are lost'); const emoji = asString(data.emoji); const body = childrenToMarkdown(block, context); return prefixLines([emoji, body].filter((part) => part !== '').join(' '), '> '); } /** * Markdown has no collapsible section, so the summary becomes a bold line * and the body follows it as ordinary blocks. */ case 'toggle': case 'toggleList': { warn(context, block.tool, 'degraded', 'toggle is rendered as a bold summary followed by its body; collapsibility is lost'); /** * A legacy `toggleList` keeps its summary in `data.title`, and the view's * document model re-emits that title as a LEADING PARAGRAPH child. So the * summary is read from the data and that child is skipped, or the title * would print twice — once bold, once plain. */ const legacyTitle = block.tool === 'toggleList' ? asString(data.title) : ''; const summary = legacyTitle === '' ? text : inlineMarkdown(context, legacyTitle); const body = childrenToMarkdown(block, context, legacyTitleChildId(block, context, legacyTitle)); const title = `**${summary}**`; return body === '' ? title : `${title}\n\n${body}`; } /** Markdown has no columns; the layout flattens into reading order. */ case 'column_list': case 'columns': warn(context, block.tool, 'degraded', 'columns are flattened into sequential blocks; the side-by-side layout is lost'); return childrenToMarkdown(block, context); case 'column': return childrenToMarkdown(block, context); /** Pure vertical whitespace — Markdown has no representation for a gap. */ case 'spacer': warn(context, block.tool, 'dropped', 'spacer is purely visual and has no Markdown equivalent'); return ''; default: break; } // A non-list block keeps its Tab-indent only while a list item owns it, where // four spaces are the continuation. Anywhere else they are an indented code // block, so nesting is dropped rather than exported as code. const flatIndent = isUnderList(block, context) ? LIST_INDENT.repeat(Math.max(Number(block.indent ?? 0), 0)) : ''; switch (block.tool) { case 'header': { const level = Math.min(Math.max(Number(data.level) || 1, 1), 6); if (data.isToggleable === true) { warn(context, block.tool, 'degraded', 'collapsible heading is rendered as a heading followed by its body; collapsibility is lost'); } return `${flatIndent}${'#'.repeat(level)} ${text}`; } case 'quote': { /** A blockquote has no attribution line, so the caption has nowhere to go. */ if (asString(data.caption) !== '') { warn(context, block.tool, 'degraded', 'quote is rendered as a blockquote; its caption is lost'); } /** * EVERY line carries the marker: a `
` in the quote reaches here as a * newline, and one prefix left line two a plain paragraph — the quote * silently lost half its content. A blank line is a bare `>` (the GFM * convention), and the flat indent repeats so a quote inside a list item * keeps continuing that item. */ return text .split('\n') .map((line) => (line === '' ? `${flatIndent}>` : `${flatIndent}> ${line}`)) .join('\n'); } case 'code': { const language = asString(data.language).trim(); const info = language === PLAIN_TEXT_LANGUAGE ? '' : language; /** * `data.code` is LITERAL text — the code tool saves the code element's * `textContent` — so it is emitted verbatim, every `<`, `>` and `&` * intact. It used to go through the inline backend's `htmlToText`, which * both ATE markup (`List` came out `List()`) and, in the browser, * parsed the snippet: `innerHTML` on a detached div still fires an * image's `onerror`, so copying a code sample as Markdown ran it. */ const literal = asString(data.code); const body = literal !== '' ? literal : decodeCharacterReferences(asString(data.text)); return `${flatIndent}\`\`\`${info}\n${body}\n\`\`\``; } /** `delimiter` is the Editor.js name for the same block; imported documents still carry it. */ case 'divider': case 'delimiter': return `${flatIndent}---`; /** * `![…]` is the ALT slot, so it carries `data.alt` and nothing else. It used * to carry the caption, which both hid the author's alt text and handed the * importer an alt they never wrote (`mdast-to-blocks.ts` reads that slot * back into `alt` AND `caption`). */ case 'image': { const alt = asString(data.alt); const caption = asString(data.caption); warnPresentationLosses(context, block, 'a plain Markdown image', imagePresentationLosses(data)); /** A caption equal to the alt text rides out in the alt slot: nothing is lost. */ if (caption !== '' && caption !== alt) { warn(context, block.tool, 'degraded', 'image caption has no Markdown equivalent (the `![…]` slot is alt text); the caption is lost'); } return `${flatIndent}![${inlineMarkdown(context, alt)}](${asString(data.url)})`; } /** * Markdown has no media or embed syntax, so these degrade to a link — which * still carries the URL. Without a case they serialized to an EMPTY line * (they hold no `data.text`), silently dropping the block on copy/export. */ case 'video': case 'audio': case 'file': case 'bookmark': case 'embed': { warn(context, block.tool, 'degraded', `${block.tool} is rendered as a plain link; the embedded player or preview is lost`); const url = asString(data.url) || asString(data.source); const label = inlineMarkdown(context, asString(data.caption)) || asString(data.title) || asString(data.fileName) || asString(data.service) || url; return `${flatIndent}[${label}](${url})`; } default: { const fallback = `${flatIndent}${text}`; /** * A block with no dedicated case and no inline text vanishes from the * output. Naming it is the runtime half of the Markdown serialization * law: the build-time scan cannot see a tool a consumer registered. */ if (fallback.trim() === '' && typeof data.text !== 'string') { warn(context, block.tool, 'dropped', `\`${block.tool}\` has no Markdown representation and carries no inline text`); return ''; } return fallback; } } }; /** * Build the id/children lookups for one serialization run. * @param blocks - blocks to serialize * @param inline - the inline backend * @param warnings - accumulator for degradations */ const buildContext = ( blocks: SerializableBlock[], inline: InlineBackend, warnings: MarkdownDegradation[] ): SerializationContext => { const byId = new Map(); const childrenOf = new Map(); for (const block of blocks) { if (block.id !== undefined) { byId.set(block.id, block); } const parentId = block.parentId; if (typeof parentId === 'string') { const siblings = childrenOf.get(parentId) ?? []; siblings.push(block); childrenOf.set(parentId, siblings); } } return { byId, childrenOf, inline, warnings, active: new Set(), inlineSeen: new Set() }; }; /** * Collect the ids of blocks a container already serializes INSIDE itself, so * they are not ALSO emitted as loose top-level lines. Container children live * in the same flat array as the container, which is why the claim has to be * computed up front rather than discovered during the walk. * @param blocks - blocks to serialize * @param context - the serialization context */ const collectOwnedIds = (blocks: SerializableBlock[], context: SerializationContext): Set => { const owned = new Set(); const queue = blocks .filter((block) => CONTAINER_TOOLS.has(block.tool)) .map((block) => block.id) .filter((id): id is string => id !== undefined); /** * Mark one id as rendered inside a container. * @param id - the claimed block's id */ const claimId = (id: string): void => { if (owned.has(id)) { return; } owned.add(id); queue.push(id); }; /** * Queue a not-yet-owned child id. * @param child - a structural child of an owned block */ const claim = (child: SerializableBlock): void => { if (child.id !== undefined) { claimId(child.id); } }; /** * A table cell resolves its content through `byId`, not through the `parent` * edge, so a cell block carrying no `parentId` is rendered inside the table * anyway. Claiming only the structural children left it ALSO emitted as a * loose top-level block — the same content twice. */ blocks .filter((block) => block.tool === 'table') .flatMap((block) => readTableGrid(block.data).flat()) .flatMap((cell): unknown[] => (Array.isArray(cell.blocks) ? cell.blocks : [])) .filter((id): id is string => typeof id === 'string') .forEach(claimId); while (queue.length > 0) { const parentId = queue.shift() ?? ''; (context.childrenOf.get(parentId) ?? []).forEach(claim); } return owned; }; /** * Serialize an ordered list of blocks to a single Markdown string, collecting * every construct that could not be carried across. * @param blocks - blocks to serialize, in document order * @param inline - reads the blocks' inline HTML * @returns the Markdown and the degradations recorded while producing it */ export const serializeBlocksToMarkdown = ( blocks: SerializableBlock[], inline: InlineBackend ): { markdown: string; warnings: MarkdownDegradation[] } => { const warnings: MarkdownDegradation[] = []; const context = buildContext(blocks, inline, warnings); const ownedIds = collectOwnedIds(blocks, context); const topLevel = blocks.filter((block) => block.id === undefined || !ownedIds.has(block.id)); return { markdown: sequenceToMarkdown(topLevel, context), warnings }; };