Answer the stability pass: the misleading spellings, and only the form the emitter picks
CI / gate (push) Successful in 5s

This commit is contained in:
2026-09-01 14:13:01 +02:00
parent f10353dc78
commit dcb4d67b6c
14 changed files with 101 additions and 58 deletions
+3 -1
View File
@@ -170,7 +170,9 @@ both answer to the round-trip corpus and to nothing else where a node has no fix
part of a construct the root already holds — a grammar stays in one file rather than splitting part of a construct the root already holds — a grammar stays in one file rather than splitting
across the seam. A rule both across the seam. A rule both
directions must answer alike — whether a list marker interrupts a paragraph — is one function directions must answer alike — whether a list marker interrupts a paragraph — is one function
there too, never a copy per direction, however conservative the copy would be. there too, never a copy per direction, however conservative the copy would be. Where the rule is
the emitter's own choice, input consults it rather than restating it: the parser asks
`spellsCommonMark` which form the emitter picks, so no fixture the emitter writes can be refused.
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
format spells the narrowed value. A spelling that re-checks the type is the check's second copy. format spells the narrowed value. A spelling that re-checks the type is the check's second copy.
Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
::rule
+5 -3
View File
@@ -156,7 +156,9 @@ included, is an error result naming it. The flavour never emits raw HTML.
The directive name is always the ADF node type. A container's body is the node's `content`; a The directive name is always the ADF node type. A container's body is the node's `content`; a
leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is
written; validity against ADF's content models stays the author's business (AGENTS.md §14). written; validity against ADF's content models stays the author's business (AGENTS.md §14). It
parses only in the form the emitter picks, though: a directive spelling a node CommonMark holds
is a named error, the per-node plain-versus-directive choice below read backwards.
Each section lists attributes as `name (type)`. A parenthesized value set documents what real Each section lists attributes as `name (type)`. A parenthesized value set documents what real
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
@@ -425,8 +427,8 @@ the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identic
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
`marks` key. `code`, `em`, `link`, `strike` and `strong` keep their markdown spellings, and are `marks` key. `code`, `em`, `link`, `strike` and `strong` keep their markdown spellings, and are
not directive names: `:em[x]` is a named error. The other four are inline directives, content not directive names: `:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline`
required non-empty. are inline directives, content required non-empty.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 13). - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 13).
- `code`, `em`, `strike`, `strong` — Attributes: none. - `code`, `em`, `strike`, `strong` — Attributes: none.
+1 -1
View File
@@ -90,7 +90,7 @@ for (const directory of emittingDirectories) {
} }
} }
test('every parsing directory emits the markdown it reads back', () => { test('every parsing directory is one of the emitting directories', () => {
assert.deepEqual( assert.deepEqual(
parsingDirectories.filter((directory) => emittingDirectories.includes(directory)), parsingDirectories.filter((directory) => emittingDirectories.includes(directory)),
parsingDirectories, parsingDirectories,
+2 -2
View File
@@ -5,11 +5,11 @@ import type { DirectiveAttributes, DirectiveLine } from './directive-syntax.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { readDirectiveLine, readInlineDirective } from './directive-syntax.ts' import { readDirectiveLine, readInlineDirective } from './directive-syntax.ts'
// A pair the input spells bare reads its own text back; a quoted one names the spelling beside it. // A pair the input spells bare decodes to itself; a quoted one names its spelling beside the decoding.
type Pair = [string, string, string?] type Pair = [string, string, string?]
function attributes(...pairs: Pair[]): DirectiveAttributes { function attributes(...pairs: Pair[]): DirectiveAttributes {
return new Map(pairs.map(([key, text, spelling]) => [key, { spelling: spelling ?? text, text }])) return new Map(pairs.map(([key, decoded, spelling]) => [key, { decoded, spelling: spelling ?? decoded }]))
} }
function header(colons: number, name: string, argument?: string, ...pairs: Pair[]): { value: DirectiveLine } { function header(colons: number, name: string, argument?: string, ...pairs: Pair[]): { value: DirectiveLine } {
+9 -6
View File
@@ -8,8 +8,7 @@ import { largestNesting } from '../nesting.ts'
import { runLength } from './emphasis-matching.ts' import { runLength } from './emphasis-matching.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
// The value as the input spells it, beside the string the grammar decodes it to. export type DirectiveValue = { decoded: string; spelling: string }
export type DirectiveValue = { spelling: string; text: string }
export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue> export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
@@ -80,7 +79,7 @@ export function readInlineDirective(text: string, index: number): Read<Directive
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
if (pairs.length === 0) return '' if (pairs.length === 0) return ''
const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`) const spelled = [...pairs].sort(([left], [right]) => keyOrder(left, right)).map(([key, value]) => `${key}=${value}`)
return `{${spelled.join(' ')}}` return `{${spelled.join(' ')}}`
} }
@@ -107,6 +106,10 @@ export function unknownDirectiveFault(name: string): ConvertFault {
return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` } return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` }
} }
function keyOrder(left: string, right: string): number {
return left < right ? -1 : 1
}
function quote(text: string): string { function quote(text: string): string {
return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${escapeDigits(character)}`) return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${escapeDigits(character)}`)
} }
@@ -221,7 +224,7 @@ function readAttributes(text: string, index: number): Read<Attributes> {
if (pair.fault !== undefined) return { fault: pair.fault } if (pair.fault !== undefined) return { fault: pair.fault }
const key = pair.value.key const key = pair.value.key
if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) } if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) }
if (key < previous) return { fault: malformedDirective(`${orderFault}: ${key} before ${previous}`) } if (keyOrder(previous, key) > 0) return { fault: malformedDirective(`${orderFault}: ${key} before ${previous}`) }
previous = key previous = key
attributes.set(key, pair.value.value) attributes.set(key, pair.value.value)
cursor = pair.value.end cursor = pair.value.end
@@ -243,7 +246,7 @@ function readAttributePair(text: string, index: number): Read<AttributePair> {
bareRun.lastIndex = start bareRun.lastIndex = start
const bare = bareRun.exec(text)?.[0] const bare = bareRun.exec(text)?.[0]
if (bare === undefined) return { fault: malformedDirective(pairFault) } if (bare === undefined) return { fault: malformedDirective(pairFault) }
return { value: { end: start + bare.length, key, value: { spelling: bare, text: bare } } } return { value: { end: start + bare.length, key, value: { decoded: bare, spelling: bare } } }
} }
function readQuotedValue(text: string, index: number): Read<{ end: number; value: DirectiveValue }> { function readQuotedValue(text: string, index: number): Read<{ end: number; value: DirectiveValue }> {
@@ -257,7 +260,7 @@ function readQuotedValue(text: string, index: number): Read<{ end: number; value
} }
const parsed = parseJson(spelling) const parsed = parseJson(spelling)
if (typeof parsed !== 'string') return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') } if (typeof parsed !== 'string') return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') }
return { value: { end: cursor + 1, value: { spelling, text: parsed } } } return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } }
} }
function parseJson(raw: string): JsonValue | undefined { function parseJson(raw: string): JsonValue | undefined {
+5
View File
@@ -86,6 +86,11 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
return emitDirectiveBlock(node, directive, path, depth) return emitDirectiveBlock(node, directive, path, depth)
} }
// The plain-versus-directive choice is the emitter's; input reads it back rather than restating it (AGENTS.md §11).
export function spellsCommonMark(node: AdfNode, path: ConvertErrorPath, depth: number): boolean {
return readableBlock(node, path, depth) !== undefined
}
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (node.type === 'blockquote') return emitBlockquote(node, path, depth) if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth) if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
+1 -1
View File
@@ -90,7 +90,7 @@ test('holds a directive container open until the fence that closes it', () => {
assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [ assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [
{ {
argument: 'info', argument: 'info',
attributes: new Map([['panelColor', { spelling: '"#ff0000"', text: '#ff0000' }]]), attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]),
blocks: [{ kind: 'paragraph', text: 'Part.' }], blocks: [{ kind: 'paragraph', text: 'Part.' }],
kind: 'directive', kind: 'directive',
name: 'panel', name: 'panel',
+9 -9
View File
@@ -32,7 +32,7 @@ export function readBlockDirectiveNode(
const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path) const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
if (argument !== undefined) { if (argument !== undefined) {
if (argumentKey === undefined) return failure('unsupported-node-shape', `a ${name} takes no argument`, path) if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument`, path)
attrs.value[argumentKey] = argument attrs.value[argumentKey] = argument
} }
const spelled = attributes.get(marksAttribute) const spelled = attributes.get(marksAttribute)
@@ -46,7 +46,7 @@ export function readInlineDirectiveNode(span: DirectiveSpan, path: ConvertErrorP
if (directive === undefined) return faulted(unknownDirectiveFault(span.name), path) if (directive === undefined) return faulted(unknownDirectiveFault(span.name), path)
const slot = directive.textAttribute const slot = directive.textAttribute
if (span.content !== undefined) { if (span.content !== undefined) {
const message = slot === undefined ? `a ${span.name} takes no content` : `the content slot a ${span.name} spells its ${slot} attribute in is unsupported` const message = slot === undefined ? `${span.name} takes no content` : `the content slot ${span.name} spells its ${slot} attribute in is unsupported`
return failure('unsupported-node-shape', message, path) return failure('unsupported-node-shape', message, path)
} }
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
@@ -66,24 +66,24 @@ function readVocabulary(
for (const [key, spelled] of attributes) { for (const [key, spelled] of attributes) {
if (key === elsewhere?.key) { if (key === elsewhere?.key) {
const place = elsewhere.slot === 'argument' ? 'as the directive argument' : 'in the content slot' const place = elsewhere.slot === 'argument' ? 'as the directive argument' : 'in the content slot'
return failure('unsupported-node-shape', `a ${type} spells its ${key} attribute ${place}`, path) return failure('unsupported-node-shape', `${type} spells its ${key} attribute ${place}`, path)
} }
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return failure('unsupported-node-shape', `a ${type} holds no ${key} attribute`, path) if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute`, path)
const read = attributeValue(spelled.text, kind) const read = attributeValue(spelled.decoded, kind)
if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of a ${type} is a ${kind}`, path) if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
const spelling = spellAttributeValue(read) const spelling = spellAttributeValue(read)
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `a ${type} spells its ${key} attribute as ${key}=${spelling}`, path) if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
attrs[key] = read.value attrs[key] = read.value
} }
return success(attrs) return success(attrs)
} }
function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> { function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
const read = attributeValue(spelled.text, 'json') const read = attributeValue(spelled.decoded, 'json')
const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value) const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value)
if (marks === undefined) { if (marks === undefined) {
return failure('unsupported-node-shape', `the ${marksAttribute} attribute of a ${type} is its marks array in canonical JSON`, path) return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON`, path)
} }
return success(marks) return success(marks)
} }
+38 -24
View File
@@ -136,16 +136,30 @@ test('claims a block-level colon run with no directive to parse it', () => {
}) })
test('reads the three directive forms into the nodes the tables name', () => { test('reads the three directive forms into the nodes the tables name', () => {
assert.deepEqual(content(markdownToAdf('::rule\n')), [{ type: 'rule' }]) 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('::paragraph\n')), [{ type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf(' :::panel info\nPart.\n:::\n')), [ assert.deepEqual(content(markdownToAdf(' :::panel info\nPart.\n:::\n')), [
{ attrs: { panelType: 'info' }, content: [paragraph('Part.')], type: 'panel' }, { attrs: { panelType: 'info' }, content: [paragraph('Part.')], type: 'panel' },
]) ])
assert.deepEqual(content(markdownToAdf(':::blockquote\n:::\n')), [{ type: 'blockquote' }]) assert.deepEqual(content(markdownToAdf(':::blockquote {localId=a-1}\n:::\n')), [{ attrs: { localId: 'a-1' }, type: 'blockquote' }])
assert.deepEqual(content(markdownToAdf(':::heading {level=2}\nPart.\n:::\n')), [{ attrs: { level: 2 }, content: [text('Part.')], type: 'heading' }]) assert.deepEqual(content(markdownToAdf(':::heading {level=2 localId=a-1}\nPart.\n:::\n')), [
{ attrs: { level: 2, localId: 'a-1' }, content: [text('Part.')], type: 'heading' },
])
assert.deepEqual(content(markdownToAdf('Part:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('Part:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }])
}) })
// The emitter's plain-versus-directive choice, read backwards: only the form it picks parses.
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'))
// 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' }))])
})
test('names the directive name no node reads back to', () => { test('names the directive name no node reads back to', () => {
assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name') 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') assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node')
@@ -180,7 +194,7 @@ test('reads the reserved marks key as the node array it spells', () => {
}) })
test('names the marks key no marks array reads back from', () => { test('names the marks key no marks array reads back from', () => {
const named = 'unsupported-node-shape: the marks attribute of a rule is its marks array in canonical JSON' const named = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON'
assert.equal(content(markdownToAdf('::rule {marks="[]"}\n')), named) assert.equal(content(markdownToAdf('::rule {marks="[]"}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks="[1]"}\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="{}"}\n')), named)
@@ -189,36 +203,36 @@ test('names the marks key no marks array reads back from', () => {
}) })
test('names the attribute a node holds no reading for', () => { test('names the attribute a node holds no reading for', () => {
assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: a rule holds no bogus attribute') assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute')
assert.equal(content(markdownToAdf('::media {width=wide}\n')), 'unsupported-node-shape: the width attribute of a media is a number') 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 a table is a boolean') 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 a media is a number') 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 a tableCell is a json') assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
const deep = `${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}` const deep = `${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}`
assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${deep}"}\n:::\n`)), 'unsupported-node-shape: the colwidth attribute of a tableCell is a json') assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${deep}"}\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: a panel spells its panelType attribute as the directive argument') assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument')
assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: a mention spells its text attribute in the content slot') assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot')
}) })
test('names the attribute value spelled outside the canonical form', () => { test('names the attribute value spelled outside the canonical form', () => {
assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: a rule spells its localId attribute as localId=a-1') 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: a media spells its width attribute as width=20') 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: a tableCell spells its colwidth attribute as colwidth="[340,420]"') assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340, 420]"}\n:::\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', () => { test('names the argument and the body a node takes no reading for', () => {
assert.equal(content(markdownToAdf('::rule x\n')), 'unsupported-node-shape: a rule takes no argument') assert.equal(content(markdownToAdf('::rule x\n')), 'unsupported-node-shape: rule takes no argument')
assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: a rule holds no content') assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: rule holds no content')
assert.equal(content(markdownToAdf('::bulletList\n')), 'unsupported-node-shape: a bulletList spells its body in the container form :::bulletList') assert.equal(content(markdownToAdf('::bulletList\n')), 'unsupported-node-shape: bulletList spells its body in the container form, :::')
assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph is the leaf form ::paragraph') assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::')
assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: a paragraph takes one paragraph as its body') assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body')
assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: a paragraph takes one paragraph as its body') assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body')
assert.equal(content(markdownToAdf(':::codeBlock\n```\nx\n```\n:::\n')), 'unsupported-node-shape: the fenced body of a codeBlock is unsupported') assert.equal(content(markdownToAdf(':::codeBlock\n```\nx\n```\n:::\n')), 'unsupported-node-shape: the fenced body of codeBlock is unsupported')
assert.equal(content(markdownToAdf(':::paragraph\n![a](/u)\n:::\n')), 'unmappable-image: no ADF node carries an image inside a paragraph') assert.equal(content(markdownToAdf(':::paragraph\n![a](/u)\n:::\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
assert.equal(content(markdownToAdf('Part :date[now]{timestamp=1}.\n')), 'unsupported-node-shape: a date takes no content') assert.equal(content(markdownToAdf('Part :date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content')
assert.equal( assert.equal(
content(markdownToAdf('Part :emoji[x]{shortName=":x:"}.\n')), content(markdownToAdf('Part :emoji[x]{shortName=":x:"}.\n')),
'unsupported-node-shape: the content slot a emoji spells its text attribute in is unsupported', 'unsupported-node-shape: the content slot emoji spells its text attribute in is unsupported',
) )
}) })
+17 -7
View File
@@ -1,5 +1,6 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts' import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryName } from '../opaque-carry.ts' import { carryName } from '../opaque-carry.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -7,6 +8,7 @@ import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
import { spellsCommonMark } from '../emit/adf-to-markdown.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> { export function markdownToAdf(markdown: string): Result<AdfDocument> {
const parsed = parseBlocks(markdown) const parsed = parseBlocks(markdown)
@@ -54,22 +56,30 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path) const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
if (!read.ok) return read if (!read.ok) return read
const { contentModel, node } = read.value const built = directiveBody(read.value, block.blocks, definitions, path, depth)
const blocks = block.blocks if (!built.ok) return built
if (spellsCommonMark(built.value, path, depth)) {
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
}
return built
}
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, node } = read
if (blocks === undefined) { if (blocks === undefined) {
if (contentModel === 'none' || contentModel === 'inline') return success(node) if (contentModel === 'none' || contentModel === 'inline') return success(node)
return failure('unsupported-node-shape', `a ${node.type} spells its body in the container form :::${node.type}`, path) return failure('unsupported-node-shape', `${node.type} spells its body in the container form, :::`, path)
} }
if (contentModel === 'none') return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) if (contentModel === 'none') return failure('unsupported-node-shape', `${node.type} holds no content`, path)
if (contentModel === 'code') return failure('unsupported-node-shape', `the fenced body of a ${node.type} is unsupported`, path) if (contentModel === 'code') return failure('unsupported-node-shape', `the fenced body of ${node.type} is unsupported`, path)
if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth) if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth)
return inlineBodyNode(node, blocks, definitions, path) return inlineBodyNode(node, blocks, definitions, path)
} }
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> { function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} is the leaf form ::${node.type}`, path) if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path)
const only = blocks.length === 1 ? blocks[0] : undefined const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `a ${node.type} takes one paragraph as its body`, path) if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body`, path)
return contentNode(node, only.text, definitions, path) return contentNode(node, only.text, definitions, path)
} }
-2
View File
@@ -18,7 +18,6 @@ const directiveName = /`([a-z][A-Za-z0-9]*)`/g
const namedType = /^`([a-z][A-Za-z0-9]*)` \(([^)]*)\)/ const namedType = /^`([a-z][A-Za-z0-9]*)` \(([^)]*)\)/
const owned = ' — ' const owned = ' — '
// Every node bullet of one section, its continuation lines folded in and its examples dropped.
function bullets(heading: string): string[] { function bullets(heading: string): string[] {
const items: string[] = [] const items: string[] = []
let fence: string | undefined let fence: string | undefined
@@ -78,7 +77,6 @@ function attributeList(heading: string, prose: string): AttributeVocabulary {
} }
} }
// A parenthesized value set documents what payloads hold; the type stays string.
function attributeKind(heading: string, parenthesized: string): AttributeKind { function attributeKind(heading: string, parenthesized: string): AttributeKind {
const first = parenthesized.split(/[\s,]/)[0] ?? '' const first = parenthesized.split(/[\s,]/)[0] ?? ''
if (first === 'boolean' || first === 'json' || first === 'number' || first === 'string') return first if (first === 'boolean' || first === 'json' || first === 'number' || first === 'string') return first
+9 -2
View File
@@ -309,14 +309,19 @@ detail is settled at its own milestone.
split. And input reads canonical `{attrs}` alone, keys in order and every value spelled as split. And input reads canonical `{attrs}` alone, keys in order and every value spelled as
the emitter spells it, the error naming the spelling to write instead: §8 makes loosening a the emitter spells it, the error naming the spelling to write instead: §8 makes loosening a
MINOR, so strict is the reversible direction, as 3f already settled for spacing. MINOR, so strict is the reversible direction, as 3f already settled for spacing.
**Settled** (the maintainer, 2026-09-01, on the review): 2f's plain-versus-directive
choice is read back here rather than at 3h — a directive spelling a node CommonMark holds
is refused, so `::rule` and `:::blockquote` are errors while `::rule {localId=…}` is not.
The parser asks `spellsCommonMark`, the emitter's own choice, rather than restating the
per-node conditions: a copy would refuse the list whose first item reads back as a
thematic break, which the emitter does spell as a directive, and §2 breaks in silence.
Two refusals land here for a later chunk to lift, on the same rule: the inline `[content]` Two refusals land here for a later chunk to lift, on the same rule: the inline `[content]`
slot, which 3i opens for `emoji`, `mention` and `status`, and the `codeBlock` content slot, which 3i opens for `emoji`, `mention` and `status`, and the `codeBlock` content
model's fenced body, 3h's. `Read<T>` stays where 3f left it — the node reader knows its model's fenced body, 3h's. `Read<T>` stays where 3f left it — the node reader knows its
path and returns `Result`, so no second reader took it. The drift guard earned itself on path and returns `Result`, so no second reader took it. The drift guard earned itself on
the way in: the spec's `text` attribute was missing from three inline table entries, which the way in: the spec's `text` attribute was missing from three inline table entries, which
the content slot spells and the vocabulary walk already passes over. the content slot spells and the vocabulary walk already passes over.
- [ ] **3h — The block nodes.** `block-nodes/` reads back: the plain-versus-directive choice 2f - [ ] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the
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; and both `language` attribute a bare fence leaves it; the media family's composition; and both
table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both
directions answer alike and moves to the `markdown/` root with the language attribute. directions answer alike and moves to the `markdown/` root with the language attribute.
@@ -344,6 +349,8 @@ detail is settled at its own milestone.
directive position want a claim code — `:em[x]` is an error forever, so directive position want a claim code — `:em[x]` is an error forever, so
`unknown-directive-name`'s "a later MINOR may give the name meaning" is the wrong signal, `unknown-directive-name`'s "a later MINOR may give the name meaning" is the wrong signal,
as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens. as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
The marks a spelling wraps answer the same question 3g settled for a block's form: only the
nesting the emitter writes parses back.
- [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back: - [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a
named error, a carry inside a mark spelling another, and the three carve-outs' escapes named error, a carry inside a mark spelling another, and the three carve-outs' escapes