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 ``;
+ 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)}${tag}>`;
-}
-
-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: `` / `` / `| ` / ` | ` 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 `${cells} `;
+ })
+ .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)}${tag}>`;
+}
+
+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;
+}
|