/**
* 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 `<` — 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}})`;
}
/**
* 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 };
};