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()))))
+ )
+ );
+}