refactor(words): extract table render to extensions/table (F2.3e)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 5797e69b88
commit d839273536

@ -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<WordsRenderNode>,
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('');
}

@ -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';

@ -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<Record<string, string | undefined>>;
readonly children: ReadonlyArray<TableRenderNode>;
}
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<TableRenderNode>;
/** 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 (`<table><tbody><tr><td|th>`), 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<TableRenderHelpers, 'getInlineText'>
): string {
return block.children
.map((row) =>
row.children
.map((cell) => cell.children.map(helpers.getInlineText).join(''))
.join('\t')
)
.join('\n');
}
Loading…
Cancel
Save

Powered by TurnKey Linux.