@ -24,6 +24,7 @@ import {
WORDS_VERTICAL_ALIGNS ,
WORDS_VERTICAL_ALIGNS ,
isWordsIntent
isWordsIntent
} from '../types' ;
} from '../types' ;
import type { WordsBlock } from '../types' ;
import type { WordsPath } from '../path' ;
import type { WordsPath } from '../path' ;
import type {
import type {
WordsBlockRenderContext ,
WordsBlockRenderContext ,
@ -156,7 +157,7 @@ const headingSpec: WordsBlockSpec = {
} ,
} ,
render ( block , ctx ) {
render ( block , ctx ) {
const b = block as HeadingBlock ;
const b = block as HeadingBlock ;
const tag = ( ` h ${ b . level } ` as const ) satisfies WordsRenderTag ;
const tag = ` h ${ b . level } ` as const satisfies WordsRenderTag ;
return {
return {
kind : 'element' ,
kind : 'element' ,
tag ,
tag ,
@ -184,7 +185,10 @@ const quoteSpec: WordsBlockSpec = {
] ,
] ,
validate ( block , ctx ) {
validate ( block , ctx ) {
ctx . validateInlineChildren ( block . children , ` ${ ctx . path } /children ` ) ;
ctx . validateInlineChildren ( block . children , ` ${ ctx . path } /children ` ) ;
if ( block . cite !== undefined && ( typeof block . cite !== 'string' || ! ctx . isParseableUrl ( block . cite ) ) ) {
if (
block . cite !== undefined &&
( typeof block . cite !== 'string' || ! ctx . isParseableUrl ( block . cite ) )
) {
ctx . error (
ctx . error (
` ${ ctx . path } /cite ` ,
` ${ ctx . path } /cite ` ,
'invalid-href' ,
'invalid-href' ,
@ -386,7 +390,9 @@ const tableSpec: WordsBlockSpec = {
}
}
const body = bodyRows
const body = bodyRows
. map ( ( row ) = > {
. map ( ( row ) = > {
const cells = row . cells . map ( ( cell , i ) = > cellToHtml ( cell , headerCol && i === 0 , ctx ) ) . join ( '' ) ;
const cells = row . cells
. map ( ( cell , i ) = > cellToHtml ( cell , headerCol && i === 0 , ctx ) )
. join ( '' ) ;
return ` <tr> ${ cells } </tr> ` ;
return ` <tr> ${ cells } </tr> ` ;
} )
} )
. join ( '' ) ;
. join ( '' ) ;
@ -559,9 +565,7 @@ const imageSpec: WordsBlockSpec = {
kind : 'element' ,
kind : 'element' ,
tag : 'span' ,
tag : 'span' ,
attrs : { 'data-words-image-placeholder-hint' : '' } ,
attrs : { 'data-words-image-placeholder-hint' : '' } ,
children : [
children : [ { kind : 'text' , text : 'Open the inspector and paste a URL.' } ]
{ kind : 'text' , text : 'Open the inspector and paste a URL.' }
]
}
}
]
]
}
}
@ -687,7 +691,11 @@ const calloutSpec: WordsBlockSpec = {
) ;
) ;
}
}
if ( block . title !== undefined && typeof block . title !== 'string' ) {
if ( block . title !== undefined && typeof block . title !== 'string' ) {
ctx . error ( ` ${ ctx . path } /title ` , 'invalid-block-shape' , 'callout.title must be a string if present' ) ;
ctx . error (
` ${ ctx . path } /title ` ,
'invalid-block-shape' ,
'callout.title must be a string if present'
) ;
}
}
if ( ! Array . isArray ( block . children ) ) {
if ( ! Array . isArray ( block . children ) ) {
ctx . error (
ctx . error (
@ -842,7 +850,8 @@ function renderTableCell(
. . . ( cell . rowspan ? { rowspan : String ( cell . rowspan ) } : { } ) ,
. . . ( cell . rowspan ? { rowspan : String ( cell . rowspan ) } : { } ) ,
. . . ( cellStyle ? { style : cellStyle } : { } )
. . . ( cellStyle ? { style : cellStyle } : { } )
} ,
} ,
children : ctx.renderInlines ( cell . children , path )
// Cells hold blocks (like callout / column): render each child block.
children : cell.children.map ( ( child , i ) = > ctx . renderBlock ( child , [ . . . path , i ] ) )
} ;
} ;
}
}
@ -851,11 +860,7 @@ function renderTableCell(
// own. Mirror the pre-registry private validators; read fields defensively
// own. Mirror the pre-registry private validators; read fields defensively
// off the untrusted `unknown` input.
// off the untrusted `unknown` input.
function validateListItemFields (
function validateListItemFields ( item : unknown , path : string , ctx : WordsBlockValidateContext ) : void {
item : unknown ,
path : string ,
ctx : WordsBlockValidateContext
) : void {
if ( ! ctx . isPlainObject ( item ) ) {
if ( ! ctx . isPlainObject ( item ) ) {
ctx . error ( path , 'invalid-block-shape' , 'list item must be an object' ) ;
ctx . error ( path , 'invalid-block-shape' , 'list item must be an object' ) ;
return ;
return ;
@ -863,7 +868,11 @@ function validateListItemFields(
ctx . validateId ( item . id , ` ${ path } /id ` ) ;
ctx . validateId ( item . id , ` ${ path } /id ` ) ;
ctx . validateInlineChildren ( item . children , ` ${ path } /children ` ) ;
ctx . validateInlineChildren ( item . children , ` ${ path } /children ` ) ;
if ( item . checked !== undefined && typeof item . checked !== 'boolean' ) {
if ( item . checked !== undefined && typeof item . checked !== 'boolean' ) {
ctx . error ( ` ${ path } /checked ` , 'invalid-block-shape' , 'list-item.checked must be boolean if present' ) ;
ctx . error (
` ${ path } /checked ` ,
'invalid-block-shape' ,
'list-item.checked must be boolean if present'
) ;
}
}
if ( item . indent !== undefined ) {
if ( item . indent !== undefined ) {
const n = item . indent as number ;
const n = item . indent as number ;
@ -877,11 +886,7 @@ function validateListItemFields(
}
}
}
}
function validateTableRowFields (
function validateTableRowFields ( row : unknown , path : string , ctx : WordsBlockValidateContext ) : void {
row : unknown ,
path : string ,
ctx : WordsBlockValidateContext
) : void {
if ( ! ctx . isPlainObject ( row ) ) {
if ( ! ctx . isPlainObject ( row ) ) {
ctx . error ( path , 'invalid-block-shape' , 'table row must be an object' ) ;
ctx . error ( path , 'invalid-block-shape' , 'table row must be an object' ) ;
return ;
return ;
@ -915,7 +920,14 @@ function validateTableCellFields(
}
}
ctx . validateId ( cell . id , ` ${ path } /id ` ) ;
ctx . validateId ( cell . id , ` ${ path } /id ` ) ;
ctx . validateStyle ( cell , path ) ;
ctx . validateStyle ( cell , path ) ;
ctx . validateInlineChildren ( cell . children , ` ${ path } /children ` ) ;
// Cells hold blocks (like callout / column): validate each child block.
if ( ! Array . isArray ( cell . children ) ) {
ctx . error ( ` ${ path } /children ` , 'invalid-children' , 'cell.children must be an array of blocks' ) ;
} else {
for ( let i = 0 ; i < cell . children . length ; i ++ ) {
ctx . validateBlock ( cell . children [ i ] , ` ${ path } /children/ ${ i } ` ) ;
}
}
ctx . validateOptionalEnum ( cell . verticalAlign , WORDS_VERTICAL_ALIGNS , ` ${ path } /verticalAlign ` ) ;
ctx . validateOptionalEnum ( cell . verticalAlign , WORDS_VERTICAL_ALIGNS , ` ${ path } /verticalAlign ` ) ;
if ( cell . colspan !== undefined ) {
if ( cell . colspan !== undefined ) {
const n = cell . colspan as number ;
const n = cell . colspan as number ;
@ -964,7 +976,9 @@ function cellToHtml(cell: TableCell, isHeader: boolean, ctx: WordsHtmlSerializeC
if ( cell . rowspan !== undefined && cell . rowspan > 1 ) attrs += ` rowspan=" ${ cell . rowspan } " ` ;
if ( cell . rowspan !== undefined && cell . rowspan > 1 ) attrs += ` rowspan=" ${ cell . rowspan } " ` ;
const styled = ctx . stringifyStyle ( style ) ;
const styled = ctx . stringifyStyle ( style ) ;
if ( styled !== undefined ) attrs += ` style=" ${ ctx . escapeAttr ( styled ) } " ` ;
if ( styled !== undefined ) attrs += ` style=" ${ ctx . escapeAttr ( styled ) } " ` ;
return ` < ${ tag } ${ attrs } > ${ ctx . inlinesToHtml ( cell . children ) } </ ${ tag } > ` ;
// Cells hold blocks (like callout): serialize each child block.
const inner = cell . children . map ( ( child ) = > ctx . blockToHtml ( child ) ) . join ( '' ) ;
return ` < ${ tag } ${ attrs } > ${ inner } </ ${ tag } > ` ;
}
}
// ── List / table Markdown sub-serializers ─────────────────────────────────
// ── List / table Markdown sub-serializers ─────────────────────────────────
@ -990,7 +1004,49 @@ function listItemToMd(
}
}
function cellTextToMd ( cell : TableCell , ctx : WordsMarkdownSerializeContext ) : string {
function cellTextToMd ( cell : TableCell , ctx : WordsMarkdownSerializeContext ) : string {
return ctx . inlinesToMd ( cell . children ) . replace ( /\|/g , '\\|' ) ;
// GFM table cells are inline-only: flatten each child block to inline
// markdown and join with <br> (the GFM idiom for multi-line cells).
// Lossy by design — block structure (lists, nested tables) collapses to
// inline text, consistent with markdown being an export-only format.
const flat = cell . children
. map ( ( child ) = > blockToInlineMd ( child , ctx ) )
. filter ( ( s ) = > s . length > 0 )
. join ( '<br>' ) ;
return flat . replace ( /\|/g , '\\|' ) ;
}
/ * *
* Flatten ONE block to inline markdown for a GFM table cell ( which cannot
* hold block content ) . Recurses through nestable containers . Lossy .
* /
function blockToInlineMd ( block : WordsBlock , ctx : WordsMarkdownSerializeContext ) : string {
switch ( block . type ) {
case 'paragraph' :
case 'heading' :
case 'quote' :
return ctx . inlinesToMd ( block . children ) ;
case 'code' :
return '`' + block . children . map ( ( t ) = > t . text ) . join ( ' ' ) + '`' ;
case 'list' :
return block . items . map ( ( it ) = > ctx . inlinesToMd ( it . children ) ) . join ( '<br>' ) ;
case 'callout' :
return block . children . map ( ( c ) = > blockToInlineMd ( c , ctx ) ) . join ( '<br>' ) ;
case 'columns' :
return block . columns
. flatMap ( ( col ) = > col . children . map ( ( c ) = > blockToInlineMd ( c , ctx ) ) )
. join ( '<br>' ) ;
case 'table' :
return block . rows
. flatMap ( ( r ) = >
r . cells . map ( ( c ) = > c . children . map ( ( cc ) = > blockToInlineMd ( cc , ctx ) ) . join ( ' ' ) )
)
. join ( '<br>' ) ;
case 'image' :
return block . alt ? ? '' ;
default :
// divider + any plugin block: no meaningful inline text.
return '' ;
}
}
}
const columnsSpec : WordsBlockSpec = {
const columnsSpec : WordsBlockSpec = {
@ -1113,11 +1169,7 @@ const columnsSpec: WordsBlockSpec = {
}
}
} ;
} ;
function validateColumnFields (
function validateColumnFields ( col : unknown , path : string , ctx : WordsBlockValidateContext ) : void {
col : unknown ,
path : string ,
ctx : WordsBlockValidateContext
) : void {
if ( ! ctx . isPlainObject ( col ) ) {
if ( ! ctx . isPlainObject ( col ) ) {
ctx . error ( path , 'invalid-block-shape' , 'column must be an object' ) ;
ctx . error ( path , 'invalid-block-shape' , 'column must be an object' ) ;
return ;
return ;
@ -1125,11 +1177,7 @@ function validateColumnFields(
ctx . validateId ( col . id , ` ${ path } /id ` ) ;
ctx . validateId ( col . id , ` ${ path } /id ` ) ;
ctx . validateStyle ( col , path ) ;
ctx . validateStyle ( col , path ) ;
if ( ! Array . isArray ( col . children ) ) {
if ( ! Array . isArray ( col . children ) ) {
ctx . error (
ctx . error ( ` ${ path } /children ` , 'invalid-children' , 'column.children must be an array of blocks' ) ;
` ${ path } /children ` ,
'invalid-children' ,
'column.children must be an array of blocks'
) ;
return ;
return ;
}
}
for ( let i = 0 ; i < col . children . length ; i ++ ) {
for ( let i = 0 ; i < col . children . length ; i ++ ) {