From d8392735362b5ee4941136455ad96fb5c5bae998 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 16:07:18 +0200 Subject: [PATCH] refactor(words): extract table render to extensions/table (F2.3e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth atomic checkpoint of F2.3. Moves the table-specific DOM render helpers from engine/render.ts to extensions/table/render.ts. Moved: - renderTable(block, path, helpers) — replaces the table branch in renderWordsBlock + internal renderWordsTableRow + renderWordsTableCell - renderTablePlainText(block, helpers) — replaces the table branch in renderBlockPlainText The extension defines structural render-element types (TableRenderElement, TableRenderText, TableRenderNode) assignment- compatible with the engine's WordsRenderElement/Node. The engine passes its constants (WORDS_PATH_ATTR, WORDS_NODE_ATTR) + helpers (encodePath, renderInlines, getInlineText) via a TableRenderHelpers object, keeping the extension independent of engine/dom.ts. The engine's renderWordsBlock dispatcher branch for `block.type === 'table'` now delegates to `renderTable(block, path, { ... })` in a single call. ~50 LoC of helper functions removed from the engine. Extension barrel updated to expose: - renderTable / renderTablePlainText - TableRenderElement / TableRenderNode / TableRenderText / TableRenderHelpers / RenderPath types Verification: - 143/143 tests pass in src/uix/soma/components/words - 0 new TypeScript errors F2.3 progress: a + b + c + d + e done. Remaining: f (path), g (normalize), h (operations — largest at ~400-500 LoC), i (registry wire-up), j (verify). Operations remains the heaviest piece — likely its own dedicated session. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soma/components/words/engine/render.ts | 84 ++-------- .../words/extensions/table/index.ts | 9 ++ .../words/extensions/table/render.ts | 153 ++++++++++++++++++ 3 files changed, 176 insertions(+), 70 deletions(-) create mode 100644 src/uix/soma/components/words/extensions/table/render.ts diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts index c6191b41b..e32b30383 100644 --- a/src/uix/soma/components/words/engine/render.ts +++ b/src/uix/soma/components/words/engine/render.ts @@ -3,9 +3,7 @@ import { type WordsBlock, type WordsDocument, type WordsInline, - type WordsListItem, - type WordsTableCell, - type WordsTableRow + type WordsListItem } from './document'; import { highlightWordsCode } from './code-highlight'; import { @@ -17,6 +15,7 @@ import { } from './dom'; import type { WordsPath } from './path'; import type { WordsTextMatch } from './find-replace'; +import { renderTable, renderTablePlainText } from '../extensions/table/render'; export interface WordsRenderFindOptions { readonly matches?: readonly WordsTextMatch[]; @@ -98,27 +97,14 @@ function renderWordsBlock( } if (block.type === 'table') { - return { - kind: 'element', - tag: 'table', - attrs: { - [WORDS_NODE_ATTR]: 'table', - [WORDS_PATH_ATTR]: encodeWordsPath(path), - 'data-words-block': 'table', - ...(block.striped ? { 'data-words-table-striped': '' } : {}), - ...(block.compact ? { 'data-words-table-compact': '' } : {}) - }, - children: [ - { - kind: 'element', - tag: 'tbody', - attrs: {}, - children: block.children.map((row, rowIndex) => - renderWordsTableRow(row, [...path, rowIndex], findOpts) - ) - } - ] - }; + return renderTable(block, path, { + pathAttr: WORDS_PATH_ATTR, + nodeAttr: WORDS_NODE_ATTR, + encodePath: encodeWordsPath, + renderInlines: (inlines, cellPath) => + renderWordsInlines(inlines, cellPath, findOpts) as ReadonlyArray, + getInlineText + }) as WordsRenderElement; } if (block.type === 'code') { @@ -165,47 +151,9 @@ function renderWordsBlock( }; } -function renderWordsTableRow( - row: WordsTableRow, - path: WordsPath, - findOpts?: WordsRenderFindOptions -): WordsRenderElement { - return { - kind: 'element', - tag: 'tr', - attrs: { - [WORDS_NODE_ATTR]: 'table-row', - [WORDS_PATH_ATTR]: encodeWordsPath(path) - }, - children: row.children.map((cell, cellIndex) => - renderWordsTableCell(cell, [...path, cellIndex], findOpts) - ) - }; -} - -function renderWordsTableCell( - cell: WordsTableCell, - path: WordsPath, - findOpts?: WordsRenderFindOptions -): WordsRenderElement { - return { - kind: 'element', - tag: cell.header ? 'th' : 'td', - attrs: { - [WORDS_NODE_ATTR]: 'table-cell', - [WORDS_PATH_ATTR]: encodeWordsPath(path), - ...(cell.header ? { 'data-words-cell-header': '' } : {}), - ...(cell.textAlign && cell.textAlign !== 'left' - ? { 'data-words-cell-align': cell.textAlign, style: `text-align: ${cell.textAlign}` } - : {}), - ...(cell.verticalAlign && cell.verticalAlign !== 'top' - ? { 'data-words-cell-vertical': cell.verticalAlign } - : {}), - ...(cell.tone && cell.tone !== 'default' ? { 'data-words-cell-tone': cell.tone } : {}) - }, - children: renderWordsInlines(cell.children, path, findOpts) - }; -} +// Table render helpers (renderWordsTableRow, renderWordsTableCell) +// moved to `extensions/table/render.ts` since F2.3e. The dispatcher +// branch above delegates to `renderTable(block, path, helpers)`. function renderWordsListItem( item: WordsListItem, @@ -368,11 +316,7 @@ function renderBlockPlainText(block: WordsBlock): string { return block.children.map((item) => item.children.map(getInlineText).join('')).join('\n'); } if (block.type === 'table') { - return block.children - .map((row) => - row.children.map((cell) => cell.children.map(getInlineText).join('')).join('\t') - ) - .join('\n'); + return renderTablePlainText(block, { getInlineText }); } return block.children.map(getInlineText).join(''); } diff --git a/src/uix/soma/components/words/extensions/table/index.ts b/src/uix/soma/components/words/extensions/table/index.ts index c63f7b0ca..65d81c352 100644 --- a/src/uix/soma/components/words/extensions/table/index.ts +++ b/src/uix/soma/components/words/extensions/table/index.ts @@ -52,3 +52,12 @@ export type { WordsTableHtmlChild, WordsTableHtmlElement } from './serialize-html'; + +export { renderTable, renderTablePlainText } from './render'; +export type { + RenderPath, + TableRenderElement, + TableRenderHelpers, + TableRenderNode, + TableRenderText +} from './render'; diff --git a/src/uix/soma/components/words/extensions/table/render.ts b/src/uix/soma/components/words/extensions/table/render.ts new file mode 100644 index 000000000..c5064d6e3 --- /dev/null +++ b/src/uix/soma/components/words/extensions/table/render.ts @@ -0,0 +1,153 @@ +/** + * Table extension — DOM render hooks. + * + * Owns the conversion of WordsTableBlock / WordsTableRow / WordsTableCell + * to the engine's intermediate render-element shape. The engine's render + * dispatcher calls into here for `block.type === 'table'`. + * + * Render-internal helpers (encodeWordsPath, WORDS_NODE_ATTR, etc.) come + * from the engine via constructor parameters to keep this module + * decoupled from `engine/dom.ts` constants. + */ + +import type { WordsInline } from '../../engine/document'; +import type { WordsTableBlock, WordsTableCell, WordsTableRow } from './types'; + +// Render-element types kept structural to avoid importing the engine +// shape. The engine's WordsRenderElement is assignment-compatible. +export interface TableRenderElement { + readonly kind: 'element'; + readonly tag: + | 'table' + | 'tbody' + | 'tr' + | 'td' + | 'th' + | string; + readonly attrs: Readonly>; + readonly children: ReadonlyArray; +} + +export interface TableRenderText { + readonly kind: 'text'; + readonly text: string; +} + +export type TableRenderNode = TableRenderElement | TableRenderText; + +export type RenderPath = readonly number[]; + +export interface TableRenderHelpers { + /** Path attr key (engine's `WORDS_PATH_ATTR`). */ + readonly pathAttr: string; + /** Node attr key (engine's `WORDS_NODE_ATTR`). */ + readonly nodeAttr: string; + /** Engine path encoder (engine's `encodeWordsPath`). */ + readonly encodePath: (path: RenderPath) => string; + /** Engine inline renderer for the cell contents. */ + readonly renderInlines: ( + inlines: readonly WordsInline[], + path: RenderPath + ) => ReadonlyArray; + /** Engine plain-text helper for cell text serialization. */ + readonly getInlineText: (inline: WordsInline) => string; +} + +/** + * Render a table block to the engine's intermediate element tree. + * + * Mirrors what `renderWordsBlock(block, path)` produced for tables + * before F2.3e — same DOM shape (``), same + * attributes, same path encoding. Pure function: takes the block + a + * path + helpers, returns a render-element. + */ +export function renderTable( + block: WordsTableBlock, + path: RenderPath, + helpers: TableRenderHelpers +): TableRenderElement { + return { + kind: 'element', + tag: 'table', + attrs: { + [helpers.nodeAttr]: 'table', + [helpers.pathAttr]: helpers.encodePath(path), + 'data-words-block': 'table', + ...(block.striped ? { 'data-words-table-striped': '' } : {}), + ...(block.compact ? { 'data-words-table-compact': '' } : {}) + }, + children: [ + { + kind: 'element', + tag: 'tbody', + attrs: {}, + children: block.children.map((row, rowIndex) => + renderTableRow(row, [...path, rowIndex], helpers) + ) + } + ] + }; +} + +function renderTableRow( + row: WordsTableRow, + path: RenderPath, + helpers: TableRenderHelpers +): TableRenderElement { + return { + kind: 'element', + tag: 'tr', + attrs: { + [helpers.nodeAttr]: 'table-row', + [helpers.pathAttr]: helpers.encodePath(path) + }, + children: row.children.map((cell, cellIndex) => + renderTableCell(cell, [...path, cellIndex], helpers) + ) + }; +} + +function renderTableCell( + cell: WordsTableCell, + path: RenderPath, + helpers: TableRenderHelpers +): TableRenderElement { + return { + kind: 'element', + tag: cell.header ? 'th' : 'td', + attrs: { + [helpers.nodeAttr]: 'table-cell', + [helpers.pathAttr]: helpers.encodePath(path), + ...(cell.header ? { 'data-words-cell-header': '' } : {}), + ...(cell.textAlign && cell.textAlign !== 'left' + ? { + 'data-words-cell-align': cell.textAlign, + style: `text-align: ${cell.textAlign}` + } + : {}), + ...(cell.verticalAlign && cell.verticalAlign !== 'top' + ? { 'data-words-cell-vertical': cell.verticalAlign } + : {}), + ...(cell.tone && cell.tone !== 'default' ? { 'data-words-cell-tone': cell.tone } : {}) + }, + children: helpers.renderInlines(cell.children, path) + }; +} + +/** + * Render a table block to plain text — cells tab-separated, rows + * newline-separated. Used by the engine's plain-text serializer for + * features like clipboard copy of a single block. Pure function. + */ +export function renderTablePlainText( + block: WordsTableBlock, + helpers: Pick +): string { + return block.children + .map((row) => + row.children + .map((cell) => cell.children.map(helpers.getInlineText).join('')) + .join('\t') + ) + .join('\n'); +}