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
parent
d6f0af4846
commit
5797e69b88
@ -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…
Reference in new issue