5b3: merge the code list's duplicate causes, spell the list separator, claim the bare pipe table

This commit is contained in:
2026-09-03 19:05:23 +02:00
parent 3e4f596eb1
commit d2ed9c8219
21 changed files with 384 additions and 81 deletions
+16 -3
View File
@@ -16,7 +16,7 @@ import {
setextHeadingLevel,
} from '../commonmark-grammar.ts'
import { directiveLineEscape, malformedDirective, readDirectiveLine } from '../directive-syntax.ts'
import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { readLinkDefinitions } from './link-reference-definitions.ts'
export type Block = { position: SourcePosition } & (
@@ -358,7 +358,7 @@ function readLineBlock(walk: Walk, opener: string): boolean {
if (level !== undefined) {
const paragraph = takeParagraph(walk)
if (paragraph !== undefined) {
currentBlocks(walk).push({ kind: 'heading', level, position: paragraph.position, text: paragraph.text })
currentBlocks(walk).push(bareTableFault(paragraph) ?? { kind: 'heading', level, position: paragraph.position, text: paragraph.text })
return true
}
}
@@ -390,7 +390,7 @@ function closeLeaf(walk: Walk): void {
if (leaf === undefined) return
if (leaf.kind === 'paragraph') {
const paragraph = takeParagraph(walk)
if (paragraph !== undefined) currentBlocks(walk).push(paragraph)
if (paragraph !== undefined) currentBlocks(walk).push(bareTableFault(paragraph) ?? paragraph)
return
}
walk.leaf = undefined
@@ -413,6 +413,19 @@ function pipeTableBlock(rows: readonly [string[], ...string[][]], position: Sour
return { kind: 'table', position, rows: [header, ...body] }
}
// spec/flavour.md, Tables: GFM's table without the leading pipes, which no line of it claims.
function bareTableFault(paragraph: Extract<Block, { kind: 'paragraph' }>): Block | undefined {
let header: string[] | undefined
for (const line of paragraph.text.split('\n')) {
const cells = barePipeCells(line)
if (header !== undefined && cells !== undefined && cells.length === header.length && isDelimiterRow(cells)) {
return faultedBlock('a pipe table opens every row with `|`: this one does not; \\| keeps a pipe literal text', paragraph.position)
}
header = cells
}
return undefined
}
function cellCount(count: number): string {
return `${count} cell${count === 1 ? '' : 's'}`
}
+2
View File
@@ -10,6 +10,7 @@ import { carryName } from '../opaque-carry.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts'
import { inlineMarkSpellingFault } from './directive-marks.ts'
import { listBreakName, listBreakSpelling } from '../list-break.ts'
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
import { readVocabulary } from './directive-attributes.ts'
import { slotLineEndingFault } from '../directive-syntax.ts'
@@ -76,6 +77,7 @@ function inlineSpellingFault(name: string): ConvertFault | undefined {
}
function blockSpellingFault(name: string): ConvertFault | undefined {
if (name === listBreakName) return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${listBreakSpelling}, never the inline form` }
const directive = blockDirective(name)
if (directive === undefined) return undefined
const form = directive.contentModel === 'none' ? `::${name}` : `:::${name}`
+37 -1
View File
@@ -190,6 +190,42 @@ test('names the pipe table a claimed line does not spell', () => {
assert.deepEqual(path(markdownToAdf('Part.\n\n| a |\n')), ['content', 1])
})
test('names the pipe table whose rows open with no pipe', () => {
const bare = 'malformed-pipe-table: a pipe table opens every row with `|`: this one does not; \\| keeps a pipe literal text'
assert.equal(content(markdownToAdf('a | b\n--- | ---\n')), bare)
assert.equal(content(markdownToAdf('Intro.\na | b\n--- | ---\n')), bare)
assert.equal(content(markdownToAdf('a | b\n--- | ---\n===\n')), bare)
assert.equal(content(markdownToAdf('a | b\n:--- | ---:\n')), bare)
assert.deepEqual(content(markdownToAdf('a | b\nc | d\n')), [paragraph('a | b c | d')])
assert.deepEqual(content(markdownToAdf('a | b\n--- | --- | ---\n')), [paragraph('a | b --- | --- | ---')])
assert.deepEqual(content(markdownToAdf('a \\| b\n--- | ---\n')), [paragraph('a | b --- | ---')])
assert.deepEqual(content(markdownToAdf('a\n---\n')), [{ attrs: { level: 2 }, content: [text('a')], type: 'heading' }])
assert.deepEqual(path(markdownToAdf('Part.\n\na | b\n--- | ---\n')), ['content', 1])
})
test('reads the separator that parts two adjacent lists of one kind', () => {
const parted = [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))]
assert.deepEqual(content(markdownToAdf('- a\n\n::listBreak\n\n- b\n')), parted)
assert.deepEqual(content(markdownToAdf('- a\n::listBreak\n- b\n')), parted)
assert.deepEqual(content(markdownToAdf('1. a\n\n::listBreak\n\n1. b\n')), [orderedList(1, item(paragraph('a'))), orderedList(1, item(paragraph('b')))])
assert.deepEqual(content(markdownToAdf('> - a\n> ::listBreak\n> - b\n')), [quote(...parted)])
assert.deepEqual(path(markdownToAdf('- a\n\n::listBreak\n\n- b\n\n| x |\n')), ['content', 2])
})
test('refuses the list separator that parts anything else', () => {
const parts = 'unsupported-node-shape: listBreak parts two adjacent lists of one type: this one parts something else'
assert.equal(content(markdownToAdf('::listBreak\n')), parts)
assert.equal(content(markdownToAdf('- a\n\n::listBreak\n')), parts)
assert.equal(content(markdownToAdf('- a\n\n::listBreak\n\n1. b\n')), parts)
assert.equal(content(markdownToAdf('Part.\n\n::listBreak\n\n- b\n')), parts)
const bare = 'unsupported-node-shape: listBreak spells the bare leaf form, ::listBreak: this one spells more'
assert.equal(content(markdownToAdf('- a\n\n::listBreak x\n\n- b\n')), bare)
assert.equal(content(markdownToAdf('- a\n\n::listBreak {id=x}\n\n- b\n')), bare)
assert.equal(content(markdownToAdf(':::listBreak\n- a\n:::\n')), bare)
assert.equal(content(markdownToAdf(':listBreak{}\n')), 'unsupported-node-shape: listBreak takes the block form, ::listBreak, never the inline form')
assert.deepEqual(path(markdownToAdf('Part.\n\n::listBreak\n')), ['content', 1])
})
test('refuses the image a pipe cell holds no ADF node for', () => {
assert.equal(content(markdownToAdf('| a |\n| --- |\n| ![x](/u) |\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
assert.deepEqual(path(markdownToAdf('| a |\n| --- |\n| ![x](/u) |\n')), ['content', 0, 'content', 1, 'content', 0, 'content', 0])
@@ -243,7 +279,7 @@ test('names the directive form a node CommonMark spells refuses', () => {
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
test('gives back the refusal the CommonMark spelling itself raises', () => {
const destination = ':::blockquote\n[t](https://example.com/a\\b)\n:::\n'
assert.equal(content(markdownToAdf(destination)), 'unspellable-link-destination: no canonical escape spells a backslash in a link destination')
assert.equal(content(markdownToAdf(destination)), 'unspellable-link: no canonical escape spells a backslash in a link destination')
})
test('names the directive name no node reads back to', () => {
+23 -1
View File
@@ -1,15 +1,18 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { ConvertFault } from '../../result.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { listBreakName, listBreakSpelling } from '../list-break.ts'
import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts'
import { unsupportedNodeShape } from '../directive-syntax.ts'
const documentStart: SourcePosition = { line: 1, offset: 0 }
@@ -24,13 +27,32 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
const content: AdfNode[] = []
for (const [index, block] of blocks.entries()) {
const node = positioned(blockNode(block, definitions, [...path, 'content', index], depth), block.position)
const nodePath = [...path, 'content', content.length]
if (block.kind === 'directive' && block.name === listBreakName) {
const fault = listBreakFault(block, blocks[index - 1], blocks[index + 1])
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
continue
}
const node = positioned(blockNode(block, definitions, nodePath, depth), block.position)
if (!node.ok) return node
content.push(node.value)
}
return success(content)
}
// spec/flavour.md, Directives: the separator builds no node, so only the pair it parts spells it.
function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next: Block | undefined): ConvertFault | undefined {
if (block.blocks !== undefined || block.argument !== undefined || block.attributes.size > 0) {
return unsupportedNodeShape(`${listBreakName} spells the bare leaf form, ${listBreakSpelling}: this one spells more`)
}
if (previous?.kind !== 'bulletList' && previous?.kind !== 'orderedList') return partsFault()
return previous.kind === next?.kind ? undefined : partsFault()
}
function partsFault(): ConvertFault {
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
}
function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
switch (block.kind) {
case 'blockquote':