From f328ed4cabeaffe5e1e4231c2b74cc59b59c3e2a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 1 Sep 2026 08:12:03 +0200 Subject: [PATCH 1/3] Read the three directive forms, their attributes and the fences that nest them --- AGENTS.md | 4 +- corpus/errors/directive-fence-unclosed.error | 1 + corpus/errors/directive-fence-unclosed.md | 2 + corpus/errors/directive-inline-unclosed.error | 1 + corpus/errors/directive-inline-unclosed.md | 1 + corpus/errors/directive-name-unknown.error | 1 + corpus/errors/directive-name-unknown.md | 3 + spec/flavour.md | 6 +- src/markdown/directive-attributes.ts | 47 ---- src/markdown/directive-syntax.test.ts | 129 +++++++++ src/markdown/directive-syntax.ts | 250 ++++++++++++++++++ src/markdown/emit/block-directive-spelling.ts | 2 +- .../emit/inline-directive-spelling.ts | 2 +- src/markdown/emit/inline-line.ts | 2 +- src/markdown/emit/line-escaping.ts | 2 +- src/markdown/mark-spellings.ts | 2 +- src/markdown/opaque-carry.ts | 2 +- src/markdown/parse/blocks.test.ts | 36 ++- src/markdown/parse/blocks.ts | 99 +++++-- src/markdown/parse/inline-content.ts | 17 ++ src/markdown/parse/markdown-to-adf.test.ts | 24 +- src/markdown/parse/markdown-to-adf.ts | 20 +- src/result.ts | 6 +- todo.md | 11 +- 24 files changed, 581 insertions(+), 89 deletions(-) create mode 100644 corpus/errors/directive-fence-unclosed.error create mode 100644 corpus/errors/directive-fence-unclosed.md create mode 100644 corpus/errors/directive-inline-unclosed.error create mode 100644 corpus/errors/directive-inline-unclosed.md create mode 100644 corpus/errors/directive-name-unknown.error create mode 100644 corpus/errors/directive-name-unknown.md delete mode 100644 src/markdown/directive-attributes.ts create mode 100644 src/markdown/directive-syntax.test.ts create mode 100644 src/markdown/directive-syntax.ts diff --git a/AGENTS.md b/AGENTS.md index 1ea936b..ce93fec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,9 @@ cause; where one cause recurs across node types or across directions, one code c `path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever direction hits it. A claim code names the spelling claimed, never the node that spelling would have built: a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no -spelling writes rides the carry with its node. +spelling writes rides the carry with its node. A directive whose name reads back to no node is +`unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that +apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. ## 9. Release automation diff --git a/corpus/errors/directive-fence-unclosed.error b/corpus/errors/directive-fence-unclosed.error new file mode 100644 index 0000000..4f35411 --- /dev/null +++ b/corpus/errors/directive-fence-unclosed.error @@ -0,0 +1 @@ +malformed-directive diff --git a/corpus/errors/directive-fence-unclosed.md b/corpus/errors/directive-fence-unclosed.md new file mode 100644 index 0000000..24f4d00 --- /dev/null +++ b/corpus/errors/directive-fence-unclosed.md @@ -0,0 +1,2 @@ +:::panel info +Part. diff --git a/corpus/errors/directive-inline-unclosed.error b/corpus/errors/directive-inline-unclosed.error new file mode 100644 index 0000000..4f35411 --- /dev/null +++ b/corpus/errors/directive-inline-unclosed.error @@ -0,0 +1 @@ +malformed-directive diff --git a/corpus/errors/directive-inline-unclosed.md b/corpus/errors/directive-inline-unclosed.md new file mode 100644 index 0000000..e035c81 --- /dev/null +++ b/corpus/errors/directive-inline-unclosed.md @@ -0,0 +1 @@ +Part :mention[@A and more. diff --git a/corpus/errors/directive-name-unknown.error b/corpus/errors/directive-name-unknown.error new file mode 100644 index 0000000..e347d54 --- /dev/null +++ b/corpus/errors/directive-name-unknown.error @@ -0,0 +1 @@ +unknown-directive-name diff --git a/corpus/errors/directive-name-unknown.md b/corpus/errors/directive-name-unknown.md new file mode 100644 index 0000000..0143c08 --- /dev/null +++ b/corpus/errors/directive-name-unknown.md @@ -0,0 +1,3 @@ +:::widget info +Part. +::: diff --git a/spec/flavour.md b/spec/flavour.md index 7a93968..c3d4f65 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -87,11 +87,15 @@ least the opening's length, and a container's fence is longer than every directi anywhere in its body, however deeply a list item or blockquote nests it; a colon run inside a code fence or opaque carry is content. Canonical form uses minimal lengths. Directive fence lines follow code-fence indentation (up to three spaces relative to their -container); trailing whitespace on a fence line is tolerated in input, never emitted. +container). **Leaf block**: `::name arg {attrs}` — a block-position node with no body, `arg` reading as above. +Canonical spacing is the only spacing input reads: one space parts the name, `arg` and `{attrs}`, +and one parts each attribute pair, with no padding inside the braces. Trailing whitespace on a +directive block line is tolerated in input, never emitted. + **Claiming at block level**, symmetric with inline: a line whose leading run of two or more colons is followed immediately by a name character is claimed and must parse fully as a container opening or a leaf, else it is a named error. A bare colon-run line is a closing fence while a diff --git a/src/markdown/directive-attributes.ts b/src/markdown/directive-attributes.ts deleted file mode 100644 index 405ba07..0000000 --- a/src/markdown/directive-attributes.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { JsonValue } from '../json-value.ts' -import type { VocabularyPair } from '../adf/attribute-vocabulary.ts' -import { serializeCanonicalJson } from '../canonical-json.ts' - -const bareToken = /^[A-Za-z0-9_-]+$/ -const inlineDirectiveOpener = /:[a-z][A-Za-z0-9]*[[{]/y - -// spec/flavour.md, Attributes. -const quotedEscapes = /[&<`|]/g - -export function isBareToken(text: string): boolean { - return bareToken.test(text) -} - -export function opensInlineDirective(text: string, index: number): boolean { - inlineDirectiveOpener.lastIndex = index - return inlineDirectiveOpener.test(text) -} - -export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { - if (pairs.length === 0) return '' - const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`) - return `{${spelled.join(' ')}}` -} - -export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, string][] { - return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)]) -} - -export function spellJsonAttribute(value: JsonValue): string { - return quote(serializeCanonicalJson(value, 'compact')) -} - -export function spellStringAttribute(text: string): string { - return isBareToken(text) ? text : quote(text) -} - -function spellAttributeValue(pair: VocabularyPair): string { - if (pair.kind === 'boolean') return `${pair.value}` - if (pair.kind === 'json') return spellJsonAttribute(pair.value) - if (pair.kind === 'number') return spellStringAttribute(JSON.stringify(pair.value)) - return spellStringAttribute(pair.value) -} - -function quote(text: string): string { - return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`) -} diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts new file mode 100644 index 0000000..fd4fdb5 --- /dev/null +++ b/src/markdown/directive-syntax.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { DirectiveLine } from './directive-syntax.ts' +import { largestNesting } from '../nesting.ts' +import { readDirectiveLine, readInlineDirective } from './directive-syntax.ts' + +function attributes(...pairs: [string, string][]): ReadonlyMap { + return new Map(pairs) +} + +function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): DirectiveLine { + return { argument, attributes: attributes(...pairs), colons, kind: 'header', name } +} + +function fault(line: string): string { + const read = readDirectiveLine(line) + return read?.kind === 'fault' ? read.fault.message : `read ${JSON.stringify(read)}` +} + +function inline(text: string): unknown { + const read = readInlineDirective(text, 0) + if (read === undefined) return 'unclaimed' + if (read.fault !== undefined) return read.fault.message + return { attributes: read.value.attributes, content: read.value.content, length: read.value.length, name: read.value.name } +} + +function spans(text: string, name: string, content: string | undefined, ...pairs: [string, string][]): void { + assert.deepEqual(inline(text), { attributes: attributes(...pairs), content, length: text.length, name }) +} + +test('claims a colon-run line only where a name or nothing follows the colons', () => { + assert.equal(readDirectiveLine('Part.'), undefined) + assert.equal(readDirectiveLine(':: two'), undefined) + assert.equal(readDirectiveLine(':panel'), undefined) + assert.equal(readDirectiveLine(' ::rule'), undefined) +}) + +test('reads a bare colon run as the fence that closes a container', () => { + assert.deepEqual(readDirectiveLine(':::'), { colons: 3, kind: 'closing' }) + assert.deepEqual(readDirectiveLine('::'), { colons: 2, kind: 'closing' }) + assert.deepEqual(readDirectiveLine(':::::: \t'), { colons: 6, kind: 'closing' }) +}) + +test('reads the leaf and container forms, their argument and their attributes', () => { + assert.deepEqual(readDirectiveLine('::rule'), header(2, 'rule')) + assert.deepEqual(readDirectiveLine('::rule '), header(2, 'rule')) + assert.deepEqual(readDirectiveLine('::taskItem TODO'), header(2, 'taskItem', 'TODO')) + assert.deepEqual(readDirectiveLine('::hardBreak {}'), header(2, 'hardBreak')) + assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file'])) + assert.deepEqual(readDirectiveLine('::panel info {panelColor="#ff0000"} '), header(2, 'panel', 'info', ['panelColor', '#ff0000'])) + assert.deepEqual(readDirectiveLine(':::panel info'), header(3, 'panel', 'info')) +}) + +test('decodes a quoted attribute value, the escapes {attrs} reserves included', () => { + assert.deepEqual(readDirectiveLine('::extension {text="two words"}'), header(2, 'extension', undefined, ['text', 'two words'])) + assert.deepEqual(readDirectiveLine('::extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'), header(2, 'extension', undefined, ['text', 'a`b&c { + assert.equal(fault('::Panel'), 'a directive name reads [a-z][A-Za-z0-9]*') + assert.equal(fault('::1panel'), 'a directive name reads [a-z][A-Za-z0-9]*') + assert.equal(fault('::panel info'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') + assert.equal(fault('::panel info extra'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') + assert.equal(fault('::panel{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') + assert.equal(fault('::panel info{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') + assert.equal(fault('::panel {} x'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') +}) + +test('names the attributes no spelling reads', () => { + assert.equal(fault('::panel {a=1'), 'the {attrs} closing brace is missing') + assert.equal(fault('::panel {a="x}'), 'the {attrs} quoted value is unclosed') + assert.equal(fault('::panel {a="\\uzzzz"}'), 'the {attrs} quoted value is not a JSON string') + assert.equal(fault('::panel {a}'), 'an attribute reads key=value, the value bare or double-quoted') + assert.equal(fault('::panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted') + assert.equal(fault('::panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted') + assert.equal(fault('::panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted') + assert.equal(fault('::panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted') + assert.equal(fault('::panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted') + assert.equal(fault('::panel {a=1 a=2}'), 'the attribute key a is spelled twice') +}) + +test('breaks the directive on the raw characters a quoted value spells as escapes', () => { + assert.equal(fault('::panel {a="x`y"}'), 'a raw ` inside {attrs} breaks the directive: spell it \\u0060') + assert.equal(fault('::panel {a="x&y"}'), 'a raw & inside {attrs} breaks the directive: spell it \\u0026') + assert.equal(fault('::panel {a="x { + assert.equal(inline('Part.'), 'unclaimed') + assert.equal(inline(':10:30'), 'unclaimed') + assert.equal(inline(':smile:'), 'unclaimed') + assert.equal(inline(':Mention[@A]'), 'unclaimed') + assert.equal(inline(':mention @A'), 'unclaimed') + spans(':mention[@A]', 'mention', '@A') + spans(':date{timestamp=1756080000000}', 'date', undefined, ['timestamp', '1756080000000']) + spans(':emoji[]{shortName=":tada:"}', 'emoji', '', ['shortName', ':tada:']) + spans(':underline[ a ]', 'underline', ' a ') +}) + +test('binds an inline directive as a unit, its content balancing brackets like link text', () => { + spans(':underline[a [b] c]', 'underline', 'a [b] c') + spans(':underline[a \\] b]', 'underline', 'a \\] b') + spans(':underline[a `]` b]', 'underline', 'a `]` b') + spans(':underline[a `b c]', 'underline', 'a `b c') + spans(':underline[:status[x]{color=red}]', 'underline', ':status[x]{color=red}') + spans(':status[x]{color=red style="bold "}', 'status', 'x', ['color', 'red'], ['style', 'bold ']) + assert.deepEqual(inline(':underline[a]{}(b)'), { attributes: attributes(), content: 'a', length: 15, name: 'underline' }) + assert.deepEqual(inline(':underline[a] {}'), { attributes: attributes(), content: 'a', length: 13, name: 'underline' }) + assert.deepEqual(inline(':text{text=" "} and more'), { attributes: attributes(['text', ' ']), content: undefined, length: 15, name: 'text' }) +}) + +test('names the inline directive left unclosed at the end of its line', () => { + assert.equal(inline(':mention[@A'), 'an inline directive [content] is unclosed') + assert.equal(inline(':mention[@A\nB]'), 'an inline directive [content] is unclosed') + assert.equal(inline(':mention[a `b\nc` d]'), 'an inline directive [content] is unclosed') + assert.equal(inline(':underline[:status[x'), 'an inline directive [content] is unclosed') + assert.equal(inline(':mention[@A]{id=1'), 'the {attrs} closing brace is missing') + assert.equal(inline(':mention{id=1'), 'the {attrs} closing brace is missing') +}) + +test('refuses inline directives nested deeper than the parser carries', () => { + const nest = (depth: number): string => `${':underline['.repeat(depth)}x${']'.repeat(depth)}` + spans(nest(largestNesting), 'underline', nest(largestNesting - 1)) + assert.equal(inline(nest(largestNesting + 1)), `the input nests inline directives deeper than the ${largestNesting} levels the parser carries`) +}) diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts new file mode 100644 index 0000000..f8010ea --- /dev/null +++ b/src/markdown/directive-syntax.ts @@ -0,0 +1,250 @@ +import type { ConvertFault } from '../result.ts' +import type { JsonValue } from '../json-value.ts' +import type { VocabularyPair } from '../adf/attribute-vocabulary.ts' +import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts' +import { backtickRun, closingBacktickRun } from './backtick-runs.ts' +import { largestNesting } from '../nesting.ts' +import { runLength } from './emphasis-matching.ts' +import { serializeCanonicalJson } from '../canonical-json.ts' + +export type DirectiveAttributes = ReadonlyMap + +export type DirectiveLine = + | { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string } + | { colons: number; kind: 'closing' } + | { fault: ConvertFault; kind: 'fault' } + +export type InlineDirective = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } + +type Read = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } + +type Attributes = { attributes: DirectiveAttributes; length: number } + +type AttributePair = { end: number; key: string; value: string } + +const bareTokenSource = '[A-Za-z0-9_-]+' +const bareRun = new RegExp(bareTokenSource, 'y') +const bareToken = new RegExp(`^${bareTokenSource}$`) +const directiveName = /[a-z][A-Za-z0-9]*/y +const inlineDirectiveOpener = /:[a-z][A-Za-z0-9]*[[{]/y +const lineEnd = /^[ \t]*$/ +// spec/flavour.md, Attributes. +const reservedSource = '[&<`|]' +const quotedEscapes = new RegExp(reservedSource, 'g') +const rawReserved = new RegExp(reservedSource) +const noAttributes: DirectiveAttributes = new Map() + +const nameFault = 'a directive name reads [a-z][A-Za-z0-9]*' +const pairFault = 'an attribute reads key=value, the value bare or double-quoted' +const shapeFault = 'a directive line reads a name, one bare argument and {attrs}, one space apart' + +export function isBareToken(text: string): boolean { + return bareToken.test(text) +} + +export function malformedDirective(message: string): ConvertFault { + return { code: 'malformed-directive', message } +} + +export function opensInlineDirective(text: string, index: number): boolean { + return inlineDirectiveName(text, index) !== undefined +} + +export function readDirectiveLine(line: string): DirectiveLine | undefined { + if (!claimsDirectiveLine(line)) return undefined + const colons = runLength(line, 0) + const rest = line.slice(colons) + if (lineEnd.test(rest)) return { colons, kind: 'closing' } + const header = readDirectiveHeader(rest) + if (header.fault !== undefined) return { fault: header.fault, kind: 'fault' } + return { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } +} + +export function readInlineDirective(text: string, index: number): Read | undefined { + return readNestedDirective(text, index, 1) +} + +export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { + if (pairs.length === 0) return '' + const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`) + return `{${spelled.join(' ')}}` +} + +export function spellJsonAttribute(value: JsonValue): string { + return quote(serializeCanonicalJson(value, 'compact')) +} + +export function spellStringAttribute(text: string): string { + return isBareToken(text) ? text : quote(text) +} + +export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, string][] { + return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)]) +} + +export function unknownDirectiveFault(name: string): ConvertFault { + return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` } +} + +function spellAttributeValue(pair: VocabularyPair): string { + if (pair.kind === 'boolean') return `${pair.value}` + if (pair.kind === 'json') return spellJsonAttribute(pair.value) + if (pair.kind === 'number') return spellStringAttribute(JSON.stringify(pair.value)) + return spellStringAttribute(pair.value) +} + +function quote(text: string): string { + return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${escapeDigits(character)}`) +} + +function escapeDigits(character: string): string { + return character.charCodeAt(0).toString(16).padStart(4, '0') +} + +function inlineDirectiveName(text: string, index: number): string | undefined { + inlineDirectiveOpener.lastIndex = index + const opened = inlineDirectiveOpener.exec(text)?.[0] + return opened === undefined ? undefined : opened.slice(1, -1) +} + +function readDirectiveHeader(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes; name: string }> { + directiveName.lastIndex = 0 + const name = directiveName.exec(rest)?.[0] + if (name === undefined) return { fault: malformedDirective(nameFault) } + let cursor = name.length + let argument: string | undefined + let attributes = noAttributes + if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) !== '{' && !lineEnd.test(rest.slice(cursor))) { + bareRun.lastIndex = cursor + 1 + argument = bareRun.exec(rest)?.[0] + if (argument === undefined) return { fault: malformedDirective(shapeFault) } + cursor += 1 + argument.length + } + if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { + const read = readAttributes(rest, cursor + 1) + if (read.fault !== undefined) return { fault: read.fault } + attributes = read.value.attributes + cursor += 1 + read.value.length + } + if (!lineEnd.test(rest.slice(cursor))) return { fault: malformedDirective(shapeFault) } + return { value: { argument, attributes, name } } +} + +function readNestedDirective(text: string, index: number, depth: number): Read | undefined { + const name = inlineDirectiveName(text, index) + if (name === undefined) return undefined + if (depth > largestNesting) { + return { fault: { code: 'unsupported-nesting-depth', message: `the input nests inline directives deeper than the ${largestNesting} levels the parser carries` } } + } + let cursor = index + 1 + name.length + let content: string | undefined + if (text.charAt(cursor) === '[') { + const end = readDirectiveContent(text, cursor + 1, depth) + if (end.fault !== undefined) return { fault: end.fault } + content = text.slice(cursor + 1, end.value) + cursor = end.value + 1 + } + let attributes = noAttributes + if (text.charAt(cursor) === '{') { + const read = readAttributes(text, cursor) + if (read.fault !== undefined) return { fault: read.fault } + attributes = read.value.attributes + cursor += read.value.length + } + return { value: { attributes, content, length: cursor - index, name } } +} + +// Where the content's own closing bracket sits: a code span, an escape and a nested directive each bind before it. +function readDirectiveContent(text: string, start: number, depth: number): Read { + let brackets = 0 + let cursor = start + while (cursor < text.length && text.charAt(cursor) !== '\n') { + const character = text.charAt(cursor) + if (character === '\\') { + cursor += backslashEscape(text, cursor) === undefined ? 1 : 2 + continue + } + if (character === '`') { + const span = readCodeSpanEnd(text, cursor) + if (span === undefined) break + cursor = span + continue + } + const nested = character === ':' ? readNestedDirective(text, cursor, depth + 1) : undefined + if (nested !== undefined) { + if (nested.fault !== undefined) return { fault: nested.fault } + cursor += nested.value.length + continue + } + if (character === ']' && brackets === 0) return { value: cursor } + if (character === '[') brackets += 1 + if (character === ']') brackets -= 1 + cursor += 1 + } + return { fault: malformedDirective('an inline directive [content] is unclosed') } +} + +// `undefined` where the span crosses the line ending an inline directive may not cross. +function readCodeSpanEnd(text: string, index: number): number | undefined { + const opener = backtickRun(text, index) + const closer = closingBacktickRun(text, index + opener, opener) + if (closer === undefined) return index + opener + return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener +} + +function readAttributes(text: string, index: number): Read { + const attributes = new Map() + let cursor = index + 1 + while (cursor < text.length && text.charAt(cursor) !== '}') { + if (attributes.size > 0) { + if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) } + cursor += 1 + } + const pair = readAttributePair(text, cursor) + if (pair.fault !== undefined) return { fault: pair.fault } + if (attributes.has(pair.value.key)) return { fault: malformedDirective(`the attribute key ${pair.value.key} is spelled twice`) } + attributes.set(pair.value.key, pair.value.value) + cursor = pair.value.end + } + if (text.charAt(cursor) !== '}') return { fault: malformedDirective('the {attrs} closing brace is missing') } + return { value: { attributes, length: cursor + 1 - index } } +} + +function readAttributePair(text: string, index: number): Read { + bareRun.lastIndex = index + const key = bareRun.exec(text)?.[0] + if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: malformedDirective(pairFault) } + const start = index + key.length + 1 + if (text.charAt(start) === '"') { + const quoted = readQuotedValue(text, start) + if (quoted.fault !== undefined) return { fault: quoted.fault } + return { value: { end: quoted.value.end, key, value: quoted.value.value } } + } + bareRun.lastIndex = start + const value = bareRun.exec(text)?.[0] + if (value === undefined) return { fault: malformedDirective(pairFault) } + return { value: { end: start + value.length, key, value } } +} + +function readQuotedValue(text: string, index: number): Read<{ end: number; value: string }> { + let cursor = index + 1 + while (cursor < text.length && text.charAt(cursor) !== '"') cursor += text.charAt(cursor) === '\\' ? 2 : 1 + if (text.charAt(cursor) !== '"') return { fault: malformedDirective('the {attrs} quoted value is unclosed') } + const raw = text.slice(index, cursor + 1) + const character = rawReserved.exec(raw)?.[0] + if (character !== undefined) { + return { fault: malformedDirective(`a raw ${character} inside {attrs} breaks the directive: spell it \\u${escapeDigits(character)}`) } + } + const value = parseJsonString(raw) + if (value === undefined) return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') } + return { value: { end: cursor + 1, value } } +} + +function parseJsonString(raw: string): string | undefined { + try { + const value: unknown = JSON.parse(raw) + return typeof value === 'string' ? value : undefined + } catch { + return undefined + } +} diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 18c7ac9..62bebb7 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -2,7 +2,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { BlockDirective } from '../../adf/block-directives.ts' import type { JsonValue } from '../../json-value.ts' import { blockArgument } from '../block-directive-arguments.ts' -import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-attributes.ts' +import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { diff --git a/src/markdown/emit/inline-directive-spelling.ts b/src/markdown/emit/inline-directive-spelling.ts index 73866ce..2e7273f 100644 --- a/src/markdown/emit/inline-directive-spelling.ts +++ b/src/markdown/emit/inline-directive-spelling.ts @@ -1,6 +1,6 @@ import type { AdfNode } from '../../adf/document.ts' import type { InlineDirective } from '../../adf/inline-directives.ts' -import { spellAttributes, spellVocabulary } from '../directive-attributes.ts' +import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 71ab82a..557858a 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -10,7 +10,7 @@ import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../backtick-runs.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { sameMark } from '../../adf/editor-normal.ts' -import { spellAttributes, spellStringAttribute } from '../directive-attributes.ts' +import { spellAttributes, spellStringAttribute } from '../directive-syntax.ts' import { spellDestination, spellTitle } from '../link-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index f139af7..d677375 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -1,7 +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 { opensInlineDirective } from '../directive-attributes.ts' +import { opensInlineDirective } from '../directive-syntax.ts' import { readEntityReference } from '../entity-references.ts' export type EmphasisRole = 'close' | 'open' diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index b93f208..e19a37c 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -2,7 +2,7 @@ import type { AdfMark } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { MarkType } from '../adf/mark-attributes.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' -import { spellAttributes, spellVocabulary } from './directive-attributes.ts' +import { spellAttributes, spellVocabulary } from './directive-syntax.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 84f9126..61262f4 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -5,7 +5,7 @@ import { isJsonValue } from '../json-value.ts' import { fencedCodeBlock } from './backtick-runs.ts' import { largestNesting } from '../nesting.ts' import { serializeCanonicalJson } from '../canonical-json.ts' -import { spellAttributes, spellStringAttribute } from './directive-attributes.ts' +import { spellAttributes, spellStringAttribute } from './directive-syntax.ts' export const carryName = 'adf' diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index cab466b..71fd8bb 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -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\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']) }) diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 7b6dd5d..1586ffb 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -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 } +type DirectiveBlock = Extract + type ListBlock = Extract -type OpenContainer = Extract | { 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 + | 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): 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 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') diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index c329909..df03fa9 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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 { + 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 = '' diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 3be28cd..f419786 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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')]) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 8a5c985..d072d38 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 { 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 { - 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' }] } diff --git a/src/result.ts b/src/result.ts index 2bdcdb4..3098b72 100644 --- a/src/result.ts +++ b/src/result.ts @@ -2,6 +2,7 @@ export type ConvertErrorCode = | 'malformed-directive' | 'malformed-pipe-table' | 'not-an-adf-document' + | 'unknown-directive-name' | 'unmappable-html' | 'unmappable-image' | 'unspellable-adjacent-lists' @@ -17,12 +18,13 @@ export type ConvertErrorCode = export type ConvertErrorPath = readonly (number | string)[] -export type ConvertError = { +export type ConvertFault = { code: ConvertErrorCode message: string - path: ConvertErrorPath } +export type ConvertError = ConvertFault & { path: ConvertErrorPath } + export type Result = { error: ConvertError; ok: false } | { ok: true; value: T } export function failure(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result { diff --git a/todo.md b/todo.md index dcb2ff0..6eaa7d5 100644 --- a/todo.md +++ b/todo.md @@ -268,7 +268,7 @@ detail is settled at its own milestone. break of either kind inside one reads as a space. And a destination or title whose entity reference decodes to a control character — `[a](/x y)` — joins 3k's exception list beside the two above: the reader takes cmark's reading, the emitter has no spelling for it. - - [ ] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`, + - [x] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`, container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with its quoting and escapes, the fence-length and nesting rules, and the malformed list `spec/flavour.md` spells, each a named error. `corpus.test.ts`'s `fenceNestingFault` stays a @@ -278,6 +278,15 @@ detail is settled at its own milestone. `|` inside `{attrs}` breaks the directive and is a named error, the author writing the `\u0060` the emitter writes. One precedence covers both directions, and CommonMark's own ordering stays untouched. + **Settled** (the maintainer, 2026-09-01): a directive whose name reads back to no node + takes its own code, `unknown-directive-name` — a well-formed spelling the vocabulary does + not hold is not a malformed one, and §8's "erroring input gaining meaning later is MINOR" + is what a consumer switches the two apart for. And input reads canonical spacing only: one + space parting the name, the argument, `{attrs}` and each attribute pair, no padding inside + the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a + MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes + `directive-syntax.ts` with the readers in it: the whole directive grammar, both + directions, beside the escaping regexes and the spellings it must not drift from. - [ ] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first directory to. A parsed directive becomes its node: the name to the type and an unknown one to a named error, the arg to the attribute it names, each value to the type its section -- 2.52.0 From f231f652ca3f98f47bd3ba271a026cc2745e7ad3 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 1 Sep 2026 10:01:35 +0200 Subject: [PATCH 2/3] Answer the architecture pass: one fault vocabulary, and the guards pinned where they compose --- AGENTS.md | 8 ++++++-- corpus/errors/nesting-depth-composed.error | 1 + corpus/errors/nesting-depth-composed.md | 1 + src/markdown/directive-syntax.test.ts | 12 ++++++------ src/markdown/directive-syntax.ts | 17 ++++++++--------- src/markdown/parse/blocks.test.ts | 10 ++++++++++ src/markdown/parse/blocks.ts | 20 +++++++++++--------- src/markdown/parse/inline-content.ts | 5 ++--- src/markdown/parse/markdown-to-adf.ts | 10 ++++------ src/result.ts | 9 +++++++-- todo.md | 4 +++- 11 files changed, 59 insertions(+), 38 deletions(-) create mode 100644 corpus/errors/nesting-depth-composed.error create mode 100644 corpus/errors/nesting-depth-composed.md diff --git a/AGENTS.md b/AGENTS.md index ce93fec..165fcc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,10 @@ direction hits it. A claim code names the spelling claimed, never the node that a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no spelling writes rides the carry with its node. A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that -apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. +apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A +refusal found before its position is known — the block walk's, a directive reader's — is a +`ConvertFault`, the code and message without the path; the node walk attaches the path as it +descends, so a document reports its first error in document order. ## 9. Release automation @@ -122,7 +125,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga - Failures are values: everything returns `Result` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws. `try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on - the spot. + the spot. A reader with no path to name returns `Read` instead, the same two arms over a + `ConvertFault`, and `faulted` attaches the path where the walk knows it. - Only the hard break's inline segment holds a raw newline — every other spelling escapes one or refuses it — which is how the whitespace carry finds a line edge. - Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text diff --git a/corpus/errors/nesting-depth-composed.error b/corpus/errors/nesting-depth-composed.error new file mode 100644 index 0000000..f54ac45 --- /dev/null +++ b/corpus/errors/nesting-depth-composed.error @@ -0,0 +1 @@ +unsupported-nesting-depth diff --git a/corpus/errors/nesting-depth-composed.md b/corpus/errors/nesting-depth-composed.md new file mode 100644 index 0000000..ec20183 --- /dev/null +++ b/corpus/errors/nesting-depth-composed.md @@ -0,0 +1 @@ +> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > :a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index fd4fdb5..d02750e 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -9,13 +9,13 @@ function attributes(...pairs: [string, string][]): ReadonlyMap { return new Map(pairs) } -function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): DirectiveLine { - return { argument, attributes: attributes(...pairs), colons, kind: 'header', name } +function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): { value: DirectiveLine } { + return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } } } function fault(line: string): string { const read = readDirectiveLine(line) - return read?.kind === 'fault' ? read.fault.message : `read ${JSON.stringify(read)}` + return read?.fault === undefined ? `read ${JSON.stringify(read)}` : read.fault.message } function inline(text: string): unknown { @@ -37,9 +37,9 @@ test('claims a colon-run line only where a name or nothing follows the colons', }) test('reads a bare colon run as the fence that closes a container', () => { - assert.deepEqual(readDirectiveLine(':::'), { colons: 3, kind: 'closing' }) - assert.deepEqual(readDirectiveLine('::'), { colons: 2, kind: 'closing' }) - assert.deepEqual(readDirectiveLine(':::::: \t'), { colons: 6, kind: 'closing' }) + assert.deepEqual(readDirectiveLine(':::'), { value: { colons: 3, kind: 'closing' } }) + assert.deepEqual(readDirectiveLine('::'), { value: { colons: 2, kind: 'closing' } }) + assert.deepEqual(readDirectiveLine(':::::: \t'), { value: { colons: 6, kind: 'closing' } }) }) test('reads the leaf and container forms, their argument and their attributes', () => { diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index f8010ea..8fd3894 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -12,11 +12,10 @@ export type DirectiveAttributes = ReadonlyMap export type DirectiveLine = | { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string } | { colons: number; kind: 'closing' } - | { fault: ConvertFault; kind: 'fault' } -export type InlineDirective = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } +export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } -type Read = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } +export type Read = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } type Attributes = { attributes: DirectiveAttributes; length: number } @@ -50,17 +49,17 @@ export function opensInlineDirective(text: string, index: number): boolean { return inlineDirectiveName(text, index) !== undefined } -export function readDirectiveLine(line: string): DirectiveLine | undefined { +export function readDirectiveLine(line: string): Read | undefined { if (!claimsDirectiveLine(line)) return undefined const colons = runLength(line, 0) const rest = line.slice(colons) - if (lineEnd.test(rest)) return { colons, kind: 'closing' } + if (lineEnd.test(rest)) return { value: { colons, kind: 'closing' } } const header = readDirectiveHeader(rest) - if (header.fault !== undefined) return { fault: header.fault, kind: 'fault' } - return { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } + if (header.fault !== undefined) return { fault: header.fault } + return { value: { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } } } -export function readInlineDirective(text: string, index: number): Read | undefined { +export function readInlineDirective(text: string, index: number): Read | undefined { return readNestedDirective(text, index, 1) } @@ -130,7 +129,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined; return { value: { argument, attributes, name } } } -function readNestedDirective(text: string, index: number, depth: number): Read | undefined { +function readNestedDirective(text: string, index: number, depth: number): Read | undefined { const name = inlineDirectiveName(text, index) if (name === undefined) return undefined if (depth > largestNesting) { diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index 71fd8bb..60551e1 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -87,6 +87,16 @@ test('holds a directive container open until the fence that closes it', () => { 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, [ + { + argument: 'info', + attributes: new Map([['panelColor', '#ff0000']]), + blocks: [{ kind: 'paragraph', text: 'Part.' }], + kind: 'directive', + name: 'panel', + }, + ]) + assert.deepEqual(parseBlocks('::rule\n').blocks, [{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule' }]) }) test('names the directive fence a container does not sit longer than', () => { diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 1586ffb..c5b8689 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -181,10 +181,15 @@ function openContainer(walk: Walk, start: ContainerStart): void { function closeContainers(walk: Walk, depth: number): void { closeLeaf(walk) - for (const container of walk.stack.splice(depth)) { + for (const container of walk.stack.slice(depth)) { if (container.kind !== 'directive') continue container.parent[container.index] = { fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), kind: 'fault' } } + dropContainers(walk, depth) +} + +function dropContainers(walk: Walk, depth: number): void { + walk.stack.length = depth } function openDirective(walk: Walk, directive: Extract): void { @@ -200,12 +205,7 @@ function openDirective(walk: Walk, directive: Extract { scan.pending += ':' return success(index + 1) } - const fault = directive.fault ?? unknownDirectiveFault(directive.value.name) - return failure(fault.code, fault.message, scan.path) + return faulted(directive.fault ?? unknownDirectiveFault(directive.value.name), scan.path) } function flush(scan: Scan, strip: boolean): void { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index d072d38..50e28a7 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -1,7 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.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 { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { largestNesting } from '../../nesting.ts' import { parseBlocks } from './blocks.ts' import { parseInlineContent } from './inline-content.ts' @@ -33,12 +33,10 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) case 'code': return success(codeBlockNode(block.language, block.text)) - case 'directive': { - const fault = unknownDirectiveFault(block.name) - return failure(fault.code, fault.message, path) - } + case 'directive': + return faulted(unknownDirectiveFault(block.name), path) case 'fault': - return failure(block.fault.code, block.fault.message, path) + return faulted(block.fault, path) case 'heading': return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path) case 'html': diff --git a/src/result.ts b/src/result.ts index 3098b72..4ad939c 100644 --- a/src/result.ts +++ b/src/result.ts @@ -18,12 +18,13 @@ export type ConvertErrorCode = export type ConvertErrorPath = readonly (number | string)[] -export type ConvertFault = { +export type ConvertError = { code: ConvertErrorCode message: string + path: ConvertErrorPath } -export type ConvertError = ConvertFault & { path: ConvertErrorPath } +export type ConvertFault = Omit export type Result = { error: ConvertError; ok: false } | { ok: true; value: T } @@ -31,6 +32,10 @@ export function failure(code: ConvertErrorCode, message: string, path: Conver return { error: { code, message, path }, ok: false } } +export function faulted(fault: ConvertFault, path: ConvertErrorPath): Result { + return failure(fault.code, fault.message, path) +} + export function success(value: T): Result { return { ok: true, value } } diff --git a/todo.md b/todo.md index 6eaa7d5..c449502 100644 --- a/todo.md +++ b/todo.md @@ -286,7 +286,9 @@ detail is settled at its own milestone. the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes `directive-syntax.ts` with the readers in it: the whole directive grammar, both - directions, beside the escaping regexes and the spellings it must not drift from. + directions, beside the escaping regexes and the spellings it must not drift from. And the + 500-level guards compose here for the first time — a recursive reader stacked on the block + walk — so `nesting-depth-composed` pins both axes now rather than waiting for 3i's third. - [ ] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first directory to. A parsed directive becomes its node: the name to the type and an unknown one to a named error, the arg to the attribute it names, each value to the type its section -- 2.52.0 From 7224b3c738fb507cf26f9dc7d5b8df4be3fdb9fe Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 1 Sep 2026 10:26:56 +0200 Subject: [PATCH 3/3] Answer the stability pass: pin the deeper stack, the quoted newline and the closing fence --- corpus/errors/nesting-depth-composed.md | 2 +- spec/flavour.md | 4 ++-- src/markdown/directive-syntax.test.ts | 1 + src/markdown/directive-syntax.ts | 2 +- src/markdown/parse/blocks.test.ts | 1 + src/markdown/parse/markdown-to-adf.test.ts | 3 ++- todo.md | 8 ++++++++ 7 files changed, 16 insertions(+), 5 deletions(-) diff --git a/corpus/errors/nesting-depth-composed.md b/corpus/errors/nesting-depth-composed.md index ec20183..dd0d6be 100644 --- a/corpus/errors/nesting-depth-composed.md +++ b/corpus/errors/nesting-depth-composed.md @@ -1 +1 @@ -> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > :a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - :a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] diff --git a/spec/flavour.md b/spec/flavour.md index c3d4f65..14ae16a 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -83,8 +83,8 @@ block content The fence is three or more colons. `arg` is one optional bare token whose meaning each node defines (e.g. the panel type). The body is block markdown. The closing fence is a line of at -least the opening's length, and a container's fence is longer than every directive fence line -anywhere in its body, however deeply a list item or blockquote nests it; a colon run inside a code +least the opening's length and closes the innermost open container however long its run, and a +container's fence is longer than every directive fence line anywhere in its body, however deeply a list item or blockquote nests it; a colon run inside a code fence or opaque carry is content. Canonical form uses minimal lengths. Directive fence lines follow code-fence indentation (up to three spaces relative to their container). diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index d02750e..d198971 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -120,6 +120,7 @@ test('names the inline directive left unclosed at the end of its line', () => { assert.equal(inline(':underline[:status[x'), 'an inline directive [content] is unclosed') assert.equal(inline(':mention[@A]{id=1'), 'the {attrs} closing brace is missing') assert.equal(inline(':mention{id=1'), 'the {attrs} closing brace is missing') + assert.equal(inline(':text{text="a\nb"}'), 'the {attrs} quoted value is not a JSON string') }) test('refuses inline directives nested deeper than the parser carries', () => { diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 8fd3894..09e5165 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -153,7 +153,7 @@ function readNestedDirective(text: string, index: number, depth: number): Read { let brackets = 0 let cursor = start diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index 60551e1..30f4156 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -102,6 +102,7 @@ test('holds a directive container open until the fence that closes it', () => { 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']) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index f419786..acb8652 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -130,7 +130,6 @@ 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(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')]) }) @@ -141,6 +140,8 @@ test('reads the three directive forms, and names the node none of them reads bac 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]) + 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]) }) test('leaves the colon that opens no directive the text it is', () => { diff --git a/todo.md b/todo.md index c449502..ffd6790 100644 --- a/todo.md +++ b/todo.md @@ -289,11 +289,19 @@ detail is settled at its own milestone. directions, beside the escaping regexes and the spellings it must not drift from. And the 500-level guards compose here for the first time — a recursive reader stacked on the block walk — so `nesting-depth-composed` pins both axes now rather than waiting for 3i's third. + A closing fence closes the innermost open container however long its run, which + `spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost + leaves the fence it did not close a named error, which §2 prefers to closing more than the + author wrote. - [ ] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first directory to. A parsed directive becomes its node: the name to the type and an unknown one to a named error, the arg to the attribute it names, each value to the type its section assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift guard is built here if the answer there was yes. + 3f leaves two here: `Read` moves to `src/result.ts` once a second reader takes it, and + the reserved `adf` name in block position needs an error of its own — 3f reports it as + `unknown-directive-name`, which §8 makes the signal that a later MINOR may give the name + meaning, and `adf` never will. - [ ] **3h — The block nodes.** `block-nodes/` reads back: the plain-versus-directive choice 2f settles, read from the other side; the `codeBlock` directive's fenced body and the `language` attribute a bare fence leaves it; the media family's composition; both table -- 2.52.0