\n'), ['paragraph', 'html'])
})
test('carries a claimed line as the block it opens, the refusal the node layer builds', () => {
- assert.deepEqual(kinds(':::\nPart.\n'), ['fault', 'paragraph'])
+ assert.deepEqual(kinds('!adf:/panel\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'), [])
- assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [
+test('opens a container where the content model takes content, and holds it open until the closer naming it', () => {
+ assert.deepEqual(kinds('!adf:panel info\nPart.\n!adf:/panel\nMore.\n'), ['directive', 'paragraph'])
+ assert.deepEqual(kinds('!adf:rule\nPart.\n'), ['directive', 'paragraph'])
+ assert.deepEqual(kinds('!adf:listBreak\nPart.\n'), ['directive', 'paragraph'])
+ assert.deepEqual(kinds('!adf:widget\nPart.\n'), ['directive', 'paragraph'])
+ assert.deepEqual(kinds('!adf:mention\nPart.\n'), ['directive', 'paragraph'])
+ assert.deepEqual(faults('!adf:panel info\n\nPart.\n\n!adf:/panel\n'), [])
+ assert.deepEqual(faults('!adf:panel info\n> Part.\n!adf:/panel\n'), [])
+ assert.deepEqual(faults('!adf:panel info\n- !adf:panel warning\n Part.\n !adf:/panel\n!adf:/panel\n'), [])
+ assert.deepEqual(faults('!adf:panel info\n```\n!adf:/panel\n```\n!adf:/panel\n'), [])
+ assert.deepEqual(parseBlocks('!adf:panel info {panelColor="#ff0000"}\nPart.\n!adf:/panel\n').blocks, [
{
argument: 'info',
attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]),
- blocks: [{ kind: 'paragraph', position: { line: 2, offset: 37 }, text: 'Part.' }],
+ blocks: [{ kind: 'paragraph', position: { line: 2, offset: 39 }, text: 'Part.' }],
kind: 'directive',
name: 'panel',
position: { line: 1, offset: 0 },
},
])
- assert.deepEqual(parseBlocks('::rule\n').blocks, [
+ assert.deepEqual(parseBlocks('!adf:rule\n').blocks, [
{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule', position: { line: 1, offset: 0 } },
])
})
-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\n:::expand\nPart.\n:::::\n::::\n'), [])
- 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; \\::: keeps the line literal text'])
+test('closes the containers a closer names past as unclosed, and crosses no list item or blockquote edge', () => {
+ const unclosed = (name: string): string => `the ${name} container is unclosed: no !adf:/${name} follows inside the block holding it; \\!adf: keeps the prefix literal`
+ const unopened = (name: string): string => `the closer !adf:/${name} closes no ${name} container open where it stands; \\!adf: keeps the prefix literal`
+ assert.deepEqual(faults('!adf:panel info\n!adf:expand\nPart.\n!adf:/expand\n!adf:/panel\n'), [])
+ assert.deepEqual(faults('!adf:panel info\n!adf:expand\n!adf:layoutSection\nPart.\n!adf:/panel\n'), [unclosed('expand')])
+ assert.deepEqual(faults('!adf:panel info\nPart.\n'), [unclosed('panel')])
+ assert.deepEqual(faults('- !adf:panel info\n\nPart.\n'), [unclosed('panel')])
+ assert.deepEqual(faults('> !adf:panel info\n> Part.\n!adf:/panel\n'), [unclosed('panel'), unopened('panel')])
+ assert.deepEqual(faults('!adf:panel info\n> !adf:/panel\n!adf:/panel\n'), [unopened('panel')])
+ assert.deepEqual(faults('Part.\n\n!adf:/panel\n'), [unopened('panel')])
+ assert.deepEqual(kinds('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['fault', 'paragraph'])
+ assert.deepEqual(kinds('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n'), ['fault', 'fault'])
})
diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts
index db40412..4983ae1 100644
--- a/src/markdown/parse/blocks.ts
+++ b/src/markdown/parse/blocks.ts
@@ -3,7 +3,6 @@ import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
import type { LinkDefinition } from '../link-syntax.ts'
import {
atxHeading,
- claimsDirectiveLine,
claimsPipeLine,
closingCodeFence,
decodeTextEscapes,
@@ -16,7 +15,8 @@ import {
replaceNullCharacters,
setextHeadingLevel,
} from '../commonmark-grammar.ts'
-import { directiveLineEscape, malformedDirective, readDirectiveLine } from '../directive-syntax.ts'
+import { blockDirectiveForm } from '../block-directive-forms.ts'
+import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { readLinkDefinitions } from '../link-reference-definitions.ts'
@@ -40,12 +40,11 @@ export type DirectiveBlock = Extract
type ListBlock = Extract
-type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[]; position: SourcePosition }
+type OpenDirective = { blocks: Block[]; depths: number[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
-type OpenContainer =
- | Extract
- | OpenDirective
- | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
+type EdgeContainer = Extract | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
+
+type OpenContainer = EdgeContainer | OpenDirective
type OpenLeaf = { position: SourcePosition } & (
| { closer: RegExp | undefined; construct: string; kind: 'html' }
@@ -60,15 +59,32 @@ type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; ind
// The line from an absolute column on: a tab a cut splits keeps the stop it is measured against.
type Line = { column: number; text: string }
-type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] }
+type LeafOpener = { index: number; position: SourcePosition }
+
+type Walk = ParsedBlocks & {
+ directiveDepths: Map
+ edges: { container: EdgeContainer; depth: number }[]
+ leaf: OpenLeaf | undefined
+ leafOpeners: Map>
+ position: SourcePosition
+ stack: OpenContainer[]
+}
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, position: { line: 1, offset: 0 }, stack: [] }
+ const walk: Walk = {
+ blocks: [],
+ definitions: new Map(),
+ directiveDepths: new Map(),
+ edges: [],
+ leaf: undefined,
+ leafOpeners: new Map(),
+ position: { line: 1, offset: 0 },
+ stack: [],
+ }
for (const line of sourceLines(markdown)) {
walk.position = line.position
readLine(walk, { column: 0, text: line.text })
@@ -100,22 +116,19 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean {
return leaf?.kind === 'fenced-code' || leaf?.kind === 'html'
}
+// A directive container has no continuation marker, so every line continues it.
function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } {
- let depth = 0
let rest = line
- for (const container of walk.stack) {
+ for (const { container, depth } of walk.edges) {
const next = continuesContainer(walk, container, rest)
- if (next === undefined) break
- depth += 1
+ if (next === undefined) return { depth, rest }
rest = next
}
- return { depth, rest }
+ return { depth: walk.stack.length, rest }
}
-function continuesContainer(walk: Walk, container: OpenContainer, line: Line): Line | undefined {
+function continuesContainer(walk: Walk, container: EdgeContainer, 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 (isBlankLine(line.text)) {
return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' }
@@ -177,14 +190,18 @@ function openList(start: number | undefined, position: SourcePosition): ListBloc
function openContainer(walk: Walk, start: ContainerStart): void {
const blocks: Block[] = []
if (start.kind === 'blockquote') {
- const blockquote: OpenContainer = { blocks, kind: 'blockquote', position: walk.position }
+ const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position }
currentBlocks(walk).push(blockquote)
- walk.stack.push(blockquote)
+ pushEdge(walk, blockquote)
return
}
const list = openedList(walk, start)
list.items.push(blocks)
- walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list })
+ pushEdge(walk, { blocks, indentation: start.indentation, kind: 'item', list })
+}
+
+function pushEdge(walk: Walk, container: EdgeContainer): void {
+ walk.edges.push({ container, depth: walk.stack.push(container) - 1 })
}
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
@@ -199,67 +216,90 @@ function openedList(walk: Walk, start: Extract
function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk)
- for (const container of walk.stack.slice(depth)) {
+ for (const container of dropContainers(walk, depth)) {
if (container.kind !== 'directive') continue
container.parent[container.index] = {
- fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`),
+ fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`),
kind: 'fault',
position: container.position,
}
}
- dropContainers(walk, depth)
}
-function dropContainers(walk: Walk, depth: number): void {
- walk.stack.length = depth
-}
-
-function openDirective(walk: Walk, directive: Extract): void {
- const block: DirectiveBlock = {
- argument: directive.argument,
- attributes: directive.attributes,
- blocks: directive.colons > leafColons ? [] : undefined,
- kind: 'directive',
- name: directive.name,
- position: walk.position,
+function dropContainers(walk: Walk, depth: number): OpenContainer[] {
+ const dropped = walk.stack.splice(depth)
+ for (const container of dropped) {
+ if (container.kind === 'directive') container.depths.pop()
+ else walk.edges.pop()
}
- const parent = currentBlocks(walk)
- parent.push(block)
- const { position } = block
- if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent, position })
+ return dropped
}
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
- 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)
+ if (directive.kind === 'closer') closeDirective(walk, directive.name)
+ else 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; ${directiveLineEscape}`))
+function openDirective(walk: Walk, directive: Extract): void {
+ const { name } = directive
+ const block: DirectiveBlock = {
+ argument: directive.argument,
+ attributes: directive.attributes,
+ blocks: blockDirectiveForm(name) === 'container' ? [] : undefined,
+ kind: 'directive',
+ name,
+ position: walk.position,
+ }
+ const parent = currentBlocks(walk)
+ const index = parent.push(block) - 1
+ const { position } = block
+ if (block.blocks === undefined) {
+ entryOf(walk.leafOpeners, parent, () => new Map()).set(name, { index, position })
return
}
- if (colons < enclosing.container.colons) {
- pushFault(walk, malformedDirective(`a closing fence is shorter than the ${enclosing.container.colons} colons it would close`))
- return
- }
- dropContainers(walk, enclosing.depth)
+ const depths = entryOf(walk.directiveDepths, name, (): number[] => [])
+ depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1)
}
-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 }
+function closeDirective(walk: Walk, name: string): void {
+ const closer = spellDirectiveCloser(name)
+ if (blockDirectiveForm(name) === 'leaf') {
+ faultLeafOpener(walk, name, malformedDirective(`${name} takes no body, so no ${closer} closes it; ${directiveEscape}`))
+ return
}
- return undefined
+ const depth = openDirectiveDepth(walk, name)
+ if (depth === undefined) {
+ pushFault(walk, malformedDirective(`the closer ${closer} closes no ${name} container open where it stands; ${directiveEscape}`))
+ return
+ }
+ closeContainers(walk, depth + 1)
+ dropContainers(walk, depth)
+}
+
+// A closer crosses no list item or blockquote edge.
+function openDirectiveDepth(walk: Walk, name: string): number | undefined {
+ const depth = walk.directiveDepths.get(name)?.at(-1)
+ return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth
+}
+
+function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
+ const blocks = currentBlocks(walk)
+ const openers = walk.leafOpeners.get(blocks)
+ const opener = openers?.get(name)
+ if (openers === undefined || opener === undefined) {
+ pushFault(walk, fault)
+ return
+ }
+ openers.delete(name)
+ blocks[opener.index] = { fault, kind: 'fault', position: opener.position }
+}
+
+function entryOf(map: Map, key: K, create: () => V): V {
+ const known = map.get(key)
+ if (known !== undefined) return known
+ const created = create()
+ map.set(key, created)
+ return created
}
function pushFault(walk: Walk, fault: ConvertFault): void {
@@ -271,7 +311,7 @@ function continuesLazily(walk: Walk, line: Line): boolean {
if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false
if (leadingColumns(line) >= indentedCodeColumns) return true
const opener = removeColumns(line, largestOpenerIndentation).text
- if (claimsDirectiveLine(opener) || claimsPipeLine(opener) || isThematicBreak(opener)) return false
+ if (readDirectiveLine(opener) !== undefined || claimsPipeLine(opener) || isThematicBreak(opener)) return false
return atxHeading(opener) === undefined && openingCodeFence(opener) === undefined && openingHtmlBlock(opener, true) === undefined
}
diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts
index eda3b72..8ee1a9a 100644
--- a/src/markdown/parse/directive-nodes.ts
+++ b/src/markdown/parse/directive-nodes.ts
@@ -4,14 +4,14 @@ import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
-import { attributeValue, directiveLineEscape, directivePrefix, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
+import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts'
-import { carryFence, carryName } from '../opaque-carry.ts'
+import { blockDirectiveForm } from '../block-directive-forms.ts'
+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'
@@ -26,10 +26,10 @@ export function readBlockDirectiveNode(
path: ConvertErrorPath,
): Result {
if (name === carryName) {
- return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryFence} fence`, path)
+ return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path)
}
const directive = blockDirective(name)
- if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name, directiveLineEscape), path)
+ if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
const argumentKey = blockArgument(name)
const rest = new Map(attributes)
rest.delete(marksAttribute)
@@ -53,7 +53,7 @@ export function readInlineDirectiveNode(
path: ConvertErrorPath,
): Result {
const directive = inlineDirective(name)
- if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name, inlineDirectiveEscape), path)
+ if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path)
const slot = directive.textAttribute
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
@@ -80,11 +80,8 @@ 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}`
- return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${form}, never the inline form` }
+ if (blockDirectiveForm(name) === undefined) return undefined
+ return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${directivePrefix}${name}, never the inline form` }
}
// spec/flavour.md, Inline nodes: the slot is plain text, its adjacent nodes already merged.
diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts
index 2fe973a..75908e1 100644
--- a/src/markdown/parse/markdown-to-adf.test.ts
+++ b/src/markdown/parse/markdown-to-adf.test.ts
@@ -132,30 +132,29 @@ test('reads a fenced code block, its info string the language', () => {
})
test('reads the codeBlock directive body as the node content, the info string its language', () => {
- const fenced = ':::codeBlock {wrap=true}\n```rust\nfn main() {}\n```\n:::\n'
+ const fenced = '!adf:codeBlock {wrap=true}\n```rust\nfn main() {}\n```\n!adf:/codeBlock\n'
assert.deepEqual(content(markdownToAdf(fenced)), [
{ attrs: { language: 'rust', wrap: true }, content: [text('fn main() {}')], type: 'codeBlock' },
])
- assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n```\n```\n:::\n')), [{ attrs: { wrap: true }, type: 'codeBlock' }])
- assert.deepEqual(content(markdownToAdf(':::codeBlock {language=""}\n```\nx\n```\n:::\n')), [
+ assert.deepEqual(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```\n```\n!adf:/codeBlock\n')), [{ attrs: { wrap: true }, type: 'codeBlock' }])
+ assert.deepEqual(content(markdownToAdf('!adf:codeBlock {language=""}\n```\nx\n```\n!adf:/codeBlock\n')), [
{ attrs: { language: '' }, content: [text('x')], type: 'codeBlock' },
])
- assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n fn()\n:::\n')), [
+ assert.deepEqual(content(markdownToAdf('!adf:codeBlock {wrap=true}\n fn()\n!adf:/codeBlock\n')), [
{ attrs: { wrap: true }, content: [text('fn()')], type: 'codeBlock' },
])
// The body is a CommonMark fence, so its info string decodes escapes the way any other fence's does.
- assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n```\\#c\nx\n```\n:::\n')), [
+ assert.deepEqual(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```\\#c\nx\n```\n!adf:/codeBlock\n')), [
{ attrs: { language: '#c', wrap: true }, content: [text('x')], type: 'codeBlock' },
])
})
test('names the slot a codeBlock spells its language outside of', () => {
const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back'
- assert.equal(content(markdownToAdf(':::codeBlock {language=rust wrap=true}\n```\nx\n```\n:::\n')), slot)
- assert.equal(content(markdownToAdf(':::codeBlock {language=rust}\n```sql\nx\n```\n:::\n')), slot)
- assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```adf\nx\n```\n:::\n')), slot)
- assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```a\\b\nx\n```\n:::\n')), slot)
- assert.equal(content(markdownToAdf('::codeBlock {wrap=true}\n')), 'unsupported-node-shape: codeBlock spells its body in the container form, :::, never the leaf form')
+ assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot)
+ assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
+ assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```carry\nx\n```\n!adf:/codeBlock\n')), slot)
+ assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot)
})
test('reads a pipe table into the header row and the body rows under it', () => {
@@ -205,33 +204,33 @@ test('names the pipe table whose rows open with no pipe', () => {
test('gives back the refusal an inline body holds, never the shape check above it', () => {
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(':::caption\na | b\n--- | ---\n:::\n')), bare)
- assert.deepEqual(position(markdownToAdf(':::caption\na | b\n--- | ---\n:::\n')), { line: 2, offset: 11 })
- assert.equal(code(markdownToAdf(':::caption\n| a |\n:::\n')), 'malformed-pipe-table')
- assert.equal(content(markdownToAdf(':::caption\n- a\n:::\n')), 'unsupported-node-shape: caption takes one paragraph as its body: this body is not one')
+ assert.equal(content(markdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), bare)
+ assert.deepEqual(position(markdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), { line: 2, offset: 13 })
+ assert.equal(code(markdownToAdf('!adf:caption\n| a |\n!adf:/caption\n')), 'malformed-pipe-table')
+ assert.equal(content(markdownToAdf('!adf:caption\n- a\n!adf:/caption\n')), 'unsupported-node-shape: caption takes one paragraph as its body: this body is not one')
})
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])
+ assert.deepEqual(content(markdownToAdf('- a\n\n!adf:listBreak\n\n- b\n')), parted)
+ assert.deepEqual(content(markdownToAdf('- a\n!adf:listBreak\n- b\n')), parted)
+ assert.deepEqual(content(markdownToAdf('1. a\n\n!adf:listBreak\n\n1. b\n')), [orderedList(1, item(paragraph('a'))), orderedList(1, item(paragraph('b')))])
+ assert.deepEqual(content(markdownToAdf('> - a\n> !adf:listBreak\n> - b\n')), [quote(...parted)])
+ assert.deepEqual(path(markdownToAdf('- a\n\n!adf: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('!adf: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])
+ assert.equal(content(markdownToAdf('!adf:listBreak\n')), parts)
+ assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n')), parts)
+ assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n\n1. b\n')), parts)
+ assert.equal(content(markdownToAdf('Part.\n\n!adf:listBreak\n\n- b\n')), parts)
+ const bare = 'unsupported-node-shape: listBreak spells the bare leaf form, !adf:listBreak: this one spells more'
+ assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak x\n\n- b\n')), bare)
+ assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak {id=x}\n\n- b\n')), bare)
+ assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n- b\n!adf:/listBreak\n')), 'malformed-directive: listBreak takes no body, so no !adf:/listBreak closes it; \\!adf: keeps the prefix literal')
+ assert.equal(content(markdownToAdf('!adf:listBreak{}\n')), 'unsupported-node-shape: listBreak takes the block form, !adf:listBreak, never the inline form')
+ assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:listBreak\n')), ['content', 1])
})
test('refuses the image a pipe cell holds no ADF node for', () => {
@@ -252,22 +251,24 @@ test('reads an indented code block where no paragraph is open', () => {
assert.deepEqual(content(markdownToAdf('Part.\n more\n')), [paragraph('Part. more')])
})
-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 {a=1 a=2}\n')), 'malformed-directive')
- assert.deepEqual(content(markdownToAdf(':10:30\n')), [paragraph(':10:30')])
- assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')])
+test('claims a block line the prefix opens with no directive to parse it', () => {
+ assert.equal(code(markdownToAdf('!adf:/panel\n')), 'malformed-directive')
+ assert.equal(code(markdownToAdf('!adf:panel info\n')), 'malformed-directive')
+ assert.equal(code(markdownToAdf('!adf:panel {a=1 a=2}\n!adf:/panel\n')), 'malformed-directive')
+ assert.equal(content(markdownToAdf('!adf:Panel\n')), 'malformed-directive: an unescaped !adf: completes no directive; \\!adf: keeps the prefix literal')
+ assert.deepEqual(content(markdownToAdf(':::panel info\nPart.\n:::\n')), [paragraph(':::panel info Part. :::')])
+ assert.deepEqual(content(markdownToAdf('::rule\n')), [paragraph('::rule')])
})
test('reads the three directive forms into the nodes the tables name', () => {
- assert.deepEqual(content(markdownToAdf('::rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
- assert.deepEqual(content(markdownToAdf('::paragraph\n')), [{ type: 'paragraph' }])
- assert.deepEqual(content(markdownToAdf(' :::panel info\nPart.\n:::\n')), [
+ assert.deepEqual(content(markdownToAdf('!adf:rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
+ assert.deepEqual(content(markdownToAdf('!adf:paragraph\n!adf:/paragraph\n')), [{ type: 'paragraph' }])
+ assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:/bulletList\n')), [{ type: 'bulletList' }])
+ assert.deepEqual(content(markdownToAdf(' !adf:panel info\nPart.\n !adf:/panel\n')), [
{ attrs: { panelType: 'info' }, content: [paragraph('Part.')], type: 'panel' },
])
- assert.deepEqual(content(markdownToAdf(':::blockquote {localId=a-1}\n:::\n')), [{ attrs: { localId: 'a-1' }, type: 'blockquote' }])
- assert.deepEqual(content(markdownToAdf(':::heading {level=2 localId=a-1}\nPart.\n:::\n')), [
+ assert.deepEqual(content(markdownToAdf('!adf:blockquote {localId=a-1}\n!adf:/blockquote\n')), [{ attrs: { localId: 'a-1' }, type: 'blockquote' }])
+ assert.deepEqual(content(markdownToAdf('!adf:heading {level=2 localId=a-1}\nPart.\n!adf:/heading\n')), [
{ attrs: { level: 2, localId: 'a-1' }, content: [text('Part.')], type: 'heading' },
])
assert.deepEqual(content(markdownToAdf('Part!adf:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }])
@@ -275,53 +276,82 @@ test('reads the three directive forms into the nodes the tables name', () => {
test('names the directive form a node CommonMark spells refuses', () => {
const named = (type: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form`
- assert.equal(content(markdownToAdf('::rule\n')), named('rule'))
- assert.equal(content(markdownToAdf(':::blockquote\nPart.\n:::\n')), named('blockquote'))
- assert.equal(content(markdownToAdf(':::heading {level=2}\nPart.\n:::\n')), named('heading'))
- assert.equal(content(markdownToAdf(':::paragraph\nPart.\n:::\n')), named('paragraph'))
- assert.equal(content(markdownToAdf('::::bulletList\n:::listItem\nPart.\n:::\n::::\n')), named('bulletList'))
+ assert.equal(content(markdownToAdf('!adf:rule\n')), named('rule'))
+ assert.equal(content(markdownToAdf('!adf:blockquote\nPart.\n!adf:/blockquote\n')), named('blockquote'))
+ assert.equal(content(markdownToAdf('!adf:heading {level=2}\nPart.\n!adf:/heading\n')), named('heading'))
+ assert.equal(content(markdownToAdf('!adf:paragraph\nPart.\n!adf:/paragraph\n')), named('paragraph'))
+ assert.equal(content(markdownToAdf('!adf:bulletList\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/bulletList\n')), named('bulletList'))
// The item whose first line reads back as a thematic break keeps the directive form the emitter falls back to.
- assert.deepEqual(content(markdownToAdf('::::bulletList\n:::listItem\n---\n:::\n::::\n')), [bulletList(item({ type: 'rule' }))])
+ assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))])
})
// 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'
+ const destination = '!adf:blockquote\n[t](https://example.com/a\\b)\n!adf:/blockquote\n'
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', () => {
- assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name')
- assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\::: keeps the line literal text')
+ assert.equal(code(markdownToAdf('!adf:widget info\nx\n!adf:/widget\n')), 'unknown-directive-name')
+ assert.equal(content(markdownToAdf('!adf:widget\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\!adf: keeps the prefix literal')
assert.equal(content(markdownToAdf('!adf:widget[x]\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\!adf: keeps the prefix literal')
assert.equal(content(markdownToAdf('ratio a!adf:b[c]{d}\n')), 'malformed-directive: an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
- assert.deepEqual(path(markdownToAdf('Part.\n\n::widget\n')), ['content', 1])
- assert.equal(content(markdownToAdf('Part.\n:::x\n')), 'malformed-directive: a container fenced with 3 colons is unclosed')
- assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1])
+ assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:widget\n')), ['content', 1])
+})
+
+test('names the container no closer closes inside the block holding it', () => {
+ const unclosed = (name: string): string => `malformed-directive: the ${name} container is unclosed: no !adf:/${name} follows inside the block holding it; \\!adf: keeps the prefix literal`
+ assert.equal(content(markdownToAdf('Part.\n!adf:expand\n')), unclosed('expand'))
+ assert.deepEqual(path(markdownToAdf('Part.\n!adf:expand\n')), ['content', 1])
+ assert.equal(content(markdownToAdf('!adf:bulletList\n')), unclosed('bulletList'))
+ assert.equal(content(markdownToAdf('- !adf:panel info\n Part.\n!adf:/panel\n')), unclosed('panel'))
+ assert.equal(content(markdownToAdf('> !adf:panel info\n> - a\n> !adf:/panel\n')), unclosed('panel'))
+ assert.equal(content(markdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), unclosed('expand'))
+ assert.deepEqual(path(markdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), ['content', 0, 'content', 0])
+})
+
+test('names the closer that finds no container open where it stands', () => {
+ const unopened = (name: string): string => `malformed-directive: the closer !adf:/${name} closes no ${name} container open where it stands; \\!adf: keeps the prefix literal`
+ assert.equal(content(markdownToAdf('Part.\n\n!adf:/panel\n')), unopened('panel'))
+ assert.equal(content(markdownToAdf('!adf:panel info\n> !adf:/panel\n!adf:/panel\n')), unopened('panel'))
+ assert.equal(content(markdownToAdf('!adf:panel info\n- !adf:/panel\n!adf:/panel\n')), unopened('panel'))
+ assert.equal(content(markdownToAdf('!adf:panel info\nPart.\n!adf:/expand\n!adf:/panel\n')), unopened('expand'))
+})
+
+test('names the leaf given a body at its opener, ahead of any refusal the leaf holds itself', () => {
+ const body = (name: string): string => `malformed-directive: ${name} takes no body, so no !adf:/${name} closes it; \\!adf: keeps the prefix literal`
+ assert.equal(content(markdownToAdf('!adf:rule\nPart.\n!adf:/rule\n')), body('rule'))
+ assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:rule\nPart.\n!adf:/rule\n')), { line: 3, offset: 7 })
+ assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n!adf:/listBreak\n\n- b\n')), body('listBreak'))
+ assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\nPart.\n!adf:/listBreak\n\n- b\n')), body('listBreak'))
+ assert.deepEqual(path(markdownToAdf('!adf:rule {localId=a-1}\n!adf:rule {localId=a-2}\nPart.\n!adf:/rule\n')), ['content', 1])
+ assert.deepEqual(path(markdownToAdf('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n')), ['content', 0])
+ assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:/rule\n')), ['content', 1])
+ assert.deepEqual(path(markdownToAdf('!adf:panel info\n!adf:rule {localId=a-1}\n!adf:/panel\n!adf:/rule\n')), ['content', 1])
})
test('names the position a directive name the other one spells belongs to', () => {
- assert.equal(content(markdownToAdf(':::em\na\n:::\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive')
- assert.equal(content(markdownToAdf('::underline\n')), 'unsupported-node-shape: underline is spelled !adf:underline[…], never as a block directive')
- assert.equal(content(markdownToAdf('::text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, !adf:text{…}, never the block form')
- assert.equal(content(markdownToAdf('::date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, !adf:date{…}, never the block form')
- assert.equal(content(markdownToAdf('!adf:paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, :::paragraph, never the inline form')
- assert.equal(content(markdownToAdf('!adf:rule[a]\n')), 'unsupported-node-shape: rule takes the block form, ::rule, never the inline form')
- assert.equal(code(markdownToAdf(':::widget\na\n:::\n')), 'unknown-directive-name')
+ assert.equal(content(markdownToAdf('!adf:em\na\n!adf:/em\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive')
+ assert.equal(content(markdownToAdf('!adf:underline\n')), 'unsupported-node-shape: underline is spelled !adf:underline[…], never as a block directive')
+ assert.equal(content(markdownToAdf('!adf:text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, !adf:text{…}, never the block form')
+ assert.equal(content(markdownToAdf('!adf:date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, !adf:date{…}, never the block form')
+ assert.equal(content(markdownToAdf('!adf:paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, !adf:paragraph, never the inline form')
+ assert.equal(content(markdownToAdf('!adf:rule[a]\n')), 'unsupported-node-shape: rule takes the block form, !adf:rule, never the inline form')
+ assert.equal(code(markdownToAdf('!adf:widget\na\n!adf:/widget\n')), 'unknown-directive-name')
assert.equal(code(markdownToAdf('!adf:widget[a]\n')), 'unknown-directive-name')
})
test('names the reserved carry name a block directive spells', () => {
- const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the adf fence'
- assert.equal(content(markdownToAdf('::carry\n')), reserved)
- assert.equal(content(markdownToAdf(':::carry\nx\n:::\n')), reserved)
- assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }])
+ const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the carry fence'
+ assert.equal(content(markdownToAdf('!adf:carry\n')), reserved)
+ assert.equal(content(markdownToAdf('!adf:carry\nx\n!adf:/carry\n')), reserved)
+ assert.deepEqual(content(markdownToAdf('```adf\nx\n```\n')), [{ attrs: { language: 'adf' }, content: [text('x')], type: 'codeBlock' }])
})
const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}'
-test('reads the adf fence back to the node its JSON holds', () => {
- assert.deepEqual(content(markdownToAdf('```adf\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [
+test('reads the carry fence back to the node its JSON holds', () => {
+ assert.deepEqual(content(markdownToAdf('```carry\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [
{ attrs: { url: 'https://example.com/x' }, type: 'blockCard' },
])
})
@@ -334,22 +364,22 @@ test('reads the inline carry back to the node its json attribute holds', () => {
test('names the invalid JSON no opaque carry holds', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON'
- assert.equal(content(markdownToAdf('```adf\n{"type":\n```\n')), invalid)
- assert.equal(content(markdownToAdf('```adf\n```\n')), invalid)
+ assert.equal(content(markdownToAdf('```carry\n{"type":\n```\n')), invalid)
+ assert.equal(content(markdownToAdf('```carry\n```\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid)
})
test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
- assert.equal(content(markdownToAdf('```adf\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`)
+ assert.equal(content(markdownToAdf('```carry\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
})
test('names the node JSON an opaque carry restores alone', () => {
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node"
- assert.equal(content(markdownToAdf('```adf\n[]\n```\n')), node)
+ assert.equal(content(markdownToAdf('```carry\n[]\n```\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node)
})
@@ -363,7 +393,7 @@ test('names the shape the inline carry reads alone', () => {
test('holds a carried JSON value to the nesting its position leaves', () => {
const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}`
- const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n`
+ const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`carry\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n`
const deeper = (levels: number): string => `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${levels} levels its position leaves`
assert.equal(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
assert.equal(
@@ -376,7 +406,7 @@ test('holds a carried JSON value to the nesting its position leaves', () => {
test('names the number no JSON spelling carries in an opaque carry', () => {
const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell'
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named)
- assert.equal(content(markdownToAdf('```adf\n1e999\n```\n')), named)
+ assert.equal(content(markdownToAdf('```carry\n1e999\n```\n')), named)
})
test('names the mark spelling no opaque carry sits inside', () => {
@@ -401,37 +431,37 @@ test('keeps the carry a mark spelling does not wrap', () => {
})
test('reads each attribute value as the type its section assigns', () => {
- assert.deepEqual(content(markdownToAdf('::media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [
+ assert.deepEqual(content(markdownToAdf('!adf:media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [
{ attrs: { height: 10, id: 'a-1', type: 'file', url: '/x y', width: 20.5 }, type: 'media' },
])
- assert.deepEqual(content(markdownToAdf(':::table {isNumberColumnEnabled=true}\n:::\n')), [{ attrs: { isNumberColumnEnabled: true }, type: 'table' }])
- assert.deepEqual(content(markdownToAdf(':::tableCell {colwidth="[340,420]"}\n:::\n')), [{ attrs: { colwidth: [340, 420] }, type: 'tableCell' }])
- assert.deepEqual(content(markdownToAdf('::rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
+ assert.deepEqual(content(markdownToAdf('!adf:table {isNumberColumnEnabled=true}\n!adf:/table\n')), [{ attrs: { isNumberColumnEnabled: true }, type: 'table' }])
+ assert.deepEqual(content(markdownToAdf('!adf:tableCell {colwidth="[340,420]"}\n!adf:/tableCell\n')), [{ attrs: { colwidth: [340, 420] }, type: 'tableCell' }])
+ assert.deepEqual(content(markdownToAdf('!adf:rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
})
test('reads the reserved marks key as the node array it spells', () => {
- assert.deepEqual(content(markdownToAdf('::rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), [{ marks: [em], type: 'rule' }])
- assert.deepEqual(content(markdownToAdf('::rule {localId=a-1 marks="[{\\"attrs\\":{\\"mode\\":\\"wide\\"},\\"type\\":\\"breakout\\"}]"}\n')), [
+ assert.deepEqual(content(markdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), [{ marks: [em], type: 'rule' }])
+ assert.deepEqual(content(markdownToAdf('!adf:rule {localId=a-1 marks="[{\\"attrs\\":{\\"mode\\":\\"wide\\"},\\"type\\":\\"breakout\\"}]"}\n')), [
{ attrs: { localId: 'a-1' }, marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], type: 'rule' },
])
})
test('names the marks key no marks array reads back from', () => {
const named = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON: this one is not'
- assert.equal(content(markdownToAdf('::rule {marks="[]"}\n')), named)
- assert.equal(content(markdownToAdf('::rule {marks="[1]"}\n')), named)
- assert.equal(content(markdownToAdf('::rule {marks="{}"}\n')), named)
- assert.equal(content(markdownToAdf('::rule {marks=x}\n')), named)
- assert.equal(content(markdownToAdf('::rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named)
+ assert.equal(content(markdownToAdf('!adf:rule {marks="[]"}\n')), named)
+ assert.equal(content(markdownToAdf('!adf:rule {marks="[1]"}\n')), named)
+ assert.equal(content(markdownToAdf('!adf:rule {marks="{}"}\n')), named)
+ assert.equal(content(markdownToAdf('!adf:rule {marks=x}\n')), named)
+ assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named)
})
test('names the attribute a node holds no reading for', () => {
- assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute: this one spells it')
- assert.equal(content(markdownToAdf('::media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number')
- assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean')
- assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number')
- assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
- assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}')
+ assert.equal(content(markdownToAdf('!adf:rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute: this one spells it')
+ assert.equal(content(markdownToAdf('!adf:media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number')
+ assert.equal(content(markdownToAdf('!adf:table {isNumberColumnEnabled=yes}\n!adf:/table\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean')
+ assert.equal(content(markdownToAdf('!adf:media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number')
+ assert.equal(content(markdownToAdf('!adf:tableCell {colwidth="[340,"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
+ assert.equal(content(markdownToAdf('!adf:panel info {panelType=note}\nx\n!adf:/panel\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}')
assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
})
@@ -439,27 +469,24 @@ test('names the depth an attribute value nests past, never the kind the JSON rea
const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}`
const deeper = (key: string, type: string): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
- assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${nested(largestNesting + 1)}"}\n:::\n`)), deeper('colwidth', 'tableCell'))
- assert.equal(content(markdownToAdf(`::rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule'))
- assert.equal(content(markdownToAdf(`::rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule'))
- assert.equal(content(markdownToAdf(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number')
+ assert.equal(content(markdownToAdf(`!adf:tableCell {colwidth="${nested(largestNesting + 1)}"}\n!adf:/tableCell\n`)), deeper('colwidth', 'tableCell'))
+ assert.equal(content(markdownToAdf(`!adf:rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule'))
+ assert.equal(content(markdownToAdf(`!adf:rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule'))
+ assert.equal(content(markdownToAdf(`!adf:media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number')
})
test('names the attribute value spelled outside the canonical form', () => {
- assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1')
- assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20')
- assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340, 420]"}\n:::\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="[340,420]"')
+ assert.equal(content(markdownToAdf('!adf:rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1')
+ assert.equal(content(markdownToAdf('!adf:media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20')
+ assert.equal(content(markdownToAdf('!adf:tableCell {colwidth="[340, 420]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="[340,420]"')
})
test('names the argument and the body a node takes no reading for', () => {
- assert.equal(content(markdownToAdf('::rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one')
- assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: rule holds no content: this one holds some')
- assert.equal(content(markdownToAdf('::bulletList\n')), 'unsupported-node-shape: bulletList spells its body in the container form, :::, never the leaf form')
- assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::, never an empty container')
- assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one')
- assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one')
- assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\nx\n:::\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one')
- assert.equal(content(markdownToAdf(':::paragraph\n\n:::\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
+ assert.equal(content(markdownToAdf('!adf:rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one')
+ assert.equal(content(markdownToAdf('!adf:paragraph\nOne.\n\nTwo.\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one')
+ assert.equal(content(markdownToAdf('!adf:paragraph\n---\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one')
+ assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one')
+ assert.equal(content(markdownToAdf('!adf:paragraph\n\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
assert.equal(content(markdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some')
})
@@ -500,12 +527,12 @@ test('refuses the raw HTML no element mapping carries', () => {
assert.equal(code(markdownToAdf('\n')), ['content', 1])
- assert.equal(code(markdownToAdf('
\nx\n\n:::\n')), 'unmappable-html')
+ assert.equal(code(markdownToAdf('
\nx\n\n!adf:/panel\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('
\n- x\n
\n')), 'unmappable-html')
})
test('swallows an HTML block ahead of the claim a line inside it would make', () => {
- assert.equal(code(markdownToAdf('\n')), 'unmappable-html')
+ assert.equal(code(markdownToAdf('\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('
\n| x |\n
\n')), 'unmappable-html')
})
@@ -514,15 +541,15 @@ test('names the line and the offset in the input a refusal sits at, the innermos
assert.deepEqual(position(markdownToAdf('Part.\n\n
\n')), { line: 3, offset: 7 })
assert.deepEqual(position(markdownToAdf('> Part.\n>\n> a
b\n')), { line: 3, offset: 10 })
assert.deepEqual(position(markdownToAdf('- Part.\n- a
b\n')), { line: 2, offset: 8 })
- assert.deepEqual(position(markdownToAdf('Part.\n\n:::panel info\nMore.\n')), { line: 3, offset: 7 })
+ assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:panel info\nMore.\n')), { line: 3, offset: 7 })
assert.deepEqual(position(markdownToAdf('x\n\na
b\n===\n')), { line: 3, offset: 3 })
- assert.deepEqual(position(markdownToAdf('x\n\n```adf\n{\n```\n')), { line: 3, offset: 3 })
+ assert.deepEqual(position(markdownToAdf('x\n\n```carry\n{\n```\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('a\nb
c\n')), { line: 1, offset: 0 })
assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n
\r\n')), { line: 3, offset: 9 })
assert.deepEqual(position(markdownToAdf('a\u0000b\n\n
\n')), { line: 3, offset: 5 })
- assert.deepEqual(position(markdownToAdf(':::caption\na
b\n:::\n')), { line: 2, offset: 11 })
- assert.deepEqual(position(markdownToAdf('x\n\n:::caption\n- a\n:::\n')), { line: 3, offset: 3 })
+ assert.deepEqual(position(markdownToAdf('!adf:caption\na
b\n!adf:/caption\n')), { line: 2, offset: 13 })
+ assert.deepEqual(position(markdownToAdf('x\n\n!adf:caption\n- a\n!adf:/caption\n')), { line: 3, offset: 3 })
})
test('names the line the text a paragraph keeps starts on, never a definition line it gave up', () => {
@@ -530,7 +557,7 @@ test('names the line the text a paragraph keeps starts on, never a definition li
assert.deepEqual(position(markdownToAdf('[a]: /a\n[b]: /b\n[c]: /c\n[d]: /d\n
x\n')), { line: 5, offset: 32 })
assert.deepEqual(position(markdownToAdf('[a]:\n
\n"Title"\nb\n')), { line: 4, offset: 23 })
assert.deepEqual(position(markdownToAdf('> [a]: /url\n> b\n')), { line: 2, offset: 12 })
- assert.deepEqual(position(markdownToAdf(':::caption\n[a]: /url\nb\n:::\n')), { line: 3, offset: 21 })
+ assert.deepEqual(position(markdownToAdf('!adf:caption\n[a]: /url\nb\n!adf:/caption\n')), { line: 3, offset: 23 })
})
test('gives up the link reference definitions a paragraph opens with', () => {
@@ -626,13 +653,13 @@ test('folds a lazy continuation into the paragraph the container holds', () => {
})
test('ends a lazy continuation at a claimed line', () => {
- assert.equal(code(markdownToAdf('> Part.\n:::\n')), 'malformed-directive')
- assert.deepEqual(path(markdownToAdf('> Part.\n:::\n')), ['content', 1])
+ assert.equal(code(markdownToAdf('> Part.\n!adf:/panel\n')), 'malformed-directive')
+ assert.deepEqual(path(markdownToAdf('> Part.\n!adf:/panel\n')), ['content', 1])
assert.equal(code(markdownToAdf('- Part.\n| x |\n')), 'malformed-pipe-table')
})
test('names the block the claim inside a container opens', () => {
- assert.deepEqual(path(markdownToAdf('> Part.\n>\n> :::x\n')), ['content', 0, 'content', 1])
+ assert.deepEqual(path(markdownToAdf('> Part.\n>\n> !adf:expand\n')), ['content', 0, 'content', 1])
assert.deepEqual(path(markdownToAdf('- Part.\n- | x |\n')), ['content', 0, 'content', 1, 'content', 0])
})
@@ -642,6 +669,11 @@ test('refuses input nested deeper than the parser carries', () => {
const marks = (levels: number): string => `${'!adf:underline['.repeat(levels)}a${']'.repeat(levels)}\n`
assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth')
assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }])
+ const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('')
+ const repeated = (name: string): string[] => Array.from({ length: largestNesting }, () => name)
+ assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok)
+ assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 })
+ assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth')
})
test('decodes the backslash escapes CommonMark spells, and keeps the rest literal', () => {
@@ -670,9 +702,9 @@ test('reads a code span, its content literal', () => {
assert.deepEqual(content(markdownToAdf('` `\n')), [{ content: [codeSpan(' ')], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('`a\nb`\n')), [{ content: [codeSpan('a b')], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('`foo``bar`\n')), [{ content: [codeSpan('foo``bar')], type: 'paragraph' }])
- assert.deepEqual(content(markdownToAdf('`:::panel` `~~x~~` `\\*` `&`\n')), [
+ assert.deepEqual(content(markdownToAdf('`!adf:/panel` `~~x~~` `\\*` `&`\n')), [
{
- content: [codeSpan(':::panel'), text(' '), codeSpan('~~x~~'), text(' '), codeSpan('\\*'), text(' '), codeSpan('&')],
+ content: [codeSpan('!adf:/panel'), text(' '), codeSpan('~~x~~'), text(' '), codeSpan('\\*'), text(' '), codeSpan('&')],
type: 'paragraph',
},
])
diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts
index 07aa667..ace6616 100644
--- a/src/markdown/parse/markdown-to-adf.ts
+++ b/src/markdown/parse/markdown-to-adf.ts
@@ -3,7 +3,7 @@ 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 { carryFence, readCarriedBlock } from '../opaque-carry.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'
@@ -43,7 +43,7 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path
// 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) {
+ if (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()
@@ -94,14 +94,10 @@ function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result {
const { contentModel, node } = read
- if (blocks === undefined) {
- if (contentModel === 'none' || contentModel === 'inline') return success(node)
- return failure('unsupported-node-shape', `${node.type} spells its body in the container form, :::, never the leaf form`, path)
- }
- if (contentModel === 'none') return failure('unsupported-node-shape', `${node.type} holds no content: this one holds some`, path)
+ if (blocks === undefined) return success(node)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
- if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth)
- return inlineBodyNode(node, blocks, definitions, path)
+ if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path)
+ return containerNode(node, blocks, definitions, path, depth)
}
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result {
@@ -133,7 +129,7 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
}
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result {
- if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::, never an empty container`, path)
+ if (blocks.length === 0) return success(node)
const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
@@ -161,7 +157,7 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
}
function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result {
- if (language === carryFence) {
+ if (language === carryName) {
const carried = readCarriedBlock(text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path)
return success(carried.value)
diff --git a/src/markdown/text-directive.ts b/src/markdown/text-directive.ts
index 4cdea77..05ce403 100644
--- a/src/markdown/text-directive.ts
+++ b/src/markdown/text-directive.ts
@@ -1,5 +1,5 @@
import type { DirectiveSpan, Read } from './directive-syntax.ts'
-import { readSoleStringAttribute, spellAttributes, spellLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
+import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
const name = 'text'
const whitespaceRun = /^(?:[ \t]+|\n+)$/
@@ -7,7 +7,7 @@ const whitespaceRun = /^(?:[ \t]+|\n+)$/
export const textDirectiveName = name
export function spellTextDirective(text: string): string {
- return spellLeafDirective(name, spellAttributes([[name, spellStringAttribute(text)]]))
+ return spellInlineLeafDirective(name, spellAttributes([[name, spellStringAttribute(text)]]))
}
export function readTextDirective(span: DirectiveSpan): Read | undefined {
diff --git a/todo.md b/todo.md
index 5000451..e1db05f 100644
--- a/todo.md
+++ b/todo.md
@@ -74,8 +74,14 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
them: `readNestedDirective` restarts its depth counter per level, so each parse level
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
- guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents
- nobody typed.
+ guard. A fifth predates 12c: the list-item walk re-scans the rest of a line once per item
+ level — `isThematicBreak` in `containerStart` on an opener line, `isBlankLine` and
+ `leadingColumns` in `continuesContainer` on a continuation line, and a blank line
+ continues every open item without consuming input; 30000 nested items take 4.4s at 59 KB
+ (the stability-reviewer, 2026-09-16). §11's scanning rule is
+ the whole argument; the pipeline persona feeds documents nobody typed.
+ `readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
+ complexity (the maintainer, 2026-09-16).
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its