refactor(words): extract table HTML serializer (F2.3c)

Fourth atomic checkpoint of F2.3. Moves the HTML pipe-table serialize +
parse helpers from engine/serialize-html.ts to
extensions/table/serialize-html.ts.

Moved (write side):
- serializeTableHtml(block, serializeInlines, escapeHtmlAttr)
- serializeTableCellHtml (internal)
- tableAttrsHtml (internal)
- tableCellAttrsHtml (internal)

Moved (parse side):
- parseTableHtml(node, parseInlines, textAlignFromStyle)
- collectTableRows (internal)
- htmlRowToWords (internal)
- htmlCellToWords (internal)
- tableCellVerticalAlignFromNode (internal)
- tableCellToneFromNode (internal)

Mirrors the F2.3d pattern: extension consumes engine inline serializer
+ HTML attribute escaper as callback parameters to avoid circular
dependencies. The extension defines its own minimal `WordsTableHtmlElement`
/ `WordsTableHtmlChild` types structurally compatible with the engine's
internal HtmlElementNode/HtmlNode — engine passes its own parsed
nodes via a safe `as unknown as` cast at the call site (commented).

engine/serialize-html.ts now contains only the dispatch call to the
extension's serializeTableHtml / parseTableHtml. ~150 LoC of helper
code removed from the engine; same LoC + clearer separation in
extension.

Extension barrel updated to expose:
- serializeTableHtml / parseTableHtml
- WordsTableHtmlElement / WordsTableHtmlChild types

Verification:
- 143/143 tests pass in src/uix/soma/components/words
- 0 new TypeScript errors

F2.3 progress: a + b + c + d done. Remaining: e (render), f (path),
g (normalize), h (operations — largest), i (registry wire-up),
j (verify).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent d6f0af4846
commit 5797e69b88

@ -8,12 +8,7 @@ import {
createListItem, createListItem,
createParagraph, createParagraph,
createQuote, createQuote,
createTable,
createTableCell,
createTableRow,
createText, createText,
isWordsTableCellTone,
isWordsTableCellVerticalAlign,
normalizeMarks, normalizeMarks,
normalizeWordsCodeLanguage, normalizeWordsCodeLanguage,
type WordsBlock, type WordsBlock,
@ -21,13 +16,13 @@ import {
type WordsInline, type WordsInline,
type WordsListItem, type WordsListItem,
type WordsMark, type WordsMark,
type WordsTableCell,
type WordsTableCellTone,
type WordsTableCellVerticalAlign,
type WordsTableRow,
type WordsTextAlign type WordsTextAlign
} from './document'; } from './document';
import { normalizeDocument, sanitizeWordsUrl } from './normalize'; import { normalizeDocument, sanitizeWordsUrl } from './normalize';
import {
parseTableHtml,
serializeTableHtml
} from '../extensions/table/serialize-html';
const MARK_TAGS = { const MARK_TAGS = {
bold: 'strong', bold: 'strong',
@ -124,13 +119,7 @@ function serializeBlockHtml(block: WordsBlock): string {
} }
if (block.type === 'table') { if (block.type === 'table') {
const rows = block.children return serializeTableHtml(block, serializeInlinesHtml, escapeHtmlAttr);
.map((row) => {
const cells = row.children.map(serializeTableCellHtml).join('');
return `<tr>${cells}</tr>`;
})
.join('');
return `<table${tableAttrsHtml(block)}><tbody>${rows}</tbody></table>`;
} }
if (block.type === 'code') { if (block.type === 'code') {
@ -152,34 +141,10 @@ function serializeBlockHtml(block: WordsBlock): string {
return `<p${align}>${serializeInlinesHtml(block.children)}</p>`; return `<p${align}>${serializeInlinesHtml(block.children)}</p>`;
} }
function tableAttrsHtml(block: Extract<WordsBlock, { type: 'table' }>): string { // Table write helpers (tableAttrsHtml, serializeTableCellHtml,
const attrs = [ // tableCellAttrsHtml) moved to `extensions/table/serialize-html.ts`
block.striped ? 'data-words-table-striped' : '', // since F2.3c. The single entry point `serializeTableHtml` is imported
block.compact ? 'data-words-table-compact' : '' // at the top of this file.
].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(' ')}` : '';
}
function serializeInlinesHtml(inlines: readonly WordsInline[]): string { function serializeInlinesHtml(inlines: readonly WordsInline[]): string {
return inlines.map(serializeInlineHtml).join(''); return inlines.map(serializeInlineHtml).join('');
@ -339,27 +304,10 @@ function textAlignFromStyle(node: HtmlElementNode): WordsTextAlign | undefined {
return undefined; return undefined;
} }
function tableCellVerticalAlignFromNode( // Table cell parse helpers (tableCellVerticalAlignFromNode,
node: HtmlElementNode // tableCellToneFromNode) moved to `extensions/table/serialize-html.ts`
): WordsTableCellVerticalAlign | undefined { // since F2.3c. The single entry point `parseTableHtml` is imported
const data = // at the top of this file.
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;
}
function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] { function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] {
if (node.name === 'pre') { if (node.name === 'pre') {
@ -385,7 +333,17 @@ function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] {
return [htmlListToBlock(node)]; 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))]; if (node.name === 'tr') return [createParagraph(htmlNodesToInlines(node.children))];
@ -461,47 +419,10 @@ function htmlListItemToWords(node: HtmlElementNode): WordsListItem {
); );
} }
function htmlTableToBlock(node: HtmlElementNode): WordsBlock { // Table parse helpers (htmlTableToBlock, htmlTableRows,
const rows = htmlTableRows(node); // htmlTableRowToWords, htmlTableCellToWords) moved to
return createTable(rows.length ? rows : [createTableRow()], { // `extensions/table/serialize-html.ts` since F2.3c. The single entry
striped: 'data-words-table-striped' in node.attrs, // point `parseTableHtml` is imported at the top of this file.
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)
});
}
function indentFromStyle(node: HtmlElementNode): number | undefined { function indentFromStyle(node: HtmlElementNode): number | undefined {
const style = typeof node.attrs.style === 'string' ? node.attrs.style : ''; const style = typeof node.attrs.style === 'string' ? node.attrs.style : '';

@ -43,3 +43,12 @@ export {
collectTable as parseTableMarkdownRows, collectTable as parseTableMarkdownRows,
serializeTableMarkdown serializeTableMarkdown
} from './serialize-markdown'; } from './serialize-markdown';
export {
parseTableHtml,
serializeTableHtml
} from './serialize-html';
export type {
WordsTableHtmlChild,
WordsTableHtmlElement
} from './serialize-html';

@ -0,0 +1,199 @@
/**
* Table extension — HTML serialize/parse.
*
* Owns: `<table>` / `<tr>` / `<td>` / `<th>` 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<Record<string, string | true>>;
readonly children: ReadonlyArray<WordsTableHtmlChild>;
}
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 `<tr>${cells}</tr>`;
})
.join('');
return `<table${tableAttrsHtml(block)}><tbody>${rows}</tbody></table>`;
}
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 `<table>` HTML element to a WordsTableBlock.
*
* @param node — the parsed `<table>` 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<WordsTableHtmlChild>) => 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<WordsTableHtmlChild>) => 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<WordsTableHtmlChild>) => 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<WordsTableHtmlChild>) => 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;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.