/**
* Per-tool `data → HTML string` emitters for the synchronous view renderer —
* the single central dispatcher over the whole built-in tool set (design D1,
* modeled on `src/markdown/blocks-to-markdown.ts`).
*
* Sanitization contract: every inline-content field passes through
* `env.inline` (the parse5 allowlist walker) before interpolation; scalar
* non-HTML fields go through `env.escape`; URL attributes go through
* `env.url`, which applies the `transformUrl` hook then enforces the shared
* URL scheme policy. Emitters never interpolate unsanitized strings.
*
* PURITY CONTRACT: only pure imports (src/shared/*, src/view/*). Never import
* the `src/components/utils` barrel, editor modules, or tool classes.
*/
import { normalizeHeadingAnchor } from '../shared/heading-anchor';
import { CALLOUT_CHILDREN_CLASSES } from '../shared/tool-classes/callout';
import { CODE_AREA_CLASSES } from '../shared/tool-classes/code';
import { DIVIDER_RULE_CLASSES } from '../shared/tool-classes/divider';
import {
LIST_CHECKBOX_CLASSES,
LIST_CHECKED_CLASSES,
LIST_CHECKLIST_CONTENT_CLASSES,
LIST_CHECKLIST_ROW_CLASSES,
LIST_CONTENT_CLASSES,
LIST_ITEM_ROW_CLASSES,
} from '../shared/tool-classes/list';
import { TOGGLE_CHILDREN_CLASSES, TOGGLE_CONTENT_CLASSES, TOGGLE_HEADER_ROW_CLASSES } from '../shared/tool-classes/toggle';
import type { ViewBlock } from './document-model';
/**
* Rendering services handed to every emitter by the dispatcher.
*/
export interface EmitterEnv {
/** Sanitize an inline-HTML block-data field against the composed allowlist. */
inline(value: unknown): string;
/** Entity-escape a scalar (non-HTML) block-data field. */
escape(value: unknown): string;
/** Structural children of a block, in document order. */
childrenOf(id: string | undefined): ViewBlock[];
/** Resolve blocks referenced by id (unknown ids are dropped). */
blocksById(ids: unknown): ViewBlock[];
/** Render a sibling run of blocks (applies list-run grouping). */
renderList(blocks: ViewBlock[]): string;
/**
* Build a ` name="value"` attribute for a URL-bearing block field. Applies
* the configured `transformUrl` hook first, then the shared unsafe-scheme
* strip — dropping the attribute entirely when the value is empty or resolves
* to an unsafe scheme.
*/
url(name: 'href' | 'src', value: unknown, blockType: string): string;
/**
* Build the ` data-blok-id=""` attribute for a block when the `blockIds`
* option is on and the block carries an id; empty string otherwise.
*/
idAttr(block: ViewBlock): string;
/**
* Build every root attribute the dispatcher would otherwise stamp onto the
* first opening tag — presentational `class`, `data-blok-tool`,
* `data-blok-id` — honouring the same options.
*
* Only emitters listed as self-stamping in `blocks-to-html.ts` use this. An
* emitter that WRAPS its styled element (a toggleable header emits
* `
`) must place them itself: the typography has to land
* on the `
`, and the tool hook must sit on the SAME element or the parity
* harness pairs the wrapper against the editor's heading.
*/
rootAttrs(block: ViewBlock): string;
/**
* Build a ` class="…"` attribute for an INNER element from an explicit class
* list, honouring the `classes` option (empty string when it is off).
*
* For elements that are not the block root — a divider's `` inside its
* spacing wrapper — whose classes therefore never come from `classesFor`.
*/
classList(list: readonly string[]): string;
/**
* Whether the caller asked for editor-identical rendering (`classes: true`).
*
* A few emitters need an extra WRAPPER element to reproduce the editor's box
* model (the divider's spacing wrapper). That wrapper is a structural change
* to this renderer's published output, so it appears only when parity was
* explicitly requested; the default output stays the clean semantic HTML
* existing consumers already receive.
*/
classesEnabled: boolean;
}
/**
* One tool emitter. Emitters are responsible for their block's children:
* containers place them inside their markup, leaf tools append them after
* (via {@link trail}).
*/
export type Emitter = (block: ViewBlock, env: EmitterEnv) => string;
/**
* Read a string field from block data, empty when absent/non-string.
* @param data - block data
* @param key - field name
*/
const str = (data: Record, key: string): string => {
const value = data[key];
return typeof value === 'string' ? value : '';
};
/**
* Append a leaf block's structural children after its own markup.
* @param html - the block's own markup
* @param block - the block
* @param env - emitter environment
*/
const trail = (html: string, block: ViewBlock, env: EmitterEnv): string => {
return html + env.renderList(env.childrenOf(block.id));
};
/**
* Figcaption markup for media blocks: shown when a caption (or fallback
* label) is present and `captionVisible` is not explicitly false.
*
* Captions are entity-escaped, not treated as inline HTML: every live
* caption editor (image/video/audio/file/embed UIs) reads and writes the
* field via `textContent`, so stored captions are plain text — proven by the
* golden harness against a real editor.
* @param block - media block
* @param env - emitter environment
* @param fallbackKeys - additional data fields tried when `caption` is empty
*/
const figcaption = (block: ViewBlock, env: EmitterEnv, fallbackKeys: string[] = []): string => {
if (block.data.captionVisible === false) {
return '';
}
const caption = [str(block.data, 'caption'), ...fallbackKeys.map((key) => str(block.data, key))]
.find((candidate) => candidate !== '') ?? '';
return caption === '' ? '' : `${env.escape(caption)}`;
};
/**
* Wrap a block's children in a plain container element.
* @param block - container block
* @param env - emitter environment
*/
const childrenDiv = (block: ViewBlock, env: EmitterEnv): string => {
return `
${env.renderList(env.childrenOf(block.id))}
`;
};
/**
* Children rendered bare (no own markup) — database blocks' minimal fallback.
* @param block - container block
* @param env - emitter environment
*/
const childrenOnly = (block: ViewBlock, env: EmitterEnv): string => {
return env.renderList(env.childrenOf(block.id));
};
/**
* Marks a nested-block container the way the editor's toggle and callout tools
* do. `main.css` keys the heading override
* `[data-blok-toggle-children] :is(h1, …, h6) { margin-top: 1px }` on it, which
* is what stops a heading nested in a toggle or callout from taking its
* root-level top margin (`mt-8` for an h1). Purely presentational here — the
* view has no hierarchy manager to drive.
*/
const CHILDREN_CONTAINER_ATTR = ' data-blok-toggle-children';
/** List style read with the unordered default (mirrors the list tool). */
const listStyleOf = (block: ViewBlock): string => {
const style = block.data.style;
return style === 'ordered' || style === 'checklist' ? style : 'unordered';
};
/**
* Render one consecutive run of `list` blocks as nested `
`/`` markup.
*
* Nesting comes from the flat `data.depth` (rebased to the run's first item
* and clamped to +1 per step, so imported/corrupt depths degrade gracefully);
* structurally-parented children of an item render inside its `
` via the
* generic children pipeline, which re-enters this builder for nested list
* runs. Checklists render a disabled checkbox carrying the checked state.
* @param items - consecutive sibling blocks of tool `list`
* @param env - emitter environment
*/
export const renderListRun = (items: ViewBlock[], env: EmitterEnv): string => {
if (items.length === 0) {
return '';
}
const base = Math.max(Number(items[0].data.depth ?? 0) || 0, 0);
const eff = items.reduce((acc, item, index) => {
const raw = Math.max((Number(item.data.depth ?? 0) || 0) - base, 0);
acc.push(index === 0 ? 0 : Math.min(raw, acc[index - 1] + 1));
return acc;
}, []);
const itemContent = (item: ViewBlock): string => {
const isChecklist = listStyleOf(item) === 'checklist';
const checkbox = isChecklist
? ``
: '';
const text = env.inline(item.data.text);
const children = env.renderList(env.childrenOf(item.id));
if (!env.classesEnabled) {
return checkbox + text + children;
}
/**
* Under parity the item mirrors the editor's inner layout: a flex row
* holding the marker/checkbox beside a content cell. Without it the
* checkbox and text do not align the way they do while editing.
*/
const row = isChecklist ? LIST_CHECKLIST_ROW_CLASSES : LIST_ITEM_ROW_CLASSES;
if (!isChecklist) {
return `
${text}
${children}`;
}
/**
* A CHECKED item is struck through and faded, and carries `data-checked` —
* which `src/styles/checklist.css` keys its dark-mode rules on. The view
* previously rendered completed items as plain text.
*/
const checked = item.data.checked === true;
const contentClasses = checked
? [...LIST_CHECKLIST_CONTENT_CLASSES, ...LIST_CHECKED_CLASSES]
: LIST_CHECKLIST_CONTENT_CLASSES;
const content = `
${text}
`;
return `
${checkbox}${content}
${children}`;
};
/** One recursion step: the markup produced plus the index to continue from. */
interface Step {
html: string;
next: number;
}
/**
* Consecutive `
`s of one list (same depth, same style), each pulling in
* its deeper descendants as a nested list.
*/
const buildItems = (from: number, depth: number, style: string): Step => {
if (from >= items.length || eff[from] !== depth || listStyleOf(items[from]) !== style) {
return { html: '', next: from };
}
const nested = from + 1 < items.length && eff[from + 1] === depth + 1
? buildLevel(from + 1, depth + 1)
: { html: '', next: from + 1 };
/**
* Each list ITEM is its own block, so the block's root attributes belong on
* the `
` — not on the grouping `
`/``, which has no editor
* counterpart (the editor renders a flat item sequence). Keeping them here
* is also what lets the parity harness pair view `
`s against the
* editor's item blocks one-to-one.
*/
const li = `
${itemContent(items[from])}${nested.html}
`;
const rest = buildItems(nested.next, depth, style);
return { html: li + rest.html, next: rest.next };
};
/** One or more sibling lists at this depth (a style switch opens a new list). */
const buildLevel = (from: number, depth: number): Step => {
if (from >= items.length || eff[from] !== depth) {
return { html: '', next: from };
}
const style = listStyleOf(items[from]);
const start = style === 'ordered' ? Number(items[from].data.start) : Number.NaN;
const startAttr = Number.isInteger(start) && start > 1 ? ` start="${start}"` : '';
const tag = style === 'ordered' ? 'ol' : 'ul';
const run = buildItems(from, depth, style);
const rest = buildLevel(run.next, depth);
/**
* Under parity the grouping element carries `data-list-style` the way the
* editor's list container does. Two stylesheets key on it and would
* otherwise never match in a view: `checklist.css` (the whole custom
* checkbox appearance — without this the view falls back to the native
* browser control) and main.css's `--_blok-list-pad` indirection, which
* resolves the public `--blok-list-padding-start` token the `
`'s
* `ps-[var(--_blok-list-pad,0px)]` reads.
*/
const styleAttr = env.classesEnabled ? ` data-list-style="${env.escape(style)}"` : '';
return { html: `<${tag}${startAttr}${styleAttr}>${run.html}${tag}>` + rest.html, next: rest.next };
};
return buildLevel(0, 0).html;
};
/**
* 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 && !Array.isArray(value);
/**
* Render one table cell's inner HTML: modern cells hold child-block id
* references (rendered recursively); legacy cells hold an HTML string
* (sanitized inline). Mirrors the two shapes `readTableGrid` handles in the
* markdown serializer.
* @param cell - raw cell value from `data.content`
* @param env - emitter environment
*/
const tableCellInner = (cell: unknown, env: EmitterEnv): string => {
if (typeof cell === 'string') {
return env.inline(cell);
}
if (!isRecord(cell)) {
return '';
}
const kids = env.blocksById(cell.blocks);
return kids.length > 0 ? env.renderList(kids) : env.inline(cell.text);
};
/**
* Table emitter: `` when `withHeadings`, `
`;
};
/**
* The built-in tool emitters, keyed by tool name as registered in
* `defaultBlockTools`. `list` never reaches this map — list runs are grouped
* by the dispatcher and rendered via {@link renderListRun}.
*/
export const builtinEmitters: Record = {
paragraph: (block, env) => trail(`
${env.inline(block.data.text)}
`, block, env),
header: (block, env) => {
const level = Math.min(Math.max(Number(block.data.level) || 1, 1), 6);
/**
* Classes go on the HEADING, never on the `` wrapper below — the
* editor styles its h-tag, and `