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