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..c7ac8f96 100644 --- a/website/scripts/docs-parser.test.js +++ b/website/scripts/docs-parser.test.js @@ -177,6 +177,30 @@ 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('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);