diff --git a/src/uix/soma/components/words/engine/serialize-html.ts b/src/uix/soma/components/words/engine/serialize-html.ts index 33f9f5d12..98ebfa924 100644 --- a/src/uix/soma/components/words/engine/serialize-html.ts +++ b/src/uix/soma/components/words/engine/serialize-html.ts @@ -8,12 +8,7 @@ import { createListItem, createParagraph, createQuote, - createTable, - createTableCell, - createTableRow, createText, - isWordsTableCellTone, - isWordsTableCellVerticalAlign, normalizeMarks, normalizeWordsCodeLanguage, type WordsBlock, @@ -21,13 +16,13 @@ import { type WordsInline, type WordsListItem, type WordsMark, - type WordsTableCell, - type WordsTableCellTone, - type WordsTableCellVerticalAlign, - type WordsTableRow, type WordsTextAlign } from './document'; import { normalizeDocument, sanitizeWordsUrl } from './normalize'; +import { + parseTableHtml, + serializeTableHtml +} from '../extensions/table/serialize-html'; const MARK_TAGS = { bold: 'strong', @@ -124,13 +119,7 @@ function serializeBlockHtml(block: WordsBlock): string { } if (block.type === 'table') { - const rows = block.children - .map((row) => { - const cells = row.children.map(serializeTableCellHtml).join(''); - return `${cells}`; - }) - .join(''); - return `${rows}`; + return serializeTableHtml(block, serializeInlinesHtml, escapeHtmlAttr); } if (block.type === 'code') { @@ -152,34 +141,10 @@ function serializeBlockHtml(block: WordsBlock): string { return `${serializeInlinesHtml(block.children)}

`; } -function tableAttrsHtml(block: Extract): string { - const attrs = [ - block.striped ? 'data-words-table-striped' : '', - block.compact ? 'data-words-table-compact' : '' - ].filter(Boolean); - return attrs.length ? ` ${attrs.join(' ')}` : ''; -} - -function serializeTableCellHtml(cell: WordsTableCell): string { - const tag = cell.header ? 'th' : 'td'; - const attrs = tableCellAttrsHtml(cell); - return `<${tag}${attrs}>${serializeInlinesHtml(cell.children)}`; -} - -function tableCellAttrsHtml(cell: WordsTableCell): string { - const attrs: string[] = []; - const styles: string[] = []; - if (cell.textAlign && cell.textAlign !== 'left') styles.push(`text-align: ${cell.textAlign}`); - if (cell.verticalAlign && cell.verticalAlign !== 'top') { - styles.push(`vertical-align: ${cell.verticalAlign}`); - attrs.push(`data-words-cell-vertical="${cell.verticalAlign}"`); - } - if (cell.tone && cell.tone !== 'default') { - attrs.push(`data-words-cell-tone="${cell.tone}"`); - } - if (styles.length) attrs.push(`style="${escapeHtmlAttr(styles.join('; '))}"`); - return attrs.length ? ` ${attrs.join(' ')}` : ''; -} +// Table write helpers (tableAttrsHtml, serializeTableCellHtml, +// tableCellAttrsHtml) moved to `extensions/table/serialize-html.ts` +// since F2.3c. The single entry point `serializeTableHtml` is imported +// at the top of this file. function serializeInlinesHtml(inlines: readonly WordsInline[]): string { return inlines.map(serializeInlineHtml).join(''); @@ -339,27 +304,10 @@ function textAlignFromStyle(node: HtmlElementNode): WordsTextAlign | undefined { return undefined; } -function tableCellVerticalAlignFromNode( - node: HtmlElementNode -): WordsTableCellVerticalAlign | undefined { - const data = - typeof node.attrs['data-words-cell-vertical'] === 'string' - ? node.attrs['data-words-cell-vertical'] - : undefined; - if (isWordsTableCellVerticalAlign(data)) return data; - const style = typeof node.attrs.style === 'string' ? node.attrs.style : ''; - const match = style.match(/vertical-align\s*:\s*(top|middle|bottom)/i); - const align = match?.[1]?.toLowerCase(); - return isWordsTableCellVerticalAlign(align) ? align : undefined; -} - -function tableCellToneFromNode(node: HtmlElementNode): WordsTableCellTone | undefined { - const tone = - typeof node.attrs['data-words-cell-tone'] === 'string' - ? node.attrs['data-words-cell-tone'] - : undefined; - return isWordsTableCellTone(tone) ? tone : undefined; -} +// Table cell parse helpers (tableCellVerticalAlignFromNode, +// tableCellToneFromNode) moved to `extensions/table/serialize-html.ts` +// since F2.3c. The single entry point `parseTableHtml` is imported +// at the top of this file. function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] { if (node.name === 'pre') { @@ -385,7 +333,17 @@ function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] { return [htmlListToBlock(node)]; } - if (node.name === 'table') return [htmlTableToBlock(node)]; + if (node.name === 'table') + return [ + parseTableHtml( + node, + // Engine helper expects a mutable HtmlNode[] but the extension + // adapts a readonly view; safe cast — the helper does not + // mutate the array. + (children) => htmlNodesToInlines(children as unknown as HtmlNode[]), + (el) => textAlignFromStyle(el as unknown as HtmlElementNode) + ) + ]; if (node.name === 'tr') return [createParagraph(htmlNodesToInlines(node.children))]; @@ -461,47 +419,10 @@ function htmlListItemToWords(node: HtmlElementNode): WordsListItem { ); } -function htmlTableToBlock(node: HtmlElementNode): WordsBlock { - const rows = htmlTableRows(node); - return createTable(rows.length ? rows : [createTableRow()], { - striped: 'data-words-table-striped' in node.attrs, - compact: 'data-words-table-compact' in node.attrs - }); -} - -function htmlTableRows(node: HtmlElementNode): WordsTableRow[] { - const rows: WordsTableRow[] = []; - - const visit = (current: HtmlElementNode) => { - if (current.name === 'tr') { - rows.push(htmlTableRowToWords(current)); - return; - } - for (const child of current.children) { - if (child.type === 'element') visit(child); - } - }; - - visit(node); - return rows; -} - -function htmlTableRowToWords(node: HtmlElementNode): WordsTableRow { - const cells = node.children.filter( - (child): child is HtmlElementNode => - child.type === 'element' && (child.name === 'td' || child.name === 'th') - ); - return cells.length ? createTableRow(cells.map(htmlTableCellToWords)) : createTableRow(); -} - -function htmlTableCellToWords(node: HtmlElementNode): WordsTableCell { - return createTableCell(htmlNodesToInlines(node.children), { - header: node.name === 'th', - textAlign: textAlignFromStyle(node), - verticalAlign: tableCellVerticalAlignFromNode(node), - tone: tableCellToneFromNode(node) - }); -} +// Table parse helpers (htmlTableToBlock, htmlTableRows, +// htmlTableRowToWords, htmlTableCellToWords) moved to +// `extensions/table/serialize-html.ts` since F2.3c. The single entry +// point `parseTableHtml` is imported at the top of this file. function indentFromStyle(node: HtmlElementNode): number | undefined { const style = typeof node.attrs.style === 'string' ? node.attrs.style : ''; diff --git a/src/uix/soma/components/words/extensions/table/index.ts b/src/uix/soma/components/words/extensions/table/index.ts index b32826686..c63f7b0ca 100644 --- a/src/uix/soma/components/words/extensions/table/index.ts +++ b/src/uix/soma/components/words/extensions/table/index.ts @@ -43,3 +43,12 @@ export { collectTable as parseTableMarkdownRows, serializeTableMarkdown } from './serialize-markdown'; + +export { + parseTableHtml, + serializeTableHtml +} from './serialize-html'; +export type { + WordsTableHtmlChild, + WordsTableHtmlElement +} from './serialize-html'; diff --git a/src/uix/soma/components/words/extensions/table/serialize-html.ts b/src/uix/soma/components/words/extensions/table/serialize-html.ts new file mode 100644 index 000000000..06537b827 --- /dev/null +++ b/src/uix/soma/components/words/extensions/table/serialize-html.ts @@ -0,0 +1,199 @@ +/** + * Table extension — HTML serialize/parse. + * + * Owns: `` / `` / `${cells}`; + }) + .join(''); + return `${rows}
` / `` round-trip, including + * cell text-align, vertical-align, tone, header markers, and table- + * level striped / compact attrs. + * + * The extension stays decoupled from the engine's HTML parser + * implementation by treating the parsed HTML node as a minimal + * structural shape (`WordsExtensionHtmlNode`) and accepting the inline + * serializer / parser / HTML escapers as callback parameters. + */ + +import { createTable, createTableCell, createTableRow } from './factories'; +import { + isWordsTableCellTone, + isWordsTableCellVerticalAlign +} from './factories'; +import type { + WordsTableBlock, + WordsTableCell, + WordsTableCellTone, + WordsTableCellVerticalAlign, + WordsTableRow +} from './types'; +import type { WordsInline } from '../../engine/document'; + +/** + * Minimal HTML-element shape consumed by table parse helpers. Matches + * the engine's internal HtmlElementNode at the structural level so the + * engine can pass its own parsed nodes in directly. Children union + * elements + text — extension only reads element ones. + */ +export interface WordsTableHtmlElement { + readonly type: 'element'; + readonly name: string; + readonly attrs: Readonly>; + readonly children: ReadonlyArray; +} +export type WordsTableHtmlChild = + | WordsTableHtmlElement + | { readonly type: 'text'; readonly text: string }; + +// ── Write side ──────────────────────────────────────────────────────── + +/** + * Serialize a table block to HTML. + * + * @param block — the table block to serialize. + * @param serializeInlines — engine helper that serializes a readonly + * array of WordsInline to HTML string. + * @param escapeHtmlAttr — engine helper that escapes attribute + * values for inclusion in HTML attrs. + */ +export function serializeTableHtml( + block: WordsTableBlock, + serializeInlines: (inlines: readonly WordsInline[]) => string, + escapeHtmlAttr: (value: string) => string +): string { + const rows = block.children + .map((row) => { + const cells = row.children + .map((cell) => serializeTableCellHtml(cell, serializeInlines, escapeHtmlAttr)) + .join(''); + return `
`; +} + +function tableAttrsHtml(block: WordsTableBlock): string { + const attrs = [ + block.striped ? 'data-words-table-striped' : '', + block.compact ? 'data-words-table-compact' : '' + ].filter(Boolean); + return attrs.length ? ` ${attrs.join(' ')}` : ''; +} + +function serializeTableCellHtml( + cell: WordsTableCell, + serializeInlines: (inlines: readonly WordsInline[]) => string, + escapeHtmlAttr: (value: string) => string +): string { + const tag = cell.header ? 'th' : 'td'; + const attrs = tableCellAttrsHtml(cell, escapeHtmlAttr); + return `<${tag}${attrs}>${serializeInlines(cell.children)}`; +} + +function tableCellAttrsHtml( + cell: WordsTableCell, + escapeHtmlAttr: (value: string) => string +): string { + const attrs: string[] = []; + const styles: string[] = []; + if (cell.textAlign && cell.textAlign !== 'left') styles.push(`text-align: ${cell.textAlign}`); + if (cell.verticalAlign && cell.verticalAlign !== 'top') { + styles.push(`vertical-align: ${cell.verticalAlign}`); + attrs.push(`data-words-cell-vertical="${cell.verticalAlign}"`); + } + if (cell.tone && cell.tone !== 'default') { + attrs.push(`data-words-cell-tone="${cell.tone}"`); + } + if (styles.length) attrs.push(`style="${escapeHtmlAttr(styles.join('; '))}"`); + return attrs.length ? ` ${attrs.join(' ')}` : ''; +} + +// ── Parse side ──────────────────────────────────────────────────────── + +/** + * Convert a parsed `` HTML element to a WordsTableBlock. + * + * @param node — the parsed `
` HTML element. + * @param parseInlines — engine helper that parses HTML children + * into a readonly array of WordsInline. + * @param textAlignFromStyle — engine helper that reads `text-align` + * from a style attribute, returning the + * WordsTextAlign value or undefined. + */ +export function parseTableHtml( + node: WordsTableHtmlElement, + parseInlines: (children: ReadonlyArray) => readonly WordsInline[], + textAlignFromStyle: (node: WordsTableHtmlElement) => WordsTableCell['textAlign'] +): WordsTableBlock { + const rows = collectTableRows(node, parseInlines, textAlignFromStyle); + return createTable(rows.length ? rows : [createTableRow()], { + striped: 'data-words-table-striped' in node.attrs, + compact: 'data-words-table-compact' in node.attrs + }); +} + +function collectTableRows( + node: WordsTableHtmlElement, + parseInlines: (children: ReadonlyArray) => readonly WordsInline[], + textAlignFromStyle: (node: WordsTableHtmlElement) => WordsTableCell['textAlign'] +): WordsTableRow[] { + const rows: WordsTableRow[] = []; + const visit = (current: WordsTableHtmlElement) => { + if (current.name === 'tr') { + rows.push(htmlRowToWords(current, parseInlines, textAlignFromStyle)); + return; + } + for (const child of current.children) { + if (child.type === 'element') visit(child); + } + }; + visit(node); + return rows; +} + +function htmlRowToWords( + node: WordsTableHtmlElement, + parseInlines: (children: ReadonlyArray) => readonly WordsInline[], + textAlignFromStyle: (node: WordsTableHtmlElement) => WordsTableCell['textAlign'] +): WordsTableRow { + const cells = node.children.filter( + (child): child is WordsTableHtmlElement => + child.type === 'element' && (child.name === 'td' || child.name === 'th') + ); + return cells.length + ? createTableRow( + cells.map((cell) => htmlCellToWords(cell, parseInlines, textAlignFromStyle)) + ) + : createTableRow(); +} + +function htmlCellToWords( + node: WordsTableHtmlElement, + parseInlines: (children: ReadonlyArray) => readonly WordsInline[], + textAlignFromStyle: (node: WordsTableHtmlElement) => WordsTableCell['textAlign'] +): WordsTableCell { + return createTableCell(parseInlines(node.children), { + header: node.name === 'th', + textAlign: textAlignFromStyle(node), + verticalAlign: tableCellVerticalAlignFromNode(node), + tone: tableCellToneFromNode(node) + }); +} + +function tableCellVerticalAlignFromNode( + node: WordsTableHtmlElement +): WordsTableCellVerticalAlign | undefined { + const data = + typeof node.attrs['data-words-cell-vertical'] === 'string' + ? node.attrs['data-words-cell-vertical'] + : undefined; + if (isWordsTableCellVerticalAlign(data)) return data; + const style = typeof node.attrs.style === 'string' ? node.attrs.style : ''; + const match = style.match(/vertical-align\s*:\s*(top|middle|bottom)/i); + const align = match?.[1]?.toLowerCase(); + return isWordsTableCellVerticalAlign(align) ? align : undefined; +} + +function tableCellToneFromNode(node: WordsTableHtmlElement): WordsTableCellTone | undefined { + const tone = + typeof node.attrs['data-words-cell-tone'] === 'string' + ? node.attrs['data-words-cell-tone'] + : undefined; + return isWordsTableCellTone(tone) ? tone : undefined; +}