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);