Read the three directive forms, their attributes and the fences that nest them
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { Block } from './blocks.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
|
||||
@@ -12,6 +13,20 @@ function kinds(markdown: string): string[] {
|
||||
return parseBlocks(markdown).blocks.map((block) => block.kind)
|
||||
}
|
||||
|
||||
function faults(markdown: string): string[] {
|
||||
const messages: string[] = []
|
||||
const walk = (blocks: readonly Block[]): void => {
|
||||
for (const block of blocks) {
|
||||
if (block.kind === 'fault') messages.push(block.fault.message)
|
||||
if (block.kind === 'blockquote') walk(block.blocks)
|
||||
if (block.kind === 'directive' && block.blocks !== undefined) walk(block.blocks)
|
||||
if (block.kind === 'bulletList' || block.kind === 'orderedList') for (const item of block.items) walk(item)
|
||||
}
|
||||
}
|
||||
walk(parseBlocks(markdown).blocks)
|
||||
return messages
|
||||
}
|
||||
|
||||
test('keeps the link reference definitions a paragraph gives up, the first of a label winning', () => {
|
||||
assert.deepEqual(definitions('[a]: /url\n'), [['a', { destination: '/url' }]])
|
||||
assert.deepEqual(definitions('[Foo Bar]:\n<the url>\n"Title"\n'), [['foo bar', { destination: 'the url', title: 'Title' }]])
|
||||
@@ -61,6 +76,23 @@ test('swallows an HTML block to the end condition its start sets', () => {
|
||||
})
|
||||
|
||||
test('carries a claimed line as the block it opens, the refusal the node layer builds', () => {
|
||||
assert.deepEqual(kinds(':::\nPart.\n'), ['claim', 'paragraph'])
|
||||
assert.deepEqual(kinds('Part.\n| x |\n'), ['paragraph', 'claim'])
|
||||
assert.deepEqual(kinds(':::\nPart.\n'), ['fault', 'paragraph'])
|
||||
assert.deepEqual(kinds('Part.\n| x |\n'), ['paragraph', 'fault'])
|
||||
})
|
||||
|
||||
test('holds a directive container open until the fence that closes it', () => {
|
||||
assert.deepEqual(kinds(':::panel info\nPart.\n:::\nMore.\n'), ['directive', 'paragraph'])
|
||||
assert.deepEqual(kinds('::rule\nPart.\n'), ['directive', 'paragraph'])
|
||||
assert.deepEqual(faults(':::panel info\n\nPart.\n\n:::\n'), [])
|
||||
assert.deepEqual(faults(':::panel info\n> Part.\n> :::\n'), [])
|
||||
assert.deepEqual(faults('::::panel info\n- :::expand\n Part.\n :::\n::::\n'), [])
|
||||
assert.deepEqual(faults(':::panel info\n```\n:::\n```\n:::\n'), [])
|
||||
})
|
||||
|
||||
test('names the directive fence a container does not sit longer than', () => {
|
||||
assert.deepEqual(faults(':::panel info\n:::expand\nPart.\n:::\n'), ["a directive fence line is at least as long as the container's 3 colons"])
|
||||
assert.deepEqual(faults('::::panel info\n:::\n::::\n'), ['a closing fence is shorter than the 4 colons it would close'])
|
||||
assert.deepEqual(faults(':::panel info\nPart.\n'), ['a container fenced with 3 colons is unclosed'])
|
||||
assert.deepEqual(faults('- :::panel info\n\nPart.\n'), ['a container fenced with 3 colons is unclosed'])
|
||||
assert.deepEqual(faults('Part.\n\n:::\n'), ['a closing fence closes no open container'])
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import {
|
||||
atxHeading,
|
||||
@@ -12,14 +14,14 @@ import {
|
||||
openingHtmlBlock,
|
||||
setextHeadingLevel,
|
||||
} from '../commonmark-grammar.ts'
|
||||
import { malformedDirective, readDirectiveLine } from '../directive-syntax.ts'
|
||||
import { readLinkDefinitions } from './link-reference-definitions.ts'
|
||||
|
||||
export type ClaimedConstruct = 'directive' | 'pipe-table'
|
||||
|
||||
export type Block =
|
||||
| { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string }
|
||||
| { blocks: Block[]; kind: 'blockquote' }
|
||||
| { construct: ClaimedConstruct; kind: 'claim' }
|
||||
| { construct: string; kind: 'html' }
|
||||
| { fault: ConvertFault; kind: 'fault' }
|
||||
| { items: Block[][]; kind: 'bulletList' }
|
||||
| { items: Block[][]; kind: 'orderedList'; start: number }
|
||||
| { kind: 'code'; language: string; text: string }
|
||||
@@ -29,9 +31,16 @@ export type Block =
|
||||
|
||||
export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> }
|
||||
|
||||
type DirectiveBlock = Extract<Block, { kind: 'directive' }>
|
||||
|
||||
type ListBlock = Extract<Block, { items: Block[][] }>
|
||||
|
||||
type OpenContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock; marker: string }
|
||||
type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[] }
|
||||
|
||||
type OpenContainer =
|
||||
| Extract<Block, { kind: 'blockquote' }>
|
||||
| OpenDirective
|
||||
| { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock; marker: string }
|
||||
|
||||
type OpenLeaf =
|
||||
| { closer: RegExp | undefined; construct: string; kind: 'html' }
|
||||
@@ -49,12 +58,13 @@ type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; stack: OpenContainer[]
|
||||
const blankLine = /^[ \t]*$/
|
||||
const indentedCodeColumns = 4
|
||||
const largestOpenerIndentation = 3
|
||||
const leafColons = 2
|
||||
const tabStop = 4
|
||||
|
||||
export function parseBlocks(markdown: string): ParsedBlocks {
|
||||
const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, stack: [] }
|
||||
for (const text of normalizeInput(markdown).split('\n')) readLine(walk, { column: 0, text })
|
||||
closeLeaf(walk)
|
||||
closeContainers(walk, 0)
|
||||
return { blocks: walk.blocks, definitions: walk.definitions }
|
||||
}
|
||||
|
||||
@@ -95,6 +105,8 @@ function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line }
|
||||
|
||||
function continuesContainer(walk: Walk, container: OpenContainer, line: Line): Line | undefined {
|
||||
if (container.kind === 'blockquote') return blockquoteRest(removeColumns(line, largestOpenerIndentation))
|
||||
// A directive container has no continuation marker: only its own fence closes it.
|
||||
if (container.kind === 'directive') return line
|
||||
// A list item begins with at most one blank line: an empty one gives the second up.
|
||||
if (blankLine.test(line.text)) {
|
||||
return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' }
|
||||
@@ -169,7 +181,65 @@ function openContainer(walk: Walk, start: ContainerStart): void {
|
||||
|
||||
function closeContainers(walk: Walk, depth: number): void {
|
||||
closeLeaf(walk)
|
||||
walk.stack.length = depth
|
||||
for (const container of walk.stack.splice(depth)) {
|
||||
if (container.kind !== 'directive') continue
|
||||
container.parent[container.index] = { fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), kind: 'fault' }
|
||||
}
|
||||
}
|
||||
|
||||
function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'header' }>): void {
|
||||
const block: DirectiveBlock = {
|
||||
argument: directive.argument,
|
||||
attributes: directive.attributes,
|
||||
blocks: directive.colons > leafColons ? [] : undefined,
|
||||
kind: 'directive',
|
||||
name: directive.name,
|
||||
}
|
||||
const parent = currentBlocks(walk)
|
||||
parent.push(block)
|
||||
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent })
|
||||
}
|
||||
|
||||
function readDirective(walk: Walk, directive: DirectiveLine): void {
|
||||
closeLeaf(walk)
|
||||
if (directive.kind === 'fault') {
|
||||
pushFault(walk, directive.fault)
|
||||
return
|
||||
}
|
||||
const enclosing = innermostDirective(walk)
|
||||
if (directive.kind === 'closing') {
|
||||
closeDirective(walk, directive.colons, enclosing)
|
||||
return
|
||||
}
|
||||
if (enclosing !== undefined && directive.colons >= enclosing.container.colons) {
|
||||
pushFault(walk, malformedDirective(`a directive fence line is at least as long as the container's ${enclosing.container.colons} colons`))
|
||||
return
|
||||
}
|
||||
openDirective(walk, directive)
|
||||
}
|
||||
|
||||
function closeDirective(walk: Walk, colons: number, enclosing: { container: OpenDirective; depth: number } | undefined): void {
|
||||
if (enclosing === undefined) {
|
||||
pushFault(walk, malformedDirective('a closing fence closes no open container'))
|
||||
return
|
||||
}
|
||||
if (colons < enclosing.container.colons) {
|
||||
pushFault(walk, malformedDirective(`a closing fence is shorter than the ${enclosing.container.colons} colons it would close`))
|
||||
return
|
||||
}
|
||||
walk.stack.length = enclosing.depth
|
||||
}
|
||||
|
||||
function innermostDirective(walk: Walk): { container: OpenDirective; depth: number } | undefined {
|
||||
for (let depth = walk.stack.length - 1; depth >= 0; depth -= 1) {
|
||||
const container = walk.stack[depth]
|
||||
if (container?.kind === 'directive') return { container, depth }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pushFault(walk: Walk, fault: ConvertFault): void {
|
||||
currentBlocks(walk).push({ fault, kind: 'fault' })
|
||||
}
|
||||
|
||||
// A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md).
|
||||
@@ -177,7 +247,7 @@ function continuesLazily(walk: Walk, line: Line): boolean {
|
||||
if (walk.leaf?.kind !== 'paragraph' || blankLine.test(line.text)) return false
|
||||
if (leadingColumns(line) >= indentedCodeColumns) return true
|
||||
const opener = removeColumns(line, largestOpenerIndentation).text
|
||||
if (claimedConstruct(opener) !== undefined || isThematicBreak(opener)) return false
|
||||
if (claimsDirectiveLine(opener) || claimsPipeLine(opener) || isThematicBreak(opener)) return false
|
||||
return atxHeading(opener) === undefined && openingCodeFence(opener) === undefined && openingHtmlBlock(opener, true) === undefined
|
||||
}
|
||||
|
||||
@@ -220,10 +290,14 @@ function readIndentedCodeLine(leaf: Extract<OpenLeaf, { kind: 'indented-code' }>
|
||||
|
||||
function openLeaf(walk: Walk, line: Line): void {
|
||||
const opener = removeColumns(line, largestOpenerIndentation).text
|
||||
const claimed = claimedConstruct(opener)
|
||||
if (claimed !== undefined) {
|
||||
const directive = readDirectiveLine(opener)
|
||||
if (directive !== undefined) {
|
||||
readDirective(walk, directive)
|
||||
return
|
||||
}
|
||||
if (claimsPipeLine(opener)) {
|
||||
closeLeaf(walk)
|
||||
currentBlocks(walk).push({ construct: claimed, kind: 'claim' })
|
||||
pushFault(walk, { code: 'malformed-pipe-table', message: 'the line claims a pipe table and parses as none' })
|
||||
return
|
||||
}
|
||||
if (readLineBlock(walk, opener)) return
|
||||
@@ -297,11 +371,6 @@ function currentBlocks(walk: Walk): Block[] {
|
||||
return walk.stack.at(-1)?.blocks ?? walk.blocks
|
||||
}
|
||||
|
||||
function claimedConstruct(opener: string): ClaimedConstruct | undefined {
|
||||
if (claimsDirectiveLine(opener)) return 'directive'
|
||||
return claimsPipeLine(opener) ? 'pipe-table' : undefined
|
||||
}
|
||||
|
||||
function normalizeInput(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/\r\n?/g, '\n')
|
||||
|
||||
@@ -7,6 +7,7 @@ import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.t
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
|
||||
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
||||
import { readInlineDirective, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
|
||||
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
|
||||
|
||||
@@ -50,6 +51,12 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
|
||||
index = angle.value
|
||||
break
|
||||
}
|
||||
case ':': {
|
||||
const directive = readDirective(scan, index)
|
||||
if (!directive.ok) return directive
|
||||
index = directive.value
|
||||
break
|
||||
}
|
||||
case '!':
|
||||
case '[':
|
||||
index = openBracket(scan, index)
|
||||
@@ -131,6 +138,16 @@ function openBracket(scan: Scan, index: number): number {
|
||||
return index + width
|
||||
}
|
||||
|
||||
function readDirective(scan: Scan, index: number): Result<number> {
|
||||
const directive = readInlineDirective(scan.source, index)
|
||||
if (directive === undefined) {
|
||||
scan.pending += ':'
|
||||
return success(index + 1)
|
||||
}
|
||||
const fault = directive.fault ?? unknownDirectiveFault(directive.value.name)
|
||||
return failure(fault.code, fault.message, scan.path)
|
||||
}
|
||||
|
||||
function flush(scan: Scan, strip: boolean): void {
|
||||
const raw = strip ? scan.pending.replace(trailingSpace, '') : scan.pending
|
||||
scan.pending = ''
|
||||
|
||||
@@ -128,13 +128,33 @@ test('reads an indented code block where no paragraph is open', () => {
|
||||
|
||||
test('claims a block-level colon run with no directive to parse it', () => {
|
||||
assert.equal(code(markdownToAdf(':::\n')), 'malformed-directive')
|
||||
assert.equal(code(markdownToAdf('::panel\n')), 'malformed-directive')
|
||||
assert.equal(code(markdownToAdf(' :::panel info\nx\n:::\n')), 'malformed-directive')
|
||||
assert.equal(code(markdownToAdf('::Panel\n')), 'malformed-directive')
|
||||
assert.equal(code(markdownToAdf('::panel {a=1 a=2}\n')), 'malformed-directive')
|
||||
assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1])
|
||||
assert.deepEqual(content(markdownToAdf(':10:30\n')), [paragraph(':10:30')])
|
||||
assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')])
|
||||
})
|
||||
|
||||
test('reads the three directive forms, and names the node none of them reads back to', () => {
|
||||
assert.equal(code(markdownToAdf('::rule\n')), 'unknown-directive-name')
|
||||
assert.equal(code(markdownToAdf(' :::panel info\nx\n:::\n')), 'unknown-directive-name')
|
||||
assert.equal(code(markdownToAdf('Part :mention[@A]{id=b1c2}.\n')), 'unknown-directive-name')
|
||||
assert.equal(content(markdownToAdf('::rule\n')), 'unknown-directive-name: the directive name rule reads back to no node')
|
||||
assert.deepEqual(path(markdownToAdf('Part.\n\n::rule\n')), ['content', 1])
|
||||
})
|
||||
|
||||
test('leaves the colon that opens no directive the text it is', () => {
|
||||
assert.deepEqual(content(markdownToAdf('At 10:30 :smile: today.\n')), [paragraph('At 10:30 :smile: today.')])
|
||||
assert.deepEqual(content(markdownToAdf('\\:mention[@A]\n')), [paragraph(':mention[@A]')])
|
||||
assert.deepEqual(content(markdownToAdf('`:mention[@A]`\n')), [{ content: [codeSpan(':mention[@A]')], type: 'paragraph' }])
|
||||
})
|
||||
|
||||
test('names the inline directive left unclosed at the end of its line', () => {
|
||||
assert.equal(code(markdownToAdf('Part :mention[@A\n')), 'malformed-directive')
|
||||
assert.equal(code(markdownToAdf('Part :mention[@A]{id=\n')), 'malformed-directive')
|
||||
assert.deepEqual(path(markdownToAdf('> Part :mention[@A\n')), ['content', 0, 'content', 0])
|
||||
})
|
||||
|
||||
test('claims a block-level pipe with no table to parse it', () => {
|
||||
assert.equal(code(markdownToAdf('| Part | Qty |\n')), 'malformed-pipe-table')
|
||||
assert.deepEqual(content(markdownToAdf('\\| Part\n')), [paragraph('| Part')])
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { Block, ClaimedConstruct } from './blocks.ts'
|
||||
import type { Block } from './blocks.ts'
|
||||
import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
import { unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument> {
|
||||
const parsed = parseBlocks(markdown)
|
||||
@@ -30,10 +31,14 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
|
||||
return containerNode({ type: 'blockquote' }, block.blocks, definitions, path, depth)
|
||||
case 'bulletList':
|
||||
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
|
||||
case 'claim':
|
||||
return claimFailure(block.construct, path)
|
||||
case 'code':
|
||||
return success(codeBlockNode(block.language, block.text))
|
||||
case 'directive': {
|
||||
const fault = unknownDirectiveFault(block.name)
|
||||
return failure(fault.code, fault.message, path)
|
||||
}
|
||||
case 'fault':
|
||||
return failure(block.fault.code, block.fault.message, path)
|
||||
case 'heading':
|
||||
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path)
|
||||
case 'html':
|
||||
@@ -63,15 +68,6 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
|
||||
return success({ ...node, content })
|
||||
}
|
||||
|
||||
function claimFailure(construct: ClaimedConstruct, path: ConvertErrorPath): Result<AdfNode> {
|
||||
switch (construct) {
|
||||
case 'directive':
|
||||
return failure('malformed-directive', 'the line claims a directive and parses as none', path)
|
||||
case 'pipe-table':
|
||||
return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path)
|
||||
}
|
||||
}
|
||||
|
||||
function codeBlockNode(language: string, text: string): AdfNode {
|
||||
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
|
||||
return text === '' ? node : { ...node, content: [{ text, type: 'text' }] }
|
||||
|
||||
Reference in New Issue
Block a user