5b3: merge the code list's duplicate causes, spell the list separator, claim the bare pipe table
This commit is contained in:
@@ -81,13 +81,13 @@ test('spells a code block language no info string holds as an attribute', () =>
|
||||
|
||||
test('refuses a link destination CommonMark cannot spell', () => {
|
||||
const link = (href: string): AdfDocument => document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a b>c'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('<https://example.com/'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a\\b'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&b=2'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a b>c'))), 'unspellable-link')
|
||||
assert.equal(code(adfToMarkdown(link('<https://example.com/'))), 'unspellable-link')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a\\b'))), 'unspellable-link')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&b=2'))), 'unspellable-link')
|
||||
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link')
|
||||
const entity = 'https://example.com/?a=1&b=2'
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link-destination')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link')
|
||||
})
|
||||
|
||||
test('escapes the parenthesis a link destination leaves unbalanced, and no other', () => {
|
||||
@@ -103,8 +103,8 @@ test('escapes the quote a link title holds, and refuses the rest', () => {
|
||||
const titled = (title: string): AdfDocument =>
|
||||
document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' }))
|
||||
assert.equal(markdown(adfToMarkdown(titled('He said "hi"'))), '[t](https://example.com/ "He said \\"hi\\"")\n')
|
||||
assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link-title')
|
||||
assert.equal(code(adfToMarkdown(titled('a\\b'))), 'unspellable-link-title')
|
||||
assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link')
|
||||
assert.equal(code(adfToMarkdown(titled('a\\b'))), 'unspellable-link')
|
||||
})
|
||||
|
||||
test('carries a link mark the link spelling cannot write', () => {
|
||||
@@ -134,17 +134,25 @@ test('carries a mark the canonical spellings cannot nest', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses whitespace CommonMark cannot hold', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))), 'unspellable-whitespace')
|
||||
})
|
||||
|
||||
test('refuses a line whose start block parsing would claim', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start')
|
||||
})
|
||||
|
||||
test('refuses two adjacent lists of the same kind, the marker spelling being what merges', () => {
|
||||
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
|
||||
const broken = (second: string): string => markdown(adfToMarkdown(document(paragraph({ text: 'a | b', type: 'text' }, { type: 'hardBreak' }, { text: second, type: 'text' }))))
|
||||
assert.equal(broken('--- | ---'), 'a | b\\\n\\--- | ---\n')
|
||||
assert.equal(broken(':--- | ---:'), 'a | b\\\n\\:--- | ---:\n')
|
||||
assert.equal(broken('c | d'), 'a | b\\\nc | d\n')
|
||||
})
|
||||
|
||||
test('parts two adjacent lists of the same kind, the marker spelling being what merges', () => {
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.equal(code(adfToMarkdown(document(list, list))), 'unspellable-adjacent-lists')
|
||||
assert.equal(markdown(adfToMarkdown(document(list, list))), '- x\n\n::listBreak\n\n- x\n')
|
||||
const ordered: AdfNode = { attrs: { order: 1 }, content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }], type: 'orderedList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(ordered, ordered))), '1. x\n\n::listBreak\n\n1. x\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { panelType: 'info' }, content: [list, list], type: 'panel' }))), ':::panel info\n- x\n::listBreak\n- x\n:::\n')
|
||||
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n ::listBreak\n\n - x\n')
|
||||
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
|
||||
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf\n'))
|
||||
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
|
||||
@@ -394,7 +402,11 @@ test('spells a list item whose marker completes a thematic break as a directive'
|
||||
test('refuses the characters CommonMark rewrites', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))),
|
||||
'unspellable-whitespace: a codeBlock holds no carriage return CommonMark keeps: this text holds one',
|
||||
'unspellable-character: a codeBlock holds no carriage return CommonMark keeps: this text holds one',
|
||||
)
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))),
|
||||
'unspellable-character: a text node holds a carriage return CommonMark rewrites',
|
||||
)
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fencedCodeBlock } from '../backtick-runs.ts'
|
||||
import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { listBreakSpelling } from '../list-break.ts'
|
||||
import { spellDirectiveHeader } from './block-directive-spelling.ts'
|
||||
import { tryImage } from './image.ts'
|
||||
import { tryPipeTable } from './pipe-table.ts'
|
||||
@@ -17,7 +18,7 @@ type BlockContainer = 'directive' | 'document' | 'list-item'
|
||||
type BlockSpelling = 'commonmark' | 'directive' | 'list'
|
||||
type EmittedBody = { fenceColons: number; text: string }
|
||||
type EmittedBlock = EmittedBody & { spelling: BlockSpelling }
|
||||
type PlacedBlock = EmittedBlock & { node: AdfNode; path: ConvertErrorPath }
|
||||
type PlacedBlock = EmittedBlock & { node: AdfNode }
|
||||
|
||||
const largestListMarker = 999999999
|
||||
|
||||
@@ -34,35 +35,31 @@ function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path:
|
||||
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||
const blocks: PlacedBlock[] = []
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const nodePath = [...path, 'content', index]
|
||||
const block = emitBlock(node, nodePath, depth)
|
||||
const block = emitBlock(node, [...path, 'content', index], depth)
|
||||
if (!block.ok) return block
|
||||
blocks.push({ ...block.value, node, path: nodePath })
|
||||
blocks.push({ ...block.value, node })
|
||||
}
|
||||
let fenceColons = 0
|
||||
let text = ''
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const previous = blocks[index - 1]
|
||||
if (previous !== undefined) {
|
||||
const separation = separationBetween(previous, block, container)
|
||||
if (!separation.ok) return separation
|
||||
text += separation.value
|
||||
}
|
||||
if (previous !== undefined) text += separationBetween(previous, block, container)
|
||||
fenceColons = Math.max(fenceColons, block.fenceColons)
|
||||
text += block.text
|
||||
}
|
||||
return success({ fenceColons, text })
|
||||
}
|
||||
|
||||
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): Result<string> {
|
||||
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
|
||||
const plainPair = previous.spelling !== 'directive' && next.spelling !== 'directive'
|
||||
if (plainPair && next.spelling === 'list') {
|
||||
if (previous.spelling === 'list' && previous.node.type === next.node.type) {
|
||||
return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path)
|
||||
const gap = container === 'directive' ? '\n' : '\n\n'
|
||||
return `${gap}${listBreakSpelling}${gap}`
|
||||
}
|
||||
if (container === 'list-item') return success(interruptsParagraph(next.node) ? '\n' : '\n\n')
|
||||
if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
|
||||
}
|
||||
return success(container === 'directive' && !plainPair ? '\n' : '\n\n')
|
||||
return container === 'directive' && !plainPair ? '\n' : '\n\n'
|
||||
}
|
||||
|
||||
function interruptsParagraph(node: AdfNode): boolean {
|
||||
@@ -179,7 +176,7 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
) {
|
||||
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
|
||||
}
|
||||
if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
|
||||
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
|
||||
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
||||
text += child.text
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ function emitText(node: AdfNode, context: InlineContext, index: number, path: Co
|
||||
if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } })
|
||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
||||
if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
|
||||
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../emphasis-matching.ts'
|
||||
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark-grammar.ts'
|
||||
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||
import { opensInlineDirective } from '../directive-syntax.ts'
|
||||
import { readEntityReference } from '../entity-references.ts'
|
||||
|
||||
@@ -196,8 +197,11 @@ function opensConstruct(
|
||||
return claimsCharacter(scan, linkClose, index, inBrackets, container, escaped)
|
||||
}
|
||||
|
||||
// A hard break is the one spelling that puts a delimiter row under a row of its own, so only a later line claims.
|
||||
function claimsLineStart(line: ScanLine, index: number, container: LineContainer): boolean {
|
||||
return container === 'paragraph' && escapesLineClaim(line.text, index - line.start, line.position)
|
||||
if (container !== 'paragraph') return false
|
||||
if (index === line.start && line.position === 'later' && isBareDelimiterRow(line.text)) return true
|
||||
return escapesLineClaim(line.text, index - line.start, line.position)
|
||||
}
|
||||
|
||||
function scanLine(scan: string, start: number): ScanLine {
|
||||
|
||||
@@ -91,26 +91,26 @@ export function skipLinkWhitespace(text: string, offset: number): number {
|
||||
}
|
||||
|
||||
export function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
|
||||
if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path)
|
||||
if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path)
|
||||
if (holdsControlCharacter(href)) return failure('unspellable-link', 'a link destination holds a control character', path)
|
||||
if (href.includes('\\')) return failure('unspellable-link', 'no canonical escape spells a backslash in a link destination', path)
|
||||
if (holdsEntityReference(href)) {
|
||||
return failure('unspellable-link-destination', 'a link destination holds an entity reference that decodes on the way back', path)
|
||||
return failure('unspellable-link', 'a link destination holds an entity reference that decodes on the way back', path)
|
||||
}
|
||||
if (href.includes(' ')) {
|
||||
if (/[<>]/.test(href)) {
|
||||
return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path)
|
||||
return failure('unspellable-link', 'no canonical escape spells an angle bracket beside a space in a link destination', path)
|
||||
}
|
||||
return success(`<${href}>`)
|
||||
}
|
||||
if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path)
|
||||
if (href.startsWith('<')) return failure('unspellable-link', 'a bare link destination cannot begin with an angle bracket', path)
|
||||
return success(escapeUnbalanced(href))
|
||||
}
|
||||
|
||||
export function spellTitle(title: string, path: ConvertErrorPath): Result<string> {
|
||||
if (/[\n\r\\]/.test(title)) {
|
||||
return failure('unspellable-link-title', 'no canonical escape spells a backslash or newline in a link title', path)
|
||||
return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path)
|
||||
}
|
||||
if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title holds an entity reference that decodes on the way back', path)
|
||||
if (holdsEntityReference(title)) return failure('unspellable-link', 'a link title holds an entity reference that decodes on the way back', path)
|
||||
return success(` "${title.replaceAll('"', '\\"')}"`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const name = 'listBreak'
|
||||
|
||||
export const listBreakName = name
|
||||
|
||||
export const listBreakSpelling = `::${name}`
|
||||
@@ -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'}`
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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|  |\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
|
||||
assert.deepEqual(path(markdownToAdf('| a |\n| --- |\n|  |\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', () => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -4,6 +4,21 @@ import { backslashEscape, claimsPipeLine, trimSpace } from './commonmark-grammar
|
||||
const alignmentCell = /^:-+:?$|^-+:$/
|
||||
const delimiterCell = /^-+$/
|
||||
|
||||
// spec/flavour.md, Tables: the cells of a row the leading `|` no claim read, GFM's form without it.
|
||||
export function barePipeCells(line: string): string[] | undefined {
|
||||
const cells = splitPipeCells(line, 0)
|
||||
return cells.length > 1 ? cells : undefined
|
||||
}
|
||||
|
||||
export function isBareDelimiterRow(line: string): boolean {
|
||||
const cells = barePipeCells(line)
|
||||
return cells !== undefined && isDelimiterRow(cells)
|
||||
}
|
||||
|
||||
export function isDelimiterRow(cells: readonly string[]): boolean {
|
||||
return cells.every((cell) => isPipeDelimiter(cell) || isPipeAlignment(cell))
|
||||
}
|
||||
|
||||
export function isPipeAlignment(cell: string): boolean {
|
||||
return alignmentCell.test(cell)
|
||||
}
|
||||
@@ -18,24 +33,7 @@ export function malformedPipeTable(message: string): ConvertFault {
|
||||
|
||||
// spec/flavour.md, Tables: the cells of a claimed row, the closing `|` the spelling writes optional here.
|
||||
export function pipeCells(line: string): string[] | undefined {
|
||||
if (!claimsPipeLine(line)) return undefined
|
||||
const cells: string[] = []
|
||||
let start = 1
|
||||
let index = 1
|
||||
while (index < line.length) {
|
||||
if (backslashEscape(line, index) !== undefined) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line.charAt(index) === '|') {
|
||||
cells.push(trimSpace(line.slice(start, index)))
|
||||
start = index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
cells.push(trimSpace(line.slice(start)))
|
||||
if (cells.length > 1 && cells.at(-1) === '') cells.pop()
|
||||
return cells
|
||||
return claimsPipeLine(line) ? splitPipeCells(line, 1) : undefined
|
||||
}
|
||||
|
||||
export function spellPipeDelimiter(columns: number): string {
|
||||
@@ -45,3 +43,23 @@ export function spellPipeDelimiter(columns: number): string {
|
||||
export function spellPipeRow(cells: readonly string[]): string {
|
||||
return `| ${cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
function splitPipeCells(line: string, start: number): string[] {
|
||||
const cells: string[] = []
|
||||
let cellStart = start
|
||||
let index = start
|
||||
while (index < line.length) {
|
||||
if (backslashEscape(line, index) !== undefined) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line.charAt(index) === '|') {
|
||||
cells.push(trimSpace(line.slice(cellStart, index)))
|
||||
cellStart = index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
cells.push(trimSpace(line.slice(cellStart)))
|
||||
if (cells.length > 1 && cells.at(-1) === '') cells.pop()
|
||||
return cells
|
||||
}
|
||||
|
||||
+1
-3
@@ -5,11 +5,9 @@ export type ConvertErrorCode =
|
||||
| 'unknown-directive-name'
|
||||
| 'unmappable-html'
|
||||
| 'unmappable-image'
|
||||
| 'unspellable-adjacent-lists'
|
||||
| 'unspellable-character'
|
||||
| 'unspellable-line-start'
|
||||
| 'unspellable-link-destination'
|
||||
| 'unspellable-link-title'
|
||||
| 'unspellable-link'
|
||||
| 'unspellable-whitespace'
|
||||
| 'unsupported-document-version'
|
||||
| 'unsupported-nesting-depth'
|
||||
|
||||
Reference in New Issue
Block a user