From dcb4d67b6c918f84a99e36f369f897b8cf5e5818 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 1 Sep 2026 14:13:01 +0200 Subject: [PATCH] Answer the stability pass: the misleading spellings, and only the form the emitter picks --- AGENTS.md | 4 +- corpus/errors/directive-form-commonmark.error | 1 + corpus/errors/directive-form-commonmark.md | 1 + spec/flavour.md | 8 ++- src/corpus.test.ts | 2 +- src/markdown/directive-syntax.test.ts | 4 +- src/markdown/directive-syntax.ts | 15 +++-- src/markdown/emit/adf-to-markdown.ts | 5 ++ src/markdown/parse/blocks.test.ts | 2 +- src/markdown/parse/directive-nodes.ts | 18 +++--- src/markdown/parse/markdown-to-adf.test.ts | 62 ++++++++++++------- src/markdown/parse/markdown-to-adf.ts | 24 ++++--- src/spec.test.ts | 2 - todo.md | 11 +++- 14 files changed, 101 insertions(+), 58 deletions(-) create mode 100644 corpus/errors/directive-form-commonmark.error create mode 100644 corpus/errors/directive-form-commonmark.md diff --git a/AGENTS.md b/AGENTS.md index 412187e..9a3a8de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 across the seam. A rule both 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 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 diff --git a/corpus/errors/directive-form-commonmark.error b/corpus/errors/directive-form-commonmark.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/directive-form-commonmark.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/directive-form-commonmark.md b/corpus/errors/directive-form-commonmark.md new file mode 100644 index 0000000..98aed2d --- /dev/null +++ b/corpus/errors/directive-form-commonmark.md @@ -0,0 +1 @@ +::rule diff --git a/spec/flavour.md b/spec/flavour.md index 80847f9..6ff1570 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -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 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 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 `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 -required non-empty. +not directive names: `:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline` +are inline directives, content required non-empty. - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3). - `code`, `em`, `strike`, `strong` — Attributes: none. diff --git a/src/corpus.test.ts b/src/corpus.test.ts index e22652a..161c551 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -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( parsingDirectories.filter((directory) => emittingDirectories.includes(directory)), parsingDirectories, diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index d0bb93c..d720229 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -5,11 +5,11 @@ import type { DirectiveAttributes, DirectiveLine } from './directive-syntax.ts' import { largestNesting } from '../nesting.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?] 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 } { diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 7939149..92b1d6a 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -8,8 +8,7 @@ import { largestNesting } from '../nesting.ts' import { runLength } from './emphasis-matching.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 = { spelling: string; text: string } +export type DirectiveValue = { decoded: string; spelling: string } export type DirectiveAttributes = ReadonlyMap @@ -80,7 +79,7 @@ export function readInlineDirective(text: string, index: number): Read (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(' ')}}` } @@ -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` } } +function keyOrder(left: string, right: string): number { + return left < right ? -1 : 1 +} + function quote(text: string): string { return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${escapeDigits(character)}`) } @@ -221,7 +224,7 @@ function readAttributes(text: string, index: number): Read { if (pair.fault !== undefined) return { fault: pair.fault } const key = pair.value.key 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 attributes.set(key, pair.value.value) cursor = pair.value.end @@ -243,7 +246,7 @@ function readAttributePair(text: string, index: number): Read { bareRun.lastIndex = start const bare = bareRun.exec(text)?.[0] 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 }> { @@ -257,7 +260,7 @@ function readQuotedValue(text: string, index: number): Read<{ end: number; value } const parsed = parseJson(spelling) 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 { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 8dbcd68..eef32d1 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -86,6 +86,11 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result 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 | undefined { if (node.type === 'blockquote') return emitBlockquote(node, path, depth) if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth) diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index 93ac65e..8396337 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -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, [ { argument: 'info', - attributes: new Map([['panelColor', { spelling: '"#ff0000"', text: '#ff0000' }]]), + attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]), blocks: [{ kind: 'paragraph', text: 'Part.' }], kind: 'directive', name: 'panel', diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 7ab7f29..d649c70 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -32,7 +32,7 @@ export function readBlockDirectiveNode( const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path) if (!attrs.ok) return attrs 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 } 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) const slot = directive.textAttribute 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) } const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } @@ -66,24 +66,24 @@ function readVocabulary( for (const [key, spelled] of attributes) { if (key === elsewhere?.key) { 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 - if (kind === undefined) return failure('unsupported-node-shape', `a ${type} holds no ${key} attribute`, path) - const read = attributeValue(spelled.text, kind) - if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of a ${type} is a ${kind}`, path) + if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute`, path) + const read = attributeValue(spelled.decoded, kind) + if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path) 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 } return success(attrs) } function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result { - const read = attributeValue(spelled.text, 'json') + const read = attributeValue(spelled.decoded, 'json') const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value) 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) } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index e58c2d9..71d34f0 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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', () => { - 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(' :::panel info\nPart.\n:::\n')), [ { attrs: { panelType: 'info' }, content: [paragraph('Part.')], type: 'panel' }, ]) - assert.deepEqual(content(markdownToAdf(':::blockquote\n:::\n')), [{ type: 'blockquote' }]) - assert.deepEqual(content(markdownToAdf(':::heading {level=2}\nPart.\n:::\n')), [{ attrs: { level: 2 }, content: [text('Part.')], type: 'heading' }]) + assert.deepEqual(content(markdownToAdf(':::blockquote {localId=a-1}\n:::\n')), [{ attrs: { localId: 'a-1' }, type: 'blockquote' }]) + assert.deepEqual(content(markdownToAdf(':::heading {level=2 localId=a-1}\nPart.\n:::\n')), [ + { 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' }]) }) +// 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', () => { 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') @@ -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', () => { - 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="[1]"}\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', () => { - assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: a 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(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of a table is a 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(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of a tableCell is a json') + 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 media is no number') + assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') + assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') + assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') 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(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: a 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(`:::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: panel spells its panelType attribute as the directive argument') + 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', () => { - 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('::media {width="20.0"}\n')), 'unsupported-node-shape: a 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('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1') + assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20') + assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340, 420]"}\n:::\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="[340,420]"') }) 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\nPart.\n:::\n')), 'unsupported-node-shape: a 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(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph is the leaf form ::paragraph') - 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\n---\n:::\n')), 'unsupported-node-shape: a 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('::rule x\n')), 'unsupported-node-shape: rule takes no argument') + assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: rule holds no content') + 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 takes the leaf form, ::') + 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: paragraph takes one paragraph as its body') + 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('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( 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', ) }) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 4fd402c..a03f404 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -1,5 +1,6 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { Block, DirectiveBlock } from './blocks.ts' +import type { BlockDirectiveNode } from './directive-nodes.ts' import type { LinkDefinitions } from './inline-content.ts' import { carryName } from '../opaque-carry.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 { parseInlineContent } from './inline-content.ts' import { readBlockDirectiveNode } from './directive-nodes.ts' +import { spellsCommonMark } from '../emit/adf-to-markdown.ts' export function markdownToAdf(markdown: string): Result { 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 { const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path) if (!read.ok) return read - const { contentModel, node } = read.value - const blocks = block.blocks + const built = directiveBody(read.value, block.blocks, definitions, path, depth) + 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 { + const { contentModel, node } = read if (blocks === undefined) { 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 === 'code') return failure('unsupported-node-shape', `the fenced body of a ${node.type} is unsupported`, 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 ${node.type} is unsupported`, path) if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth) return inlineBodyNode(node, blocks, definitions, path) } function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result { - if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} 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 - 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) } diff --git a/src/spec.test.ts b/src/spec.test.ts index a4af0f1..04b399a 100644 --- a/src/spec.test.ts +++ b/src/spec.test.ts @@ -18,7 +18,6 @@ const directiveName = /`([a-z][A-Za-z0-9]*)`/g const namedType = /^`([a-z][A-Za-z0-9]*)` \(([^)]*)\)/ const owned = ' — ' -// Every node bullet of one section, its continuation lines folded in and its examples dropped. function bullets(heading: string): string[] { const items: string[] = [] 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 { const first = parenthesized.split(/[\s,]/)[0] ?? '' if (first === 'boolean' || first === 'json' || first === 'number' || first === 'string') return first diff --git a/todo.md b/todo.md index 5bea29c..207aead 100644 --- a/todo.md +++ b/todo.md @@ -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 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. + **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]` slot, which 3i opens for `emoji`, `mention` and `status`, and the `codeBlock` content model's fenced body, 3h's. `Read` 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 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. - - [ ] **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 + - [ ] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the `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 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 `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. + 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: 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