Skip to content

Commit 2ae4f2a

Browse files
authored
fix(parser): indented fences, trailing text, table detection, and escaped pipes (#429)
Three fixes to parseMarkdownToBlocks and one to table cell rendering: 1. Indented closing fences — allow leading whitespace so ` ``` ` inside list items closes the code block instead of swallowing to EOF. 2. Trailing text after closing fence — drop end-of-line anchor so ` ``` some text` still closes the block. 3. False table detection — require lines start with `|` instead of matching any line with 2+ pipe characters. 4. Escaped pipes in table cells — split on unescaped `|` only, so `\|` renders as a literal pipe instead of creating extra columns. Closes #427 For provenance purposes, this commit was AI assisted.
1 parent 9b016da commit 2ae4f2a

4 files changed

Lines changed: 116 additions & 9 deletions

File tree

packages/ui/components/Viewer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -927,12 +927,12 @@ const parseTableContent = (content: string): { headers: string[]; rows: string[]
927927
if (lines.length === 0) return { headers: [], rows: [] };
928928

929929
const parseRow = (line: string): string[] => {
930-
// Remove leading/trailing pipes and split by |
930+
// Remove leading/trailing pipes, split by unescaped |, then unescape \|
931931
return line
932932
.replace(/^\|/, '')
933933
.replace(/\|$/, '')
934-
.split('|')
935-
.map(cell => cell.trim());
934+
.split(/(?<!\\)\|/)
935+
.map(cell => cell.trim().replace(/\\\|/g, '|'));
936936
};
937937

938938
const headers = parseRow(lines[0]);

packages/ui/components/plan-diff/PlanCleanDiffView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
508508
const lines = block.content.split('\n').filter(line => line.trim());
509509
if (lines.length === 0) return null;
510510
const parseRow = (line: string): string[] =>
511-
line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim());
511+
line.replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'));
512512
const headers = parseRow(lines[0]);
513513
const rows: string[][] = [];
514514
for (let i = 1; i < lines.length; i++) {

packages/ui/utils/parser.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,111 @@ describe("parseMarkdownToBlocks — code fences", () => {
100100
expect(blocks[0].language).toBe("ts");
101101
expect(blocks[0].content).toBe("");
102102
});
103+
104+
/**
105+
* Indented code fences (e.g. inside list items in a plan) must close when
106+
* the closing fence is equally indented. Before the fix, the closing fence
107+
* regex required backticks at column 0, so indented ``` never matched and
108+
* the code block swallowed everything to EOF.
109+
*/
110+
test("indented closing fence closes the code block", () => {
111+
const md = "- Replace with reads:\n ```ts\n const x = 1;\n ```\n- Next item";
112+
const blocks = parseMarkdownToBlocks(md);
113+
const types = blocks.map((b) => b.type);
114+
expect(types).toEqual(["list-item", "code", "list-item"]);
115+
expect(blocks[1].content).toBe(" const x = 1;");
116+
expect(blocks[2].content).toBe("Next item");
117+
});
118+
119+
/**
120+
* A closing fence with trailing text (e.g. ``` some comment) should still
121+
* close the block. Before the fix, the regex required only whitespace after
122+
* backticks, so trailing text caused the fence to swallow everything to EOF.
123+
*/
124+
test("closing fence with trailing text still closes the block", () => {
125+
const md = "```js\nconst x = 1;\n``` this is ignored\nafter";
126+
const blocks = parseMarkdownToBlocks(md);
127+
expect(blocks).toHaveLength(2);
128+
expect(blocks[0].type).toBe("code");
129+
expect(blocks[0].content).toBe("const x = 1;");
130+
expect(blocks[1].type).toBe("paragraph");
131+
expect(blocks[1].content).toBe("after");
132+
});
133+
134+
test("deeply indented fence closes correctly", () => {
135+
const md = " ```py\n print('hi')\n ```\nafter";
136+
const blocks = parseMarkdownToBlocks(md);
137+
expect(blocks[0].type).toBe("code");
138+
expect(blocks[0].language).toBe("py");
139+
expect(blocks[0].content).toBe(" print('hi')");
140+
expect(blocks[1].type).toBe("paragraph");
141+
expect(blocks[1].content).toBe("after");
142+
});
143+
});
144+
145+
describe("parseMarkdownToBlocks — tables", () => {
146+
test("pipe-delimited table parses correctly", () => {
147+
const md = "| A | B |\n|---|---|\n| 1 | 2 |";
148+
const blocks = parseMarkdownToBlocks(md);
149+
expect(blocks).toHaveLength(1);
150+
expect(blocks[0].type).toBe("table");
151+
});
152+
153+
/**
154+
* Prose containing pipe characters (e.g. TypeScript union types in inline
155+
* code) must NOT be treated as a table. Before the fix, the regex
156+
* matched any line with 2+ pipes.
157+
*/
158+
test("paragraph with inline code containing pipes is not a table", () => {
159+
const md = "The type is `'scroll' | 'wrap'` and supports `'a' | 'b' | 'c'` values.";
160+
const blocks = parseMarkdownToBlocks(md);
161+
expect(blocks).toHaveLength(1);
162+
expect(blocks[0].type).toBe("paragraph");
163+
expect(blocks[0].content).toBe(md);
164+
});
165+
166+
test("paragraph with multiple pipes in prose is not a table", () => {
167+
const md = "Use option A | B | C depending on context.";
168+
const blocks = parseMarkdownToBlocks(md);
169+
expect(blocks).toHaveLength(1);
170+
expect(blocks[0].type).toBe("paragraph");
171+
});
172+
173+
test("real-world plan: prose with union types is not a table", () => {
174+
const md = "`@pierre/diffs` supports `overflow: 'scroll' | 'wrap'` plus options, but Plannotator doesn't expose any of them.";
175+
const blocks = parseMarkdownToBlocks(md);
176+
expect(blocks).toHaveLength(1);
177+
expect(blocks[0].type).toBe("paragraph");
178+
});
179+
});
180+
181+
describe("parseMarkdownToBlocks — real-world plan regression", () => {
182+
test("indented code fence inside list does not swallow rest of plan", () => {
183+
const md = [
184+
"### 5. Migrate App.tsx",
185+
"",
186+
"- **Remove** `useState` for `diffStyle`",
187+
"- **Replace** with ConfigStore reads:",
188+
" ```ts",
189+
" const diffStyle = useConfigValue('diffStyle');",
190+
" ```",
191+
"- **Update** toolbar toggle handler",
192+
"",
193+
"### 6. Update DiffViewer",
194+
].join("\n");
195+
const blocks = parseMarkdownToBlocks(md);
196+
const types = blocks.map((b) => b.type);
197+
expect(types).toEqual([
198+
"heading", // ### 5. Migrate App.tsx
199+
"list-item", // - **Remove**
200+
"list-item", // - **Replace**
201+
"code", // ```ts ... ```
202+
"list-item", // - **Update**
203+
"heading", // ### 6. Update DiffViewer
204+
]);
205+
expect(blocks[3].type).toBe("code");
206+
expect(blocks[3].language).toBe("ts");
207+
expect(blocks[4].type).toBe("list-item");
208+
expect(blocks[5].type).toBe("heading");
209+
});
103210
});

packages/ui/utils/parser.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
179179
const codeStartLine = currentLineNum;
180180
// Count backticks in opening fence to support nested fences (e.g. ```` wrapping ```)
181181
const fenceLen = trimmed.match(/^`+/)?.[0].length ?? 3;
182-
const closingFence = new RegExp('^`{' + fenceLen + ',}\\s*$');
182+
const closingFence = new RegExp('^\\s*`{' + fenceLen + ',}');
183183
// Extract language from fence (e.g., ```rust → "rust")
184184
const language = trimmed.slice(fenceLen).trim() || undefined;
185185
// Fast forward until end of code block
@@ -200,17 +200,17 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
200200
continue;
201201
}
202202

203-
// Tables (lines starting and containing |)
204-
if (trimmed.startsWith('|') || (trimmed.includes('|') && trimmed.match(/^\|?.+\|.+\|?$/))) {
203+
// Tables (lines starting with |)
204+
if (trimmed.startsWith('|')) {
205205
flush();
206206
const tableStartLine = currentLineNum;
207207
const tableLines: string[] = [line];
208208

209209
// Collect all consecutive table lines
210210
while (i + 1 < lines.length) {
211211
const nextLine = lines[i + 1].trim();
212-
// Continue if line has table structure (contains | and looks like table content)
213-
if (nextLine.startsWith('|') || (nextLine.includes('|') && nextLine.match(/^\|?.+\|.+\|?$/))) {
212+
// Continue if line starts with | (table row or separator)
213+
if (nextLine.startsWith('|')) {
214214
i++;
215215
tableLines.push(lines[i]);
216216
} else {

0 commit comments

Comments
 (0)