/** * `htmlToBlocks` — DOM-free HTML → Blok document conversion, the inverse of * `blocksToHtml` and the twin of `markdownToBlocks`. * * Reads HTML with parse5 rather than a DOM, so it runs in bare Node, workers, * RSC and — the reason it exists — the Jint engine the `Blok.Server` NuGet * package embeds, which has no `document`, `window` or `DOMParser`. * * It deliberately covers the STRUCTURAL subset a document body is made of: * headings, paragraphs, lists, tables, images, links, code, blockquotes, * toggles and dividers. Anything outside that subset is reported through * {@link HtmlImportResult.warnings} rather than dropped in silence — a caller * storing the result has to be able to tell what did not survive. * * PURITY CONTRACT: only pure imports (src/shared/*, src/view/*, parse5, and the * DOM-free leaves of src/markdown + src/components/utils/sanitize-url). */ import { parse, serialize } from 'parse5'; import type { DefaultTreeAdapterMap } from 'parse5'; import type { OutputBlockData } from '../../types'; import type { ImageAlignment } from '../../types/tools/image'; import { INLINE_TEXT_SANITIZE } from '../components/shared/inline-content-sanitize'; import { safeImageSrc } from '../components/utils/sanitize-url'; import { normalizeFenceLang } from '../markdown/fence-language'; import type { MarkdownDegradation } from '../markdown/blocks-to-markdown-core'; import { sanitizeHtmlFragment } from './sanitize'; export type { MarkdownDegradation } from '../markdown/blocks-to-markdown-core'; type P5Element = DefaultTreeAdapterMap['element']; type P5ChildNode = DefaultTreeAdapterMap['childNode']; /** Blocks parsed out of HTML, plus everything the HTML could not carry into them. */ export interface HtmlImportResult { /** Blocks ready for `blok.blocks.render()`, `insertMany()`, or storage. */ blocks: OutputBlockData[]; /** Constructs that arrived degraded or not at all, in document order. */ warnings: MarkdownDegradation[]; } /** * Tags whose content is machinery, not prose. Removed without a warning: a * caller told that a stylesheet did not become a block learns nothing. */ const SILENTLY_REMOVED = new Set(['script', 'style', 'template', 'link', 'meta', 'base', 'param', 'source', 'track', 'col', 'colgroup']); /** * Tags that carry content Blok has no block for. Removed WITH a warning naming * the tag — this is the class of loss the whole report exists for. */ const DROPPED_WITH_WARNING = new Set([ 'iframe', 'video', 'audio', 'object', 'embed', 'canvas', 'svg', 'math', 'form', 'input', 'button', 'select', 'textarea', 'output', 'progress', 'meter', 'dialog', 'map', 'area', 'frame', 'frameset', 'applet', 'noscript', ]); /** Containers with no meaning of their own: unwrapped, children converted in place. */ const TRANSPARENT = new Set([ 'div', 'section', 'article', 'main', 'header', 'footer', 'nav', 'center', 'hgroup', 'picture', 'address', 'fieldset', 'body', 'html', ]); /** * Inline tags that survive into a block's stored `text`. Mirrors * {@link INLINE_TEXT_SANITIZE}, which is what actually enforces it. */ const KEPT_INLINE = new Set(['br', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'a', 'code', 'mark', 'span']); /** * Inline tags the sanitizer unwraps whose MEANING goes with them — `H2O` * and `H2O` do not say the same thing. Reported once per occurrence. Purely * presentational inline markup (`span`, `font`, `label`) is unwrapped without a * warning: its text survives intact, so nothing a reader can point at is lost. */ const LOSSY_INLINE = new Set(['sub', 'sup', 'ins', 'abbr', 'q', 'cite', 'kbd', 'samp', 'var', 'small', 'big', 'ruby', 'rt']); /** Tags read as inline content when they appear beside text. */ const INLINE_TAGS = new Set([ ...KEPT_INLINE, ...LOSSY_INLINE, 'font', 'label', 'strike', 'tt', 'nobr', 'wbr', 'bdi', 'bdo', 'data', 'time', 'dfn', ]); const HEADING = /^h([1-6])$/; /** Everything one conversion shares. */ interface Ctx { nextId: () => string; warnings: MarkdownDegradation[]; blocks: OutputBlockData[]; } /** * Record one degradation. * @param ctx - conversion state * @param construct - the tag or construct that degraded * @param action - `dropped` when nothing was emitted, `degraded` when something lossy was * @param detail - plain-language explanation */ const warn = (ctx: Ctx, construct: string, action: 'dropped' | 'degraded', detail: string): void => { ctx.warnings.push({ construct, action, detail }); }; /** * Ids are scoped to one conversion, so two documents converted in the same * millisecond cannot collide through a shared counter. */ const createIdGenerator = (): () => string => { const prefix = `html-${Date.now().toString(36)}`; const state = { counter: 0 }; return () => `${prefix}-${(state.counter++).toString(36)}`; }; /** * @param node - node to test */ const isElement = (node: P5ChildNode): node is P5Element => 'tagName' in node; /** * @param node - node to test */ const isText = (node: P5ChildNode): node is DefaultTreeAdapterMap['textNode'] => node.nodeName === '#text'; /** * @param node - element to read * @param name - attribute name */ const attr = (node: P5Element, name: string): string | undefined => node.attrs.find((candidate) => candidate.name === name)?.value; /** * @param node - node whose children are wanted */ const childrenOf = (node: P5ChildNode): P5ChildNode[] => 'childNodes' in node ? node.childNodes : []; /** * Raw text of a subtree, entities already decoded by the tokenizer. * @param nodes - nodes to read */ const rawText = (nodes: P5ChildNode[]): string => nodes.map((node) => { if (isText(node)) { return node.value; } return 'childNodes' in node ? rawText(node.childNodes) : ''; }).join(''); /** * Serialize loose nodes by lending them a fragment to hang off. parse5's * serializer only ever reads `childNodes`, so the borrowed parent costs one * object rather than a re-parse. * @param nodes - nodes to serialize */ const serializeNodes = (nodes: P5ChildNode[]): string => serialize({ nodeName: '#document-fragment', childNodes: nodes }); /** * Report the inline tags a sanitize pass is about to unwrap along with their * meaning. Walks before sanitizing, because afterwards there is nothing left to * see. * @param ctx - conversion state * @param nodes - the inline nodes about to be serialized */ const reportLossyInline = (ctx: Ctx, nodes: P5ChildNode[]): void => { for (const node of nodes) { if (!isElement(node)) { continue; } if (LOSSY_INLINE.has(node.tagName)) { warn(ctx, node.tagName, 'degraded', `<${node.tagName}> is unwrapped; its text is kept but Blok has no inline mark for it`); } reportLossyInline(ctx, node.childNodes); } }; /** * Inline nodes as the HTML string Blok stores in a block's `text`. * @param ctx - conversion state * @param nodes - inline nodes */ const inlineHtml = (ctx: Ctx, nodes: P5ChildNode[]): string => { reportLossyInline(ctx, nodes); return sanitizeHtmlFragment(serializeNodes(nodes), INLINE_TEXT_SANITIZE).trim(); }; /** * @param ctx - conversion state * @param type - block type * @param data - block data */ const push = (ctx: Ctx, type: string, data: Record): OutputBlockData => { const block: OutputBlockData = { id: ctx.nextId(), type, data }; ctx.blocks.push(block); return block; }; /** * Whether a node is inline content rather than a block of its own. An unknown * tag counts as a block, so a wrapper nobody has heard of still gets reported * instead of being silently folded into a paragraph. * @param node - node to classify */ const isInline = (node: P5ChildNode): boolean => { if (isText(node)) { return true; } return isElement(node) && INLINE_TAGS.has(node.tagName); }; /** * An element's inline style, keyed by property. Both halves are lower-cased, * so a declaration only has to be compared one way. * @param element - element to read */ const styleOf = (element: P5Element): Map => { const declarations = (attr(element, 'style') ?? '').toLowerCase().split(';'); return new Map(declarations.flatMap((declaration): Array<[string, string]> => { const colon = declaration.indexOf(':'); return colon === -1 ? [] : [[declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()]]; })); }; const PERCENT = /^(\d+(?:\.\d+)?)%$/; /** * A CSS width as `ImageData.width` stores it — a percent of the container, * 10–100. Anything else reads as no width at all, a px length included: the * container is not knowable from the HTML, and dividing by a guessed one puts * the result outside the range the field allows. * @param raw - a declaration value or an attribute value */ const percentWidth = (raw: string | undefined): number | null => { const match = PERCENT.exec((raw ?? '').trim()); if (match === null) { return null; } const percent = Math.round(Number(match[1])); return percent >= 10 && percent <= 100 ? percent : null; }; /** * The left and right margins in force, the shorthand resolved the way CSS * reads it and the longhands laid over it. * @param style - the element's inline style */ const marginSides = (style: Map): { left: string; right: string } => { const parts = (style.get('margin') ?? '').split(/\s+/).filter((part) => part !== ''); const sides = parts[1] ?? parts[0] ?? ''; return { left: style.get('margin-left') ?? parts[3] ?? sides, right: style.get('margin-right') ?? sides, }; }; /** * Alignment the margins imply: two `auto` sides centre the box, one `auto` * side pushes it to the other one. * @param style - the element's inline style */ const marginAlignment = (style: Map): ImageAlignment | null => { const { left, right } = marginSides(style); if (left === 'auto') { return right === 'auto' ? 'center' : 'right'; } return right === 'auto' ? 'left' : null; }; /** The `align` values that map onto an {@link ImageAlignment}; `justify` and `middle` do not. */ const LEGACY_ALIGN = new Map([['left', 'left'], ['center', 'center'], ['right', 'right']]); /** * Horizontal alignment of an image, read from the CSS that carries it and only * then from the presentational attribute that CSS would have overridden. * @param element - the `img` element * @param style - the element's inline style */ const imageAlignment = (element: P5Element, style: Map): ImageAlignment | null => { const float = style.get('float'); if (float === 'left' || float === 'right') { return float; } const legacy = (attr(element, 'align') ?? '').trim().toLowerCase(); return marginAlignment(style) ?? LEGACY_ALIGN.get(legacy) ?? null; }; /** * An image's block data, or null when the source is missing or unsafe. * @param element - the `img` element * @param caption - caption text overriding the element's own alt */ const imageData = (element: P5Element, caption?: string): Record | null => { const src = (attr(element, 'src') ?? '').trim(); const url = src === '' ? null : safeImageSrc(src); if (url === null) { return null; } const text = caption ?? attr(element, 'alt') ?? ''; const style = styleOf(element); const data: Record = text === '' ? { url } : { url, caption: text, alt: text }; const width = percentWidth(style.get('width')) ?? percentWidth(attr(element, 'width')); const alignment = imageAlignment(element, style); if (width !== null) { data.width = width; } if (alignment !== null) { data.alignment = alignment; } return data; }; /** * Emit an image block, reporting an image whose source cannot be used. * @param ctx - conversion state * @param element - the `img` element * @param caption - caption text overriding the element's own alt */ const emitImage = (ctx: Ctx, element: P5Element, caption?: string): void => { const data = imageData(element, caption); if (data === null) { warn(ctx, 'img', 'dropped', 'The image has no usable src; Blok cannot store an image without one'); return; } push(ctx, 'image', data); }; /** * Emit every image a subtree holds, for a caller whose own field is built from * text nodes alone and would otherwise drop them without a trace. * @param ctx - conversion state * @param nodes - nodes to search */ const emitNestedImages = (ctx: Ctx, nodes: P5ChildNode[]): void => { for (const node of nodes) { if (isElement(node) && node.tagName === 'img') { emitImage(ctx, node); continue; } emitNestedImages(ctx, childrenOf(node)); } }; /** * Whether an `img` sits anywhere under a node. An inline wrapper holding one — * ``, or the sized `` a Docs export writes — has to be taken apart, * because the inline sanitizer keeps the wrapper and strips the image. * @param node - node to search */ const hasImage = (node: P5ChildNode): boolean => childrenOf(node).some((child) => (isElement(child) && child.tagName === 'img') || hasImage(child)); /** * Report the link an image wrapper carries, which the image it wraps cannot * keep — Blok's image block has no link field. * @param ctx - conversion state * @param element - the inline element wrapping an image */ const reportImageLink = (ctx: Ctx, element: P5Element): void => { if (element.tagName === 'a' && (attr(element, 'href') ?? '').trim() !== '') { warn(ctx, 'a', 'degraded', 'A link around an image is dropped and the image kept; Blok\'s image block has no link field'); } }; /** One piece a run of inline nodes splits into, in document order. */ type InlineSegment = { image: P5Element } | { block: P5Element } | { inline: P5ChildNode[] }; /** * Split a run of inline nodes on the images inside it, so a paragraph that * mixes prose and an image yields both rather than losing the image to the * inline sanitizer, which has no `img` rule. * @param ctx - conversion state * @param nodes - inline nodes */ const splitOnImages = (ctx: Ctx, nodes: P5ChildNode[]): InlineSegment[] => { const segments: InlineSegment[] = []; const pending: { run: P5ChildNode[] } = { run: [] }; /** Close the run of inline nodes collected so far. */ const flush = (): void => { if (pending.run.length > 0) { segments.push({ inline: pending.run }); pending.run = []; } }; /** * Fold a wrapper's own parts into this run, re-wrapping the inline ones so * `text ` keeps its bold on the text. The clone borrows the * wrapper's tag and attributes, which is all the serializer reads. * @param wrapper - the inline element being taken apart * @param parts - what its children split into */ const absorb = (wrapper: P5Element, parts: InlineSegment[]): void => { for (const part of parts) { if ('inline' in part) { pending.run.push({ ...wrapper, childNodes: part.inline }); continue; } flush(); segments.push(part); } }; for (const node of nodes) { if (isElement(node) && node.tagName === 'img') { flush(); segments.push({ image: node }); continue; } if (isElement(node) && !INLINE_TAGS.has(node.tagName)) { // Hoisted whole rather than walked: `convertElement` is the only place // that knows an element is dropped, so walking past it drops it silently. flush(); segments.push({ block: node }); continue; } if (isElement(node) && hasImage(node)) { reportImageLink(ctx, node); absorb(node, splitOnImages(ctx, node.childNodes)); continue; } pending.run.push(node); } flush(); return segments; }; /** * Emit the non-text segments of an inline run, for a caller that has already * taken the text for a field of its own. * @param ctx - conversion state * @param segments - what the run split into */ const emitSegmentMedia = (ctx: Ctx, segments: InlineSegment[]): void => { for (const segment of segments) { if ('image' in segment) { emitImage(ctx, segment.image); continue; } if ('block' in segment) { convertNodes(ctx, [segment.block]); } } }; /** * Emit the blocks a run of inline content amounts to: one paragraph, or a * paragraph/image sequence when images are mixed in. * @param ctx - conversion state * @param nodes - inline nodes * @param type - block type for the text segments * @param extra - extra data merged into each text block */ const emitInlineRun = ( ctx: Ctx, nodes: P5ChildNode[], type = 'paragraph', extra: Record = {} ): void => { for (const segment of splitOnImages(ctx, nodes)) { if ('image' in segment) { emitImage(ctx, segment.image); continue; } if ('block' in segment) { convertNodes(ctx, [segment.block]); continue; } const text = inlineHtml(ctx, segment.inline); if (text !== '') { push(ctx, type, { text, ...extra }); } } }; /** * The language a `pre` declares, through its own class or its `code` child's. * @param element - the `pre` element */ const codeLanguage = (element: P5Element): string => { const code = element.childNodes.find((node): node is P5Element => isElement(node) && node.tagName === 'code'); const classes = `${attr(element, 'class') ?? ''} ${code === undefined ? '' : attr(code, 'class') ?? ''}`; const match = /(?:^|\s)(?:language|lang|highlight)-([\w+#-]+)/.exec(classes); const raw = match?.[1] ?? ''; return normalizeFenceLang(raw) ?? (raw === '' ? 'plain text' : raw); }; /** Elements a blockquote unwraps into its own text, the way a browser lays them out. */ const QUOTE_UNWRAPPED = new Set([...TRANSPARENT, 'p']); /** * Group a blockquote's children into the runs its text is built from: adjacent * inline nodes read as one run, a paragraph or wrapper contributes its own * children, and anything else stays whole so `splitOnImages` hoists it out * rather than folding it into a field that cannot hold it. * @param nodes - the blockquote's children */ const quoteRuns = (nodes: P5ChildNode[]): P5ChildNode[][] => { const runs: P5ChildNode[][] = []; const pending: { run: P5ChildNode[] } = { run: [] }; /** Close the run of inline nodes collected so far. */ const flush = (): void => { if (pending.run.length > 0) { runs.push(pending.run); pending.run = []; } }; for (const node of nodes) { if (isInline(node)) { pending.run.push(node); continue; } flush(); runs.push(isElement(node) && (QUOTE_UNWRAPPED.has(node.tagName) || HEADING.test(node.tagName)) ? node.childNodes : [node]); } flush(); return runs; }; /** * Convert a blockquote. Its paragraphs join with `
`, matching the Markdown * importer — Blok's quote holds one inline field, not a block list. What that * field cannot hold follows the quote as blocks of its own, and a quote left * with no text at all is not emitted. * @param ctx - conversion state * @param element - the `blockquote` element */ const emitQuote = (ctx: Ctx, element: P5Element): void => { const segments = quoteRuns(element.childNodes).flatMap((run) => splitOnImages(ctx, run)); const text = segments .flatMap((segment) => 'inline' in segment ? [inlineHtml(ctx, segment.inline)] : []) .filter((part) => part !== '') .join('
'); if (text !== '') { push(ctx, 'quote', { text, size: 'default' }); } emitSegmentMedia(ctx, segments); }; /** * Convert `details` into a toggle whose body blocks reference it as `parent`. * A toggle's title is one inline field, so an image the summary carries opens * the body instead, where it stays attached to the toggle. * @param ctx - conversion state * @param element - the `details` element */ const emitToggle = (ctx: Ctx, element: P5Element): void => { const summary = element.childNodes.find((node): node is P5Element => isElement(node) && node.tagName === 'summary'); const segments = summary === undefined ? [] : splitOnImages(ctx, summary.childNodes); const toggle = push(ctx, 'toggle', { text: inlineHtml(ctx, segments.flatMap((segment) => 'inline' in segment ? segment.inline : [])), isOpen: attr(element, 'open') !== undefined, }); const body = element.childNodes.filter((node) => node !== summary); const before = ctx.blocks.length; emitSegmentMedia(ctx, segments); convertNodes(ctx, body); for (const block of ctx.blocks.slice(before)) { if (block.parent === undefined) { block.parent = toggle.id; } } }; /** One list item, split into the parts a Blok list block is built from. */ interface ListItemParts { inline: P5ChildNode[]; blocks: P5ChildNode[]; nested: P5Element[]; checkbox: P5Element | undefined; } /** * Split a list item's children into its own text, its nested lists, and the * block-level content that becomes sibling blocks after it. * @param item - the `li` element */ const splitListItem = (item: P5Element): ListItemParts => { const parts: ListItemParts = { inline: [], blocks: [], nested: [], checkbox: undefined }; for (const node of item.childNodes) { if (isElement(node) && (node.tagName === 'ul' || node.tagName === 'ol')) { parts.nested.push(node); continue; } if (isElement(node) && node.tagName === 'input' && attr(node, 'type') === 'checkbox') { parts.checkbox = node; continue; } if (isInline(node)) { parts.inline.push(node); continue; } parts.blocks.push(node); } /** * An item written as `
  • text

  • ` says the same thing as * `
  • text
  • `, so its lone paragraph becomes the item's text rather than * a stray block after it. */ const [first] = parts.blocks; if (rawText(parts.inline).trim() === '' && first !== undefined && isElement(first) && first.tagName === 'p') { parts.inline = first.childNodes; parts.blocks = parts.blocks.slice(1); } return parts; }; /** * Convert a `ul`/`ol` into Blok's flat list blocks. * @param ctx - conversion state * @param element - the list element * @param depth - nesting level, 0 at the root */ const emitList = (ctx: Ctx, element: P5Element, depth: number): void => { const ordered = element.tagName === 'ol'; const bulletStyle = ordered ? 'ordered' : 'unordered'; const start = Number(attr(element, 'start')); const firstItem = element.childNodes.find( (node): node is P5Element => isElement(node) && node.tagName === 'li' ); for (const node of element.childNodes) { if (!isElement(node)) { continue; } /** * A `ul`/`ol` that is a SIBLING of the items rather than a child of one: * what HTML5 parsing makes of the unclosed-`li` nesting legacy editors * emit. A browser renders it one level in, so it is imported that way. */ if (node.tagName === 'ul' || node.tagName === 'ol') { emitList(ctx, node, depth + 1); continue; } if (node.tagName !== 'li') { continue; } const parts = splitListItem(node); const style = parts.checkbox === undefined ? bulletStyle : 'checklist'; /** * An item's text is one field, so an image the inline nodes carry cannot * stay in it. It is lifted out and emitted after the item, the way a block * child of the item already is. */ const segments = splitOnImages(ctx, parts.inline); const data: Record = { text: inlineHtml(ctx, segments.flatMap((segment) => 'inline' in segment ? segment.inline : [])), style, depth, }; if (parts.checkbox !== undefined) { data.checked = attr(parts.checkbox, 'checked') !== undefined; } if (ordered && node === firstItem && Number.isInteger(start)) { data.start = start; } push(ctx, 'list', data); emitSegmentMedia(ctx, segments); convertNodes(ctx, parts.blocks); for (const nested of parts.nested) { emitList(ctx, nested, depth + 1); } } }; /** A cell as Blok stores it. */ interface GridCell { blocks: string[]; colspan?: number; rowspan?: number; mergedInto?: [number, number]; } /** * Every `tr` under a table, in document order, reading through * `thead`/`tbody`/`tfoot`. * @param element - the `table` element */ const tableRows = (element: P5Element): P5Element[] => { const rows: P5Element[] = []; /** * @param nodes - nodes to walk */ const walk = (nodes: P5ChildNode[]): void => { for (const node of nodes) { if (!isElement(node)) { continue; } if (node.tagName === 'tr') { rows.push(node); continue; } if (node.tagName === 'thead' || node.tagName === 'tbody' || node.tagName === 'tfoot') { walk(node.childNodes); } } }; walk(element.childNodes); return rows; }; /** * @param row - a `tr` element */ const rowCells = (row: P5Element): P5Element[] => row.childNodes.filter((node): node is P5Element => isElement(node) && (node.tagName === 'td' || node.tagName === 'th')); /** * Read a span attribute, clamped to something a grid can be built from. * @param cell - the cell element * @param name - `colspan` or `rowspan` */ const spanOf = (cell: P5Element, name: 'colspan' | 'rowspan'): number => { const value = Number.parseInt(attr(cell, name) ?? '', 10); return Number.isInteger(value) && value > 1 ? Math.min(value, 1000) : 1; }; /** * Convert a table into a table block plus the cell blocks it references, which * are siblings carrying `parent` = the table's id. * @param ctx - conversion state * @param element - the `table` element */ const emitTable = (ctx: Ctx, element: P5Element): void => { const rows = tableRows(element); const caption = element.childNodes.find((node): node is P5Element => isElement(node) && node.tagName === 'caption'); if (caption !== undefined) { warn(ctx, 'caption', 'degraded', 'A table caption leads the table as a paragraph; Blok\'s table has no caption field'); emitInlineRun(ctx, caption.childNodes); } const table = push(ctx, 'table', {}); const grid: Array> = rows.map(() => []); /** * @param index - row index to guarantee exists */ const rowAt = (index: number): Array => { while (grid.length <= index) { grid.push([]); } return grid[index]; }; /** * Reserve the area a spanning cell covers, so the cells after it in later * rows land in the column they actually occupy. * @param origin - [row, column] of the merge origin * @param colspan - columns the origin covers * @param rowspan - rows the origin covers */ const cover = (origin: [number, number], colspan: number, rowspan: number): void => { const [originRow, originColumn] = origin; const offsets = Array.from({ length: rowspan }, (_, r) => r) .flatMap((r) => Array.from({ length: colspan }, (_, c) => [r, c] as const)) .filter(([r, c]) => r !== 0 || c !== 0); for (const [r, c] of offsets) { rowAt(originRow + r)[originColumn + c] = { blocks: [], mergedInto: [originRow, originColumn] }; } }; /** * Convert one cell into the blocks it owns and the grid entry referencing them. * @param cell - the `td`/`th` element * @returns ids of the blocks the cell owns */ const cellBlockIds = (cell: P5Element): string[] => { const before = ctx.blocks.length; convertCell(ctx, cell); const owned = ctx.blocks.slice(before); for (const block of owned) { block.parent ??= table.id; } return owned .filter((block) => block.parent === table.id) .map((block) => block.id) .filter((id): id is string => id !== undefined); }; /** * Place every cell of one row, skipping the columns a cell above already covers. * @param row - the `tr` element * @param rowIndex - its index among the table's rows */ const placeRow = (row: P5Element, rowIndex: number): void => { const cursor = { column: 0 }; for (const cell of rowCells(row)) { while (rowAt(rowIndex)[cursor.column] !== undefined) { cursor.column += 1; } const colspan = spanOf(cell, 'colspan'); const rowspan = spanOf(cell, 'rowspan'); const blocks = cellBlockIds(cell); rowAt(rowIndex)[cursor.column] = { blocks, ...(colspan > 1 ? { colspan } : {}), ...(rowspan > 1 ? { rowspan } : {}), }; cover([rowIndex, cursor.column], colspan, rowspan); cursor.column += colspan; } }; rows.forEach(placeRow); const width = grid.reduce((widest, row) => Math.max(widest, row.length), 0); const content = grid.map((row) => Array.from({ length: width }, (_, index) => row[index] ?? { blocks: [] })); const headRow = element.childNodes.some((node) => isElement(node) && node.tagName === 'thead' && rowCells(node).length + tableRows(node).length > 0); const allTh = (row: P5Element): boolean => { const cells = rowCells(row); return cells.length > 0 && cells.every((cell) => cell.tagName === 'th'); }; table.data = { withHeadings: headRow || (rows.length > 1 && rows[0] !== undefined && allTh(rows[0])), withHeadingColumn: rows.length > 1 && rows.every((row) => rowCells(row)[0]?.tagName === 'th'), content, }; }; /** * Convert one cell's content. A cell holds blocks, so a paragraph, list or * image inside it converts normally; a nested table does not, because its own * cells would need a second level of parenting Blok's grid cannot express. * @param ctx - conversion state * @param cell - the `td`/`th` element */ const convertCell = (ctx: Ctx, cell: P5Element): void => { const nested = cell.childNodes.filter((node): node is P5Element => isElement(node) && node.tagName === 'table'); if (nested.length === 0) { const before = ctx.blocks.length; convertNodes(ctx, cell.childNodes); if (ctx.blocks.length === before) { push(ctx, 'paragraph', { text: '' }); } return; } warn(ctx, 'table', 'degraded', 'A nested table is flattened into the cell\'s paragraphs; a Blok cell holds blocks, not another grid'); convertNodes(ctx, cell.childNodes.filter((node) => !nested.includes(node as P5Element))); const innerCells = nested.flatMap((inner) => tableRows(inner).flatMap(rowCells)); for (const innerCell of innerCells) { emitInlineRun(ctx, innerCell.childNodes); } }; /** * Convert a `figure`: an image with its caption when it holds one, otherwise * just its children. * @param ctx - conversion state * @param element - the `figure` element */ const emitFigure = (ctx: Ctx, element: P5Element): void => { const images: P5Element[] = []; const found: { caption: P5Element | undefined } = { caption: undefined }; /** * @param nodes - nodes to walk */ const walk = (nodes: P5ChildNode[]): void => { for (const node of nodes) { if (!isElement(node)) { continue; } if (node.tagName === 'img') { images.push(node); } else if (node.tagName === 'figcaption') { found.caption ??= node; } else { walk(node.childNodes); } } }; walk(element.childNodes); if (images.length !== 1) { convertNodes(ctx, element.childNodes); return; } const { caption } = found; // A caption is one plain-text field: an image inside it follows the figure. const segments = caption === undefined ? [] : splitOnImages(ctx, caption.childNodes); const text = rawText(segments.flatMap((segment) => 'inline' in segment ? segment.inline : [])).trim(); emitImage(ctx, images[0], text === '' ? undefined : text); emitSegmentMedia(ctx, segments); }; /** * Convert an element with no block handler of its own: recurse when it holds * block content, read it as a paragraph when it holds only inline content. * @param ctx - conversion state * @param element - the element to flatten */ const flatten = (ctx: Ctx, element: P5Element): void => { if (element.childNodes.some((node) => !isInline(node))) { convertNodes(ctx, element.childNodes); return; } emitInlineRun(ctx, element.childNodes); }; /** * Convert one element. * @param ctx - conversion state * @param element - the element to convert */ const convertElement = (ctx: Ctx, element: P5Element): void => { const tag = element.tagName; if (SILENTLY_REMOVED.has(tag)) { return; } if (DROPPED_WITH_WARNING.has(tag)) { warn(ctx, tag, 'dropped', `<${tag}> and its contents are dropped; Blok has no block for it`); return; } const heading = HEADING.exec(tag); if (heading !== null) { emitInlineRun(ctx, element.childNodes, 'header', { level: Number(heading[1]) }); return; } switch (tag) { case 'p': emitInlineRun(ctx, element.childNodes); return; case 'hr': push(ctx, 'divider', {}); return; case 'br': return; case 'img': emitImage(ctx, element); return; case 'pre': push(ctx, 'code', { code: rawText(element.childNodes).replace(/\n$/, ''), language: codeLanguage(element) }); // `rawText` reads text nodes only, so an image inside the block follows it // rather than disappearing. emitNestedImages(ctx, element.childNodes); return; case 'blockquote': emitQuote(ctx, element); return; case 'ul': case 'ol': emitList(ctx, element, 0); return; case 'table': emitTable(ctx, element); return; case 'details': emitToggle(ctx, element); return; case 'figure': emitFigure(ctx, element); return; case 'dl': warn(ctx, 'dl', 'degraded', 'A definition list becomes plain paragraphs; Blok has no term/definition block'); convertNodes(ctx, element.childNodes); return; case 'aside': warn(ctx, 'aside', 'degraded', '