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