From 5470e9f051af78a672b5342a11002821bb95f8cf Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:35:01 +0000 Subject: [PATCH 1/2] fix(website): require the table delimiter row's cell count to match the header GFM does not recognise a table when the header and delimiter row have different cell counts, so `a | b` over `| --- |` is paragraph text. The lookahead added in #534 stopped at `isDelimiterRow`, which validates the row's syntax in isolation, so that input parsed as a table: the delimiter row vanished from the output and the prose above it rendered as a table header, with no error. The block-level table branch had the same hole from before #534. `splitRow`'s escape-aware loop is split into a raw cell-splitter that `startsTable` can call speculatively on every paragraph line, where `parseInline` would raise on inline content the parser rejects. --- website/scripts/docs-parser.js | 36 ++++++++++++++++++++++------- website/scripts/docs-parser.test.js | 18 +++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/website/scripts/docs-parser.js b/website/scripts/docs-parser.js index 6b526e06..7810a99b 100644 --- a/website/scripts/docs-parser.js +++ b/website/scripts/docs-parser.js @@ -295,8 +295,7 @@ function closesFence(line, marker, length) { /** * Whether `line` begins a new block, so a paragraph — or a blockquote's lazy * continuation — ends before it instead of swallowing it. `next` supplies the - * single line of lookahead a table needs, a `|` row being a table only when a - * delimiter row follows it. + * single line of lookahead a table needs; `startsTable` holds the test. * * Six of the seven block starts are here: ATX heading, list item, blockquote, * fence, table, and block-level raw HTML. A standalone `` is deliberately @@ -309,7 +308,7 @@ function interruptsParagraph(line, next) { || LIST_ITEM.test(line) || /^\s*>\s?/.test(line) || /^(\s*)(`{3,}|~{3,})/.test(line) - || (line.includes('|') && isDelimiterRow(next ?? '')) + || startsTable(line, next ?? '') || (/^\s*<\/?[a-zA-Z]/.test(line) && !/^\s* parseInline(c.trim(), line)); + return cells; +} + +/** Split a table row into cells and parse each one's inline content. */ +function splitRow(row, line) { + return splitCells(row).map((c) => parseInline(c.trim(), line)); } /** @@ -360,6 +368,18 @@ function isDelimiterRow(row) { return row.includes('|') && /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(row); } +/** + * Whether `line` and the delimiter row `next` start a GFM table. The cell + * counts have to match: GFM does not recognise a table when they differ, so + * `a | b` over `| --- |` is paragraph text, and reading it as a table drops the + * delimiter row and restyles the prose with no error. + */ +function startsTable(line, next) { + return line.includes('|') + && isDelimiterRow(next) + && splitCells(line).length === splitCells(next).length; +} + function alignmentsFrom(row) { return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => { const s = c.trim(); @@ -429,7 +449,7 @@ export function parseMarkdown(markdown, options = {}) { } // Table - if (raw.includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) { + if (i + 1 < lines.length && startsTable(raw, lines[i + 1])) { const header = splitRow(raw, lineNo); const align = alignmentsFrom(lines[i + 1]); i += 2; diff --git a/website/scripts/docs-parser.test.js b/website/scripts/docs-parser.test.js index c66fd0e6..bdd389a9 100644 --- a/website/scripts/docs-parser.test.js +++ b/website/scripts/docs-parser.test.js @@ -177,6 +177,24 @@ describe('blocks', () => { expect(() => parseMarkdown('a | b\n---\n')).toThrow(/setext heading underline/); }); + it('reads a delimiter row whose cell count differs from the header as prose', () => { + expect(parseMarkdown('Intro\na | b\n| --- |\n').blocks.map((b) => b.type)).toEqual(['paragraph']); + expect(parseMarkdown('a | b\n| --- |\n').blocks.map((b) => b.type)).toEqual(['paragraph']); + expect(parseMarkdown('a | b | c\n--- | ---\n1 | 2 | 3\n').blocks.map((b) => b.type)).toEqual(['paragraph']); + }); + + it('keeps a mismatched delimiter row inside the blockquote it lazily continues', () => { + const { blocks } = parseMarkdown('> quoted\na | b\n| --- |\n'); + expect(blocks.map((b) => b.type)).toEqual(['blockquote']); + }); + + it('still parses a table whose header omits the outer pipes', () => { + const { blocks } = parseMarkdown('a|b\n-|-\n1|2\n'); + expect(blocks[0].type).toBe('table'); + expect(blocks[0].header.map(inlineToText)).toEqual(['a', 'b']); + expect(blocks[0].rows[0].map(inlineToText)).toEqual(['1', '2']); + }); + it('parses a table with an escaped pipe inside inline code', () => { const md = '| Key | Action |\n|-----|--------|\n| `\\|` or tmux `%` | Split |\n'; const { blocks } = parseMarkdown(md); From fbcc5db890934f2d0d978fc9bf38ab8c4b88e7e6 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:43:59 +0000 Subject: [PATCH 2/2] test(website): pin the escaped-pipe cell count in a table header --- website/scripts/docs-parser.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/scripts/docs-parser.test.js b/website/scripts/docs-parser.test.js index bdd389a9..c7ac8f96 100644 --- a/website/scripts/docs-parser.test.js +++ b/website/scripts/docs-parser.test.js @@ -195,6 +195,12 @@ describe('blocks', () => { expect(blocks[0].rows[0].map(inlineToText)).toEqual(['1', '2']); }); + it('counts an escaped pipe in the header as one cell, not two', () => { + const { blocks } = parseMarkdown('a \\| b | c\n--- | ---\n1 | 2\n'); + expect(blocks[0].type).toBe('table'); + expect(blocks[0].header.map(inlineToText)).toEqual(['a | b', 'c']); + }); + it('parses a table with an escaped pipe inside inline code', () => { const md = '| Key | Action |\n|-----|--------|\n| `\\|` or tmux `%` | Split |\n'; const { blocks } = parseMarkdown(md);