/** * 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 `

`; const children = childrenOnly(block, env); const body = env.classesEnabled ? `${children}` : children; return `${summary}${body}
`; }, image: (block, env) => { const img = ``; return trail(`
${img}${figcaption(block, env)}
`, block, env); }, video: (block, env) => { const controls = block.data.hideControls === true ? '' : ' controls'; const autoplay = block.data.autoplay === true ? ' autoplay' : ''; const loop = block.data.loop === true ? ' loop' : ''; const video = ``; return trail(`
${video}${figcaption(block, env)}
`, block, env); }, audio: (block, env) => { const audio = ``; return trail(`
${audio}${figcaption(block, env, ['title'])}
`, block, env); }, file: (block, env) => { const label = str(block.data, 'fileName') || str(block.data, 'url'); return trail(`${env.escape(label)}
`, block, env); }, bookmark: (block, env) => { const label = str(block.data, 'title') || str(block.data, 'url'); return trail(`${env.escape(label)}`, block, env); }, embed: (block, env) => { const embedUrl = str(block.data, 'embed'); /** Only https embed targets reach an iframe src (matches the live tool's toSafeEmbedSrc gate). */ if (/^https:\/\//i.test(embedUrl)) { return trail(`
${figcaption(block, env)}
`, block, env); } const source = str(block.data, 'source'); const label = str(block.data, 'service') || source; return trail(`${env.escape(label)}`, block, env); }, table: emitTable, spacer: (block, env) => trail('', block, env), column_list: childrenDiv, columns: childrenDiv, column: childrenDiv, database: childrenOnly, 'database-row': childrenOnly, };