Files
gbrain/src/core/fence-shared.ts
T
Time AttakcandGitHub 8e84c5b4a1 fix(facts): parse escaped pipes in facts fence round-trips (#2726) (#2738)
parseRowCells now splits on unescaped pipes only and decodes escaped pipes while preserving ordinary backslashes and empty cells, so facts whose text contains literal | survive the fence->DB reconcile instead of being silently deleted. Fixes #2726.
2026-07-13 00:09:16 -07:00

99 lines
3.5 KiB
TypeScript

/**
* Shared primitives for HTML-comment-delimited markdown fences.
*
* Two consumers today:
* - `src/core/takes-fence.ts` (v0.28+) — the `## Takes` fence.
* - `src/core/facts-fence.ts` (v0.32.2+) — the `## Facts` fence.
*
* Both fences share row-level shape (pipe-separated cells, strikethrough on
* the claim for inactive rows, optional separator row, escape-on-write for
* embedded pipes). Lifting these helpers out makes that contract a single
* source of truth — so a fix to one fence's parsing semantics applies to
* the other automatically.
*
* Behavior is byte-identical to the inlined versions that shipped in
* takes-fence at v0.28 — this is a refactor, not a behavior change. The
* takes-fence test suite is the regression gate.
*
* Future fences (hunches as a third category, anything else that needs
* fenced markdown tables) should consume these helpers and add only the
* domain-specific parser layer on top.
*/
/**
* Match a markdown table row's cell-stripped content.
*
* Returns `null` when the line is not a table row (doesn't start with `|`
* or has no second pipe). On a match, returns the cells with surrounding
* whitespace trimmed, with the outer pipes already stripped.
*
* Escaped pipes (`\|`) stay inside their cell and are decoded back to `|`.
* Other backslashes are preserved verbatim so existing fence text such as
* Windows paths remains byte-stable across a render/parse cycle.
*/
export function parseRowCells(line: string): string[] | null {
const trimmed = line.trim();
if (!trimmed.startsWith('|') || !trimmed.includes('|', 1)) return null;
const inner = trimmed.replace(/^\|/, '').replace(/\|$/, '');
const cells: string[] = [];
let cell = '';
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
if (char === '\\' && inner[i + 1] === '|') {
cell += '|';
i += 1;
} else if (char === '|') {
cells.push(cell.trim());
cell = '';
} else {
cell += char;
}
}
cells.push(cell.trim());
return cells;
}
/**
* Markdown table separator detector. A row like `|---|---|---|` (or with
* colons for alignment) returns true. Used to skip past the header
* separator when iterating fence rows.
*/
export function isSeparatorRow(cells: string[]): boolean {
return cells.every(c => /^[-:\s]+$/.test(c)) && cells.length > 0;
}
/**
* Detect strikethrough wrapping on a cell.
*
* `~~text~~` → `{ text: 'text', struck: true }`
* `text` → `{ text: 'text', struck: false }`
*
* Used by both fences to mark a row as inactive (takes: superseded /
* retracted; facts: superseded / forgotten — the parser distinguishes
* via the `context` cell at the domain layer, not here).
*/
export function stripStrikethrough(s: string): { text: string; struck: boolean } {
const m = s.match(/^~~(.+?)~~$/);
if (m) return { text: m[1].trim(), struck: true };
return { text: s, struck: false };
}
/**
* Trim a cell's surrounding whitespace and collapse empty / whitespace-only
* cells to `undefined`. The `''` → `undefined` mapping is what callers want
* for optional string fields.
*/
export function parseStringCell(raw: string): string | undefined {
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
/**
* Escape a value for safe placement inside a pipe-separated cell. Replaces
* any literal `|` with `\|` so the table layout stays intact. `parseRowCells`
* is the inverse and decodes the escape after identifying cell boundaries.
*/
export function escapeFenceCell(s: string): string {
return s.replace(/\|/g, '\\|');
}