You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/words/engine/blocks/built-ins.ts

914 lines
27 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* Built-in block specs.
*
* The canonical definitions for the blocks the engine ships. For now each
* spec carries only metadata + insert-menu entries (phase R1); the
* behavioural methods (render / validate / create / serialize) are layered
* on in phases R2–R5, at which point the heavier specs split into their
* own `specs/<type>.ts` modules — the same shape a third-party plugin
* uses.
*
* This module unifies what used to be two divergent hard-coded lists (the
* provider's `DEFAULT_SLASH_COMMANDS` and the gutter's inline `INSERTS`)
* into one source of truth. Importing it registers every built-in into the
* shared `defaultWordsSchema`.
*/
import { defaultWordsSchema } from './registry';
import { WORDS_CHECK_TOGGLE_ATTR, WORDS_NODE_ATTR, WORDS_PATH_ATTR } from '../dom';
import {
WORDS_HEADING_LEVELS,
WORDS_IMAGE_ALIGNS,
WORDS_INTENTS,
WORDS_LIST_KINDS,
WORDS_VERTICAL_ALIGNS,
isWordsIntent
} from '../types';
import type { WordsPath } from '../path';
import type {
WordsBlockRenderContext,
WordsBlockSpec,
WordsBlockValidateContext,
WordsHtmlSerializeContext,
WordsMarkdownSerializeContext
} from './spec';
import type {
CalloutBlock,
CodeBlock,
HeadingBlock,
ImageBlock,
ListBlock,
ListItem,
ParagraphBlock,
QuoteBlock,
TableBlock,
TableCell,
TableRow,
WordsIntent
} from '../types';
import type { WordsRenderElement, WordsRenderNode, WordsRenderTag } from '../render';
// Raw inline content for a fresh, empty text block.
const emptyText = () => [{ type: 'text', text: '' }];
// Callout intent → GFM admonition tag (Markdown export). Inlined here
// rather than imported from serialize-markdown.ts to avoid a value import
// cycle (that module imports this registry). The legacy serializer keeps
// its own copy until the fallback switch is removed.
const CALLOUT_GFM_ADMONITION: Record<WordsIntent, string> = {
neutral: 'NOTE',
affirm: 'TIP',
fulfill: 'IMPORTANT',
risk: 'WARNING',
threat: 'CAUTION',
loss: 'CAUTION'
};
const paragraphSpec: WordsBlockSpec = {
type: 'paragraph',
group: 'text',
core: true,
menu: [
{
id: 'paragraph',
label: 'Text',
description: 'Plain paragraph',
keywords: ['text', 'paragraph', 'p'],
group: 'text',
create: () => ({ type: 'paragraph', children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`);
},
toHtml(block, ctx) {
const b = block as ParagraphBlock;
return `<p${ctx.styleAttr(b)}>${ctx.inlinesToHtml(b.children)}</p>`;
},
toMarkdown(block, ctx) {
const b = block as ParagraphBlock;
return ctx.inlinesToMd(b.children);
},
render(block, ctx) {
const b = block as ParagraphBlock;
return {
kind: 'element',
tag: 'p',
attrs: ctx.composeBlockAttrs(
b,
'paragraph',
ctx.path,
b.align ? { 'data-words-align': b.align } : {}
),
children: ctx.renderInlines(b.children, ctx.path)
};
}
};
const headingSpec: WordsBlockSpec = {
type: 'heading',
group: 'text',
menu: [
{
id: 'heading-1',
label: 'Heading 1',
description: 'Large section heading',
keywords: ['h1', 'title', 'heading'],
group: 'text',
create: () => ({ type: 'heading', level: 1, children: emptyText() })
},
{
id: 'heading-2',
label: 'Heading 2',
description: 'Medium section heading',
keywords: ['h2', 'subtitle', 'heading'],
group: 'text',
create: () => ({ type: 'heading', level: 2, children: emptyText() })
},
{
id: 'heading-3',
label: 'Heading 3',
description: 'Small section heading',
keywords: ['h3', 'heading'],
group: 'text',
create: () => ({ type: 'heading', level: 3, children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`);
if (!(WORDS_HEADING_LEVELS as readonly number[]).includes(block.level as number)) {
ctx.error(
`${ctx.path}/level`,
'invalid-enum-value',
`heading.level must be 1, 2, or 3; got ${JSON.stringify(block.level)}`
);
}
},
toHtml(block, ctx) {
const b = block as HeadingBlock;
return `<h${b.level}${ctx.styleAttr(b)}>${ctx.inlinesToHtml(b.children)}</h${b.level}>`;
},
toMarkdown(block, ctx) {
const b = block as HeadingBlock;
return `${'#'.repeat(b.level)} ${ctx.inlinesToMd(b.children)}`;
},
render(block, ctx) {
const b = block as HeadingBlock;
const tag = (`h${b.level}` as const) satisfies WordsRenderTag;
return {
kind: 'element',
tag,
attrs: ctx.composeBlockAttrs(b, 'heading', ctx.path, {
'data-words-heading-level': String(b.level),
...(b.align ? { 'data-words-align': b.align } : {})
}),
children: ctx.renderInlines(b.children, ctx.path)
};
}
};
const quoteSpec: WordsBlockSpec = {
type: 'quote',
group: 'text',
menu: [
{
id: 'quote',
label: 'Quote',
description: 'Blockquote callout',
keywords: ['blockquote', 'cite', 'quote'],
group: 'text',
create: () => ({ type: 'quote', children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`);
if (block.cite !== undefined && (typeof block.cite !== 'string' || !ctx.isParseableUrl(block.cite))) {
ctx.error(
`${ctx.path}/cite`,
'invalid-href',
`quote.cite must be a parseable URL string; got ${JSON.stringify(block.cite)}`
);
}
},
toHtml(block, ctx) {
const b = block as QuoteBlock;
const cite = b.cite !== undefined ? ` cite="${ctx.escapeAttr(b.cite)}"` : '';
return `<blockquote${ctx.styleAttr(b)}${cite}>${ctx.inlinesToHtml(b.children)}</blockquote>`;
},
toMarkdown(block, ctx) {
const b = block as QuoteBlock;
return ctx
.inlinesToMd(b.children)
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
},
render(block, ctx) {
const b = block as QuoteBlock;
return {
kind: 'element',
tag: 'blockquote',
attrs: ctx.composeBlockAttrs(b, 'quote', ctx.path, {
...(b.align ? { 'data-words-align': b.align } : {}),
...(b.cite ? { cite: b.cite } : {})
}),
children: ctx.renderInlines(b.children, ctx.path)
};
}
};
const codeSpec: WordsBlockSpec = {
type: 'code',
group: 'text',
menu: [
{
id: 'code-block',
label: 'Code block',
description: 'Preformatted code',
keywords: ['code', 'pre', 'snippet'],
group: 'text',
create: () => ({ type: 'code', children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateTextOnlyChildren(block.children, `${ctx.path}/children`);
if (block.language !== undefined && typeof block.language !== 'string') {
ctx.error(
`${ctx.path}/language`,
'invalid-block-shape',
`code.language must be a string if present`
);
}
},
toHtml(block, ctx) {
const b = block as CodeBlock;
const code = b.children.map((t) => ctx.escapeText(t.text)).join('');
const lang = b.language ? ` class="language-${ctx.escapeAttr(b.language)}"` : '';
return `<pre${ctx.styleAttr(b)}><code${lang}>${code}</code></pre>`;
},
toMarkdown(block) {
const b = block as CodeBlock;
const code = b.children.map((t) => t.text).join('');
const lang = b.language ?? '';
return `\`\`\`${lang}\n${code}\n\`\`\``;
},
render(block, ctx) {
const b = block as CodeBlock;
const innerCode: WordsRenderElement = {
kind: 'element',
tag: 'code',
attrs: b.language ? { class: `language-${b.language}` } : {},
children: b.children.map((text, i) => ctx.renderText(text, [...ctx.path, i], b.language))
};
return {
kind: 'element',
tag: 'pre',
attrs: ctx.composeBlockAttrs(
b,
'code',
ctx.path,
b.language ? { 'data-words-code-language': b.language } : {}
),
children: [innerCode]
};
}
};
const listSpec: WordsBlockSpec = {
type: 'list',
group: 'list',
menu: [
{
id: 'unordered-list',
label: 'Bulleted list',
description: 'List with bullets',
keywords: ['ul', 'bullet', 'list'],
group: 'list',
create: () => ({ type: 'list', kind: 'unordered', items: [{ children: emptyText() }] })
},
{
id: 'ordered-list',
label: 'Numbered list',
description: 'List with numbers',
keywords: ['ol', 'number', 'list'],
group: 'list',
create: () => ({ type: 'list', kind: 'ordered', items: [{ children: emptyText() }] })
},
{
id: 'check-list',
label: 'Check list',
description: 'Track tasks inline',
keywords: ['todo', 'task', 'check'],
group: 'list',
create: () => ({
type: 'list',
kind: 'check',
items: [{ checked: false, children: emptyText() }]
})
}
],
validate(block, ctx) {
ctx.validateOptionalEnum(block.kind, WORDS_LIST_KINDS, `${ctx.path}/kind`, true);
if (!Array.isArray(block.items)) {
ctx.error(`${ctx.path}/items`, 'invalid-children', 'list.items must be an array');
} else {
for (let i = 0; i < block.items.length; i++) {
validateListItemFields(block.items[i], `${ctx.path}/items/${i}`, ctx);
}
}
},
toHtml(block, ctx) {
const b = block as ListBlock;
const tag = b.kind === 'ordered' ? 'ol' : 'ul';
const items = b.items.map((item) => listItemToHtml(item, b.kind, ctx)).join('');
return `<${tag}${ctx.styleAttr(b)}>${items}</${tag}>`;
},
toMarkdown(block, ctx) {
const b = block as ListBlock;
const lines: string[] = [];
for (let i = 0; i < b.items.length; i++) {
lines.push(listItemToMd(b.items[i], i, b.kind, ctx.depth, ctx));
}
return lines.join('\n');
},
render(block, ctx) {
const b = block as ListBlock;
const tag = (b.kind === 'ordered' ? 'ol' : 'ul') satisfies WordsRenderTag;
return {
kind: 'element',
tag,
attrs: ctx.composeBlockAttrs(b, 'list', ctx.path, { 'data-words-list-kind': b.kind }),
children: b.items.map((item, i) => renderListItem(item, [...ctx.path, i], b.kind, ctx))
};
}
};
const tableSpec: WordsBlockSpec = {
type: 'table',
group: 'structure',
menu: [
{
id: 'table',
label: 'Table',
description: '2 by 2 editable grid',
keywords: ['grid', 'cells', 'rows', 'columns'],
group: 'structure',
create: () => ({
type: 'table',
rows: [
{ cells: [{ children: emptyText() }, { children: emptyText() }] },
{ cells: [{ children: emptyText() }, { children: emptyText() }] }
]
})
}
],
validate(block, ctx) {
if (!Array.isArray(block.rows)) {
ctx.error(`${ctx.path}/rows`, 'invalid-children', 'table.rows must be an array');
} else {
for (let i = 0; i < block.rows.length; i++) {
validateTableRowFields(block.rows[i], `${ctx.path}/rows/${i}`, ctx);
}
}
},
toHtml(block, ctx) {
const b = block as TableBlock;
const hasHeaderRow = b.headerRow === true && b.rows.length > 0;
const headerCol = b.headerCol === true;
let thead = '';
let bodyRows: readonly TableRow[] = b.rows;
if (hasHeaderRow) {
const cells = b.rows[0].cells.map((cell) => cellToHtml(cell, true, ctx)).join('');
thead = `<thead><tr>${cells}</tr></thead>`;
bodyRows = b.rows.slice(1);
}
const body = bodyRows
.map((row) => {
const cells = row.cells.map((cell, i) => cellToHtml(cell, headerCol && i === 0, ctx)).join('');
return `<tr>${cells}</tr>`;
})
.join('');
return `<table${ctx.styleAttr(b)}>${thead}<tbody>${body}</tbody></table>`;
},
toMarkdown(block, ctx) {
const b = block as TableBlock;
if (b.rows.length === 0) return '';
const colCount = b.rows[0]?.cells.length ?? 0;
if (colCount === 0) return '';
const rowToMd = (row: { cells: readonly TableCell[] }): string => {
const cells = row.cells.map((c) => cellTextToMd(c, ctx));
while (cells.length < colCount) cells.push('');
return `| ${cells.join(' | ')} |`;
};
const lines: string[] = [];
const headerRow = b.headerRow === true ? b.rows[0] : undefined;
if (headerRow) {
lines.push(rowToMd(headerRow));
} else {
lines.push(`| ${Array.from({ length: colCount }, () => ' ').join(' | ')} |`);
}
const alignmentMarkers: string[] = [];
for (let i = 0; i < colCount; i++) {
const firstAlign = b.rows[0]?.cells[i]?.align;
alignmentMarkers.push(
firstAlign === 'center'
? ':---:'
: firstAlign === 'right'
? '---:'
: firstAlign === 'left'
? ':---'
: '---'
);
}
lines.push(`| ${alignmentMarkers.join(' | ')} |`);
const bodyRows = headerRow ? b.rows.slice(1) : b.rows;
for (const row of bodyRows) lines.push(rowToMd(row));
return lines.join('\n');
},
render(block, ctx) {
const b = block as TableBlock;
return {
kind: 'element',
tag: 'table',
attrs: ctx.composeBlockAttrs(b, 'table', ctx.path, {
...(b.headerRow ? { 'data-words-table-header-row': 'true' } : {}),
...(b.headerCol ? { 'data-words-table-header-col': 'true' } : {})
}),
children: [
{
kind: 'element',
tag: 'tbody',
attrs: {},
children: b.rows.map((row, i) =>
renderTableRow(
row,
[...ctx.path, i],
b.headerRow === true,
b.headerCol === true,
i === 0,
ctx
)
)
}
]
};
}
};
const imageSpec: WordsBlockSpec = {
type: 'image',
group: 'media',
menu: [
{
id: 'image',
label: 'Image',
description: 'Insert an image from a URL or upload',
keywords: ['img', 'picture', 'photo', 'figure'],
group: 'media',
// Not directly insertable: needs a URL / upload flow before the
// block is valid. R6 routes this entry through that flow.
insertable: false,
create: () => ({ type: 'image', src: '' })
}
],
validate(block, ctx) {
if (typeof block.src !== 'string' || block.src.length === 0) {
ctx.error(`${ctx.path}/src`, 'invalid-block-shape', 'image.src must be a non-empty string');
}
ctx.validateOptionalNumber(block.width, `${ctx.path}/width`);
ctx.validateOptionalNumber(block.height, `${ctx.path}/height`);
ctx.validateOptionalEnum(block.imageAlign, WORDS_IMAGE_ALIGNS, `${ctx.path}/imageAlign`);
},
toHtml(block, ctx) {
const b = block as ImageBlock;
let attrs = ` src="${ctx.escapeAttr(b.src)}"`;
if (b.alt !== undefined) attrs += ` alt="${ctx.escapeAttr(b.alt)}"`;
if (b.width !== undefined) attrs += ` width="${b.width}"`;
if (b.height !== undefined) attrs += ` height="${b.height}"`;
const img = `<img${attrs}${ctx.styleAttr(b)} />`;
if (b.caption !== undefined && b.caption !== '') {
return `<figure>${img}<figcaption>${ctx.escapeText(b.caption)}</figcaption></figure>`;
}
return img;
},
toMarkdown(block) {
const b = block as ImageBlock;
const alt = b.alt ?? '';
const title = b.caption ? ` "${b.caption.replace(/"/g, '\\"')}"` : '';
return `![${alt}](${b.src}${title})`;
},
render(block, ctx) {
const b = block as ImageBlock;
const figureChildren: WordsRenderNode[] = [
{
kind: 'element',
tag: 'img',
attrs: {
src: b.src,
alt: b.alt ?? '',
...(b.width !== undefined ? { width: String(b.width) } : {}),
...(b.height !== undefined ? { height: String(b.height) } : {}),
loading: 'lazy',
draggable: 'false'
},
children: []
}
];
if (b.caption) {
figureChildren.push({
kind: 'element',
tag: 'figcaption',
attrs: {},
children: [{ kind: 'text', text: b.caption }]
});
}
const isSelected =
ctx.decorations?.selectedBlockIndex !== undefined &&
ctx.path.length === 1 &&
ctx.path[0] === ctx.decorations.selectedBlockIndex;
const status = b.id !== undefined ? ctx.decorations?.imageStatus?.get(b.id) : undefined;
return {
kind: 'element',
tag: 'figure',
attrs: ctx.composeBlockAttrs(b, 'image', ctx.path, {
// Faithful to the pre-registry renderer: it reads the BASE `align`
// (not `imageAlign`) for the float attribute. The model carries
// `imageAlign`; reconciling the two is tracked as a follow-up.
...(b.align && b.align !== 'center' ? { 'data-words-image-align': b.align } : {}),
...(status ? { 'data-words-image-status': status } : {}),
...(isSelected ? { 'data-words-block-selected': '' } : {}),
contenteditable: 'false'
}),
children: figureChildren
};
}
};
const dividerSpec: WordsBlockSpec = {
type: 'divider',
group: 'structure',
menu: [
{
id: 'divider',
label: 'Divider',
description: 'Horizontal rule',
keywords: ['hr', 'rule', 'separator', 'divider'],
group: 'structure',
create: () => ({ type: 'divider' })
}
],
validate() {
// Divider has no type-specific content to validate.
},
toHtml(block, ctx) {
return `<hr${ctx.styleAttr(block)} />`;
},
toMarkdown() {
return '---';
},
render(block, ctx) {
const isSelected =
ctx.decorations?.selectedBlockIndex !== undefined &&
ctx.path.length === 1 &&
ctx.path[0] === ctx.decorations.selectedBlockIndex;
return {
kind: 'element',
tag: 'hr',
attrs: ctx.composeBlockAttrs(block, 'divider', ctx.path, {
...(isSelected ? { 'data-words-block-selected': '' } : {}),
contenteditable: 'false'
}),
children: []
};
}
};
const calloutSpec: WordsBlockSpec = {
type: 'callout',
group: 'structure',
menu: [
{
id: 'callout',
label: 'Callout',
description: 'Highlighted note',
keywords: ['note', 'admonition', 'callout', 'aside'],
group: 'structure',
create: () => ({
type: 'callout',
intent: 'neutral',
children: [{ type: 'paragraph', children: emptyText() }]
})
}
],
validate(block, ctx) {
if (!isWordsIntent(block.intent)) {
ctx.error(
`${ctx.path}/intent`,
'invalid-eval-intent',
`callout.intent must be one of ${WORDS_INTENTS.join(' | ')}; got ${JSON.stringify(block.intent)}`
);
}
if (block.title !== undefined && typeof block.title !== 'string') {
ctx.error(`${ctx.path}/title`, 'invalid-block-shape', 'callout.title must be a string if present');
}
if (!Array.isArray(block.children)) {
ctx.error(
`${ctx.path}/children`,
'invalid-children',
'callout.children must be an array of blocks'
);
} else {
for (let i = 0; i < block.children.length; i++) {
ctx.validateBlock(block.children[i], `${ctx.path}/children/${i}`);
}
}
},
toHtml(block, ctx) {
const b = block as CalloutBlock;
const title = b.title
? `<strong class="callout-title">${ctx.escapeText(b.title)}</strong>`
: '';
const inner = b.children.map((child) => ctx.blockToHtml(child)).join('');
return `<aside class="callout callout-${b.intent}"${ctx.styleAttr(b)}>${title}${inner}</aside>`;
},
toMarkdown(block, ctx) {
const b = block as CalloutBlock;
const tag = CALLOUT_GFM_ADMONITION[b.intent];
const lines: string[] = [`> [!${tag}]`];
if (b.title) lines.push(`> **${b.title}**`);
for (const child of b.children) {
const childMd = ctx.blockToMd(child, ctx.depth + 1);
for (const line of childMd.split('\n')) lines.push(`> ${line}`);
}
return lines.join('\n');
},
render(block, ctx) {
const b = block as CalloutBlock;
const children: WordsRenderNode[] = [];
if (b.title) {
children.push({
kind: 'element',
tag: 'div',
attrs: { 'data-words-callout-title': '' },
children: [{ kind: 'text', text: b.title }]
});
}
for (let i = 0; i < b.children.length; i++) {
children.push(ctx.renderBlock(b.children[i], [...ctx.path, i]));
}
return {
kind: 'element',
tag: 'div',
attrs: ctx.composeBlockAttrs(b, 'callout', ctx.path, {
'data-words-callout-intent': b.intent,
role: 'note'
}),
children
};
}
};
// ── List / table sub-structure helpers ────────────────────────────────────
// Build the `li` / `tr` / `td` nodes the list + table specs emit. They take
// the render context (for inline rendering + path encoding) so they stay
// pure, mirroring the pre-registry private renderers byte-for-byte.
function renderListItem(
item: ListItem,
path: WordsPath,
listKind: ListBlock['kind'],
ctx: WordsBlockRenderContext
): WordsRenderElement {
const checkToggle: WordsRenderNode[] =
listKind === 'check'
? [
{
kind: 'element',
tag: 'span',
attrs: {
[WORDS_CHECK_TOGGLE_ATTR]: '',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
'data-words-checked': item.checked ? 'true' : 'false',
role: 'checkbox',
'aria-checked': item.checked ? 'true' : 'false',
contenteditable: 'false',
tabindex: '-1'
},
children: []
}
]
: [];
return {
kind: 'element',
tag: 'li',
attrs: {
[WORDS_NODE_ATTR]: 'list-item',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
...(item.id ? { 'data-words-id': item.id } : {}),
...(item.indent ? { 'data-words-indent': String(item.indent) } : {}),
...(listKind === 'check' ? { 'data-words-checked': item.checked ? 'true' : 'false' } : {})
},
children: [...checkToggle, ...ctx.renderInlines(item.children, path)]
};
}
function renderTableRow(
row: TableRow,
path: WordsPath,
headerRow: boolean,
headerCol: boolean,
isFirstRow: boolean,
ctx: WordsBlockRenderContext
): WordsRenderElement {
const rowStyle = ctx.stringifyStyle(ctx.blockStyle(row));
return {
kind: 'element',
tag: 'tr',
attrs: {
[WORDS_NODE_ATTR]: 'table-row',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
...(row.id ? { 'data-words-id': row.id } : {}),
...(rowStyle ? { style: rowStyle } : {})
},
children: row.cells.map((cell, i) =>
renderTableCell(cell, [...path, i], headerRow && isFirstRow, headerCol && i === 0, ctx)
)
};
}
function renderTableCell(
cell: TableCell,
path: WordsPath,
asHeader: boolean,
asHeaderCol: boolean,
ctx: WordsBlockRenderContext
): WordsRenderElement {
const isHeader = asHeader || asHeaderCol;
const tag = (isHeader ? 'th' : 'td') satisfies WordsRenderTag;
const cellStyle = ctx.stringifyStyle(ctx.blockStyle(cell));
return {
kind: 'element',
tag,
attrs: {
[WORDS_NODE_ATTR]: 'table-cell',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
...(cell.id ? { 'data-words-id': cell.id } : {}),
...(cell.align ? { 'data-words-cell-align': cell.align } : {}),
...(cell.verticalAlign ? { 'data-words-cell-vertical': cell.verticalAlign } : {}),
...(cell.colspan ? { colspan: String(cell.colspan) } : {}),
...(cell.rowspan ? { rowspan: String(cell.rowspan) } : {}),
...(cellStyle ? { style: cellStyle } : {})
},
children: ctx.renderInlines(cell.children, path)
};
}
// ── List / table sub-structure validators ─────────────────────────────────
// Validate the raw `item` / `row` / `cell` shapes the list + table specs
// own. Mirror the pre-registry private validators; read fields defensively
// off the untrusted `unknown` input.
function validateListItemFields(
item: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(item)) {
ctx.error(path, 'invalid-block-shape', 'list item must be an object');
return;
}
ctx.validateId(item.id, `${path}/id`);
ctx.validateInlineChildren(item.children, `${path}/children`);
if (item.checked !== undefined && typeof item.checked !== 'boolean') {
ctx.error(`${path}/checked`, 'invalid-block-shape', 'list-item.checked must be boolean if present');
}
if (item.indent !== undefined) {
const n = item.indent as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 8) {
ctx.error(
`${path}/indent`,
'invalid-number',
`list-item.indent must be an integer 0..8; got ${JSON.stringify(item.indent)}`
);
}
}
}
function validateTableRowFields(
row: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(row)) {
ctx.error(path, 'invalid-block-shape', 'table row must be an object');
return;
}
ctx.validateId(row.id, `${path}/id`);
if (!Array.isArray(row.cells)) {
ctx.error(`${path}/cells`, 'invalid-children', 'table-row.cells must be an array');
return;
}
for (let i = 0; i < row.cells.length; i++) {
validateTableCellFields(row.cells[i], `${path}/cells/${i}`, ctx);
}
ctx.validateStyle(row, path);
}
function validateTableCellFields(
cell: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(cell)) {
ctx.error(path, 'invalid-block-shape', 'cell must be an object');
return;
}
ctx.validateId(cell.id, `${path}/id`);
ctx.validateStyle(cell, path);
ctx.validateInlineChildren(cell.children, `${path}/children`);
ctx.validateOptionalEnum(cell.verticalAlign, WORDS_VERTICAL_ALIGNS, `${path}/verticalAlign`);
if (cell.colspan !== undefined) {
const n = cell.colspan as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
ctx.error(`${path}/colspan`, 'invalid-number', 'cell.colspan must be an integer >= 1');
}
}
if (cell.rowspan !== undefined) {
const n = cell.rowspan as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
ctx.error(`${path}/rowspan`, 'invalid-number', 'cell.rowspan must be an integer >= 1');
}
}
}
// ── List / table HTML sub-serializers ─────────────────────────────────────
function listItemToHtml(
item: ListItem,
kind: ListBlock['kind'],
ctx: WordsHtmlSerializeContext
): string {
const indent = item.indent ?? 0;
const style = indent > 0 ? ` style="margin-left: ${indent * 1.5}em"` : '';
const inner = ctx.inlinesToHtml(item.children);
if (kind === 'check') {
const checked = item.checked ? ' checked' : '';
return `<li${style}><input type="checkbox"${checked} disabled>${inner}</li>`;
}
return `<li${style}>${inner}</li>`;
}
function cellToHtml(cell: TableCell, isHeader: boolean, ctx: WordsHtmlSerializeContext): string {
const tag = isHeader ? 'th' : 'td';
const style: Record<string, string> = { ...ctx.blockStyle(cell) };
if (cell.verticalAlign !== undefined) style['vertical-align'] = cell.verticalAlign;
let attrs = '';
if (cell.colspan !== undefined && cell.colspan > 1) attrs += ` colspan="${cell.colspan}"`;
if (cell.rowspan !== undefined && cell.rowspan > 1) attrs += ` rowspan="${cell.rowspan}"`;
const styled = ctx.stringifyStyle(style);
if (styled !== undefined) attrs += ` style="${ctx.escapeAttr(styled)}"`;
return `<${tag}${attrs}>${ctx.inlinesToHtml(cell.children)}</${tag}>`;
}
// ── List / table Markdown sub-serializers ─────────────────────────────────
function listItemToMd(
item: ListItem,
index: number,
kind: ListBlock['kind'],
depth: number,
ctx: WordsMarkdownSerializeContext
): string {
const indent = ' '.repeat(depth + (item.indent ?? 0));
const marker =
kind === 'ordered'
? `${index + 1}.`
: kind === 'check'
? item.checked
? '- [x]'
: '- [ ]'
: '-';
const content = ctx.inlinesToMd(item.children);
return `${indent}${marker} ${content}`;
}
function cellTextToMd(cell: TableCell, ctx: WordsMarkdownSerializeContext): string {
return ctx.inlinesToMd(cell.children).replace(/\|/g, '\\|');
}
/** Every built-in spec, in document/menu order. */
export const BUILT_IN_BLOCK_SPECS: readonly WordsBlockSpec[] = [
paragraphSpec,
headingSpec,
quoteSpec,
codeSpec,
listSpec,
tableSpec,
imageSpec,
dividerSpec,
calloutSpec
];
// Register the built-ins into the shared schema (side effect of import).
for (const spec of BUILT_IN_BLOCK_SPECS) defaultWordsSchema.register(spec);

Powered by TurnKey Linux.