From d6f0af484652ee93ffd3e2295b2293147d94703c Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 15:55:59 +0200 Subject: [PATCH] refactor(words): extract table markdown serializer (F2.3d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third atomic checkpoint of F2.3. Moves the markdown pipe-table serialize + parse helpers from engine/serialize-markdown.ts to extensions/table/serialize-markdown.ts: Moved: - serializeTableMarkdown(block, renderInline) - tableMarkdownRow (internal) - escapeMarkdownTableCell (internal) - collectTable(lines, startIndex) - parseTableLine (internal) - isTableSeparator (internal) - NEW: buildTableFromMarkdownRows(rows, parseInline) — convenience for the engine to convert collectTable's raw output into a WordsTableBlock, keeping the extension free from the engine's inline parser implementation. The extension takes inline serialize/parse functions as callback parameters (`renderInline`, `parseInline`) instead of importing the engine's inline serializers. Avoids circular dependency — the extension imports engine for shared types (WordsInline, WordsTableRow) and factories only, never for runtime function dependencies. engine/serialize-markdown.ts now imports the 3 extension functions and calls them from `serializeBlockMarkdown` (write side) and `parseWordsMarkdown` (parse side). Internal `collectTable`, `parseTableLine`, `isTableSeparator` removed. Extension barrel updated to expose the new functions: - serializeTableMarkdown - parseTableMarkdownRows (collectTable re-exported under clearer name) - buildTableFromMarkdownRows Verification: - 143/143 tests pass in src/uix/soma/components/words - 0 new TypeScript errors F2.3 progress: a + b + d done. Pending: c (serialize-html), e (render), f (path), g (normalize), h (operations — largest), i (registry wire-up), j (verify). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../words/engine/serialize-markdown.ts | 105 ++----------- .../words/extensions/table/index.ts | 6 + .../extensions/table/serialize-markdown.ts | 144 ++++++++++++++++++ 3 files changed, 165 insertions(+), 90 deletions(-) create mode 100644 src/uix/soma/components/words/extensions/table/serialize-markdown.ts diff --git a/src/uix/soma/components/words/engine/serialize-markdown.ts b/src/uix/soma/components/words/engine/serialize-markdown.ts index 1ab7cff61..04cce3ef7 100644 --- a/src/uix/soma/components/words/engine/serialize-markdown.ts +++ b/src/uix/soma/components/words/engine/serialize-markdown.ts @@ -7,9 +7,6 @@ import { createListItem, createParagraph, createQuote, - createTable, - createTableCell, - createTableRow, createText, getInlineText, normalizeMarks, @@ -22,6 +19,11 @@ import { type WordsTableBlock } from './document'; import { normalizeDocument } from './normalize'; +import { + buildTableFromMarkdownRows, + collectTable, + serializeTableMarkdown +} from '../extensions/table/serialize-markdown'; const MARK_DELIMITERS = { bold: ['**', '**'], @@ -82,13 +84,7 @@ export function parseWordsMarkdown(markdown: string): WordsDocument { const table = collectTable(lines, index); if (table) { - children.push( - createTable( - table.rows.map((row) => - createTableRow(row.map((cell) => createTableCell(parseMarkdownInlines(cell.trim())))) - ) - ) - ); + children.push(buildTableFromMarkdownRows(table.rows, parseMarkdownInlines)); index = table.nextIndex; continue; } @@ -153,7 +149,7 @@ function serializeBlockMarkdown(block: WordsBlock): string { } if (block.type === 'table') { - return serializeTableMarkdown(block); + return serializeTableMarkdown(block, serializeInlinesMarkdown); } return serializeInlinesMarkdown(block.children); @@ -213,33 +209,10 @@ function listMarker(kind: WordsListKind, index: number, checked: boolean): strin return '-'; } -function serializeTableMarkdown(block: WordsTableBlock): string { - const columnCount = Math.max(1, ...block.children.map((row) => row.children.length)); - const rows = block.children.length ? block.children : [createTableRow()]; - const header = tableMarkdownRow( - rows[0]?.children.map((cell) => cell.children) ?? [], - columnCount - ); - const separator = `| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`; - const body = rows.slice(1).map((row) => - tableMarkdownRow( - row.children.map((cell) => cell.children), - columnCount - ) - ); - return [header, separator, ...body].join('\n'); -} - -function tableMarkdownRow(cells: readonly (readonly WordsInline[])[], columnCount: number): string { - const values = Array.from({ length: columnCount }, (_, index) => - escapeMarkdownTableCell(serializeInlinesMarkdown(cells[index] ?? [])) - ); - return `| ${values.join(' | ')} |`; -} - -function escapeMarkdownTableCell(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, '
'); -} +// Table markdown serialize + parse helpers live in +// `extensions/table/serialize-markdown.ts` since F2.3d. The two used +// here (`serializeTableMarkdown`, `collectTable`, +// `buildTableFromMarkdownRows`) are imported at the top of the file. function collectList( lines: readonly string[], @@ -281,58 +254,10 @@ function parseListLine(line: string): { kind: WordsListKind; item: LineMatch } | return undefined; } -function collectTable( - lines: readonly string[], - startIndex: number -): { rows: readonly (readonly string[])[]; nextIndex: number } | undefined { - const header = parseTableLine(lines[startIndex] ?? ''); - const separator = parseTableLine(lines[startIndex + 1] ?? ''); - if (!header || !separator || !isTableSeparator(separator)) return undefined; - - const rows: string[][] = [header]; - let index = startIndex + 2; - while (index < lines.length) { - const row = parseTableLine(lines[index] ?? ''); - if (!row) break; - rows.push(row); - index += 1; - } - return { rows, nextIndex: index }; -} - -function parseTableLine(line: string): string[] | undefined { - const trimmed = line.trim(); - if (!trimmed || !trimmed.includes('|')) return undefined; - const body = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed; - const withoutTrailing = body.endsWith('|') ? body.slice(0, -1) : body; - const cells: string[] = []; - let current = ''; - let escaped = false; - - for (const char of withoutTrailing) { - if (escaped) { - current += char; - escaped = false; - continue; - } - if (char === '\\') { - escaped = true; - continue; - } - if (char === '|') { - cells.push(current.trim()); - current = ''; - continue; - } - current += char; - } - cells.push(current.trim()); - return cells.length ? cells : undefined; -} - -function isTableSeparator(cells: readonly string[]): boolean { - return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())); -} +// `collectTable`, `parseTableLine`, `isTableSeparator` moved to +// `extensions/table/serialize-markdown.ts` since F2.3d. `collectTable` +// is imported at the top of this file; the other two are internal +// helpers of the extension module. function startsStructuredBlock(line: string): boolean { return Boolean( diff --git a/src/uix/soma/components/words/extensions/table/index.ts b/src/uix/soma/components/words/extensions/table/index.ts index b1e2a6667..b32826686 100644 --- a/src/uix/soma/components/words/extensions/table/index.ts +++ b/src/uix/soma/components/words/extensions/table/index.ts @@ -37,3 +37,9 @@ export { WORDS_TABLE_CELL_TONES, WORDS_TABLE_CELL_VERTICAL_ALIGNS } from './factories'; + +export { + buildTableFromMarkdownRows, + collectTable as parseTableMarkdownRows, + serializeTableMarkdown +} from './serialize-markdown'; diff --git a/src/uix/soma/components/words/extensions/table/serialize-markdown.ts b/src/uix/soma/components/words/extensions/table/serialize-markdown.ts new file mode 100644 index 000000000..e99807aeb --- /dev/null +++ b/src/uix/soma/components/words/extensions/table/serialize-markdown.ts @@ -0,0 +1,144 @@ +/** + * Table extension — Markdown serialize/parse. + * + * Pipe-table format (conservative GitHub-flavored). The deserializer + * recognises a table when two consecutive lines parse as table rows + * AND the second one is a separator (`---` divider). Multi-line cells + * collapse to `
` on serialize and unescape on parse. + * + * The extension does NOT own inline serialization — it consumes the + * engine's inline serializer + parser via callbacks injected by the + * caller. This avoids a dependency cycle while keeping table parsing + * self-contained. + */ + +import { createTable, createTableCell, createTableRow } from './factories'; +import type { WordsTableBlock } from './types'; +import type { WordsInline, WordsTableRow } from '../../engine/document'; + +/** + * Serialize a table to Markdown pipe-table format. + * + * @param block — the table to serialize. + * @param renderInline — function from the engine that serializes a + * readonly array of WordsInline to a Markdown + * string fragment. Injected because the engine + * owns the inline serializer. + */ +export function serializeTableMarkdown( + block: WordsTableBlock, + renderInline: (inlines: readonly WordsInline[]) => string +): string { + const columnCount = Math.max(1, ...block.children.map((row) => row.children.length)); + const rows: readonly WordsTableRow[] = block.children.length + ? block.children + : [createTableRow()]; + const header = tableMarkdownRow( + rows[0]?.children.map((cell) => cell.children) ?? [], + columnCount, + renderInline + ); + const separator = `| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`; + const body = rows + .slice(1) + .map((row) => + tableMarkdownRow(row.children.map((cell) => cell.children), columnCount, renderInline) + ); + return [header, separator, ...body].join('\n'); +} + +function tableMarkdownRow( + cells: readonly (readonly WordsInline[])[], + columnCount: number, + renderInline: (inlines: readonly WordsInline[]) => string +): string { + const values = Array.from({ length: columnCount }, (_, index) => + escapeMarkdownTableCell(renderInline(cells[index] ?? [])) + ); + return `| ${values.join(' | ')} |`; +} + +function escapeMarkdownTableCell(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, '
'); +} + +/** + * Try to consume a table starting at `lines[startIndex]`. Returns the + * parsed rows (as raw cell-text strings) + the index right after the + * table, or `undefined` if no table starts here. + * + * The returned rows are still raw text — the caller must run them + * through its own inline parser (`parseMarkdownInlines`) to produce + * actual `WordsInline` content. We return raw strings to keep this + * module independent of the engine's inline parser implementation. + */ +export function collectTable( + lines: readonly string[], + startIndex: number +): { rows: readonly (readonly string[])[]; nextIndex: number } | undefined { + const header = parseTableLine(lines[startIndex] ?? ''); + const separator = parseTableLine(lines[startIndex + 1] ?? ''); + if (!header || !separator || !isTableSeparator(separator)) return undefined; + + const rows: string[][] = [header]; + let index = startIndex + 2; + while (index < lines.length) { + const row = parseTableLine(lines[index] ?? ''); + if (!row) break; + rows.push(row); + index += 1; + } + return { rows, nextIndex: index }; +} + +function parseTableLine(line: string): string[] | undefined { + const trimmed = line.trim(); + if (!trimmed || !trimmed.includes('|')) return undefined; + const body = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed; + const withoutTrailing = body.endsWith('|') ? body.slice(0, -1) : body; + const cells: string[] = []; + let current = ''; + let escaped = false; + + for (const char of withoutTrailing) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === '|') { + cells.push(current.trim()); + current = ''; + continue; + } + current += char; + } + cells.push(current.trim()); + return cells.length ? cells : undefined; +} + +function isTableSeparator(cells: readonly string[]): boolean { + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())); +} + +/** + * Convert the raw parsed rows from `collectTable` into a `WordsTableBlock` + * by running each cell through the caller's inline parser. + * + * @param rows — output of `collectTable`. + * @param parseInline — engine's `parseMarkdownInlines` function. + */ +export function buildTableFromMarkdownRows( + rows: readonly (readonly string[])[], + parseInline: (text: string) => readonly WordsInline[] +): WordsTableBlock { + return createTable( + rows.map((row) => + createTableRow(row.map((cell) => createTableCell(parseInline(cell.trim())))) + ) + ); +}