refactor(words): extract table markdown serializer (F2.3d)

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

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

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

@ -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 `<br>` 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, '<br>');
}
/**
* 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()))))
)
);
}
Loading…
Cancel
Save

Powered by TurnKey Linux.