diff --git a/spec/flavour.md b/spec/flavour.md index 1c8548f..bb4791a 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -62,7 +62,9 @@ node and mark names the sections below spell as directives. Recognition is synta name-set-independent: anything matching the forms below parses as a directive regardless of whether the name is known, and an unknown name is an error result naming it — so output an old emitter escaped stays escaped, and erroring input gaining meaning later is MINOR, never a reparse -(§8). The name `adf` is reserved for the opaque carry, as both directive name and fence info +(§8). Each name belongs to one position, and a name the other one spells — a mark or an inline +node written as a block directive, a block node written inline — is a different error, naming the +spelling it takes. The name `adf` is reserved for the opaque carry, as both directive name and fence info string. **Inline**: `:name[content]{attrs}`, on one line — an inline directive never spans lines. diff --git a/src/markdown/parse/directive-marks.ts b/src/markdown/parse/directive-marks.ts index a27b991..bb4a953 100644 --- a/src/markdown/parse/directive-marks.ts +++ b/src/markdown/parse/directive-marks.ts @@ -1,4 +1,5 @@ import type { AdfMark } from '../../adf/document.ts' +import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes } from '../directive-syntax.ts' import type { MarkSpelling } from '../mark-spellings.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -15,6 +16,12 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes, return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name }) } +export function markSpellingFault(name: string): ConvertFault | undefined { + const spelling = markSpelling(name) + if (spelling === undefined) return undefined + return { code: 'unsupported-node-shape', message: `${name} is spelled ${markdownForm(spelling) ?? `:${name}[…]`}, never as a block directive` } +} + function markdownForm(spelling: MarkSpelling): string | undefined { switch (spelling.kind) { case 'code': diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index d4530cd..cffe314 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -1,5 +1,6 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { BlockDirective } from '../../adf/block-directives.ts' +import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { Elsewhere } from './directive-attributes.ts' import { attributeValue, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' @@ -8,9 +9,11 @@ import { blockDirective } from '../../adf/block-directives.ts' import { carryName } from '../opaque-carry.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineDirective } from '../../adf/inline-directives.ts' +import { markSpellingFault } from './directive-marks.ts' import { marksAttribute, readMarkValues } from '../block-directive-marks.ts' import { readVocabulary } from './directive-attributes.ts' import { slotLineEndingFault } from '../directive-syntax.ts' +import { textDirectiveName } from '../text-directive.ts' export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode } @@ -24,7 +27,7 @@ export function readBlockDirectiveNode( return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the fence`, path) } const directive = blockDirective(name) - if (directive === undefined) return faulted(unknownDirectiveFault(name), path) + if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path) const argumentKey = blockArgument(name) const rest = new Map(attributes) rest.delete(marksAttribute) @@ -48,7 +51,7 @@ export function readInlineDirectiveNode( path: ConvertErrorPath, ): Result { const directive = inlineDirective(name) - if (directive === undefined) return faulted(unknownDirectiveFault(name), path) + if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path) const slot = directive.textAttribute if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content`, path) const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } @@ -64,6 +67,19 @@ export function readInlineDirectiveNode( return success(namedNode(name, attrs.value, undefined)) } +// A name the other position spells names that spelling, never the code a later MINOR may fill (AGENTS.md §8). +function inlineSpellingFault(name: string): ConvertFault | undefined { + const mark = markSpellingFault(name) + if (mark !== undefined) return mark + if (inlineDirective(name) === undefined && name !== textDirectiveName) return undefined + return { code: 'unsupported-node-shape', message: `${name} takes the inline form, :${name}` } +} + +function blockSpellingFault(name: string): ConvertFault | undefined { + if (blockDirective(name) === undefined) return undefined + return { code: 'unsupported-node-shape', message: `${name} takes the block form, ::${name}` } +} + // spec/flavour.md, Inline nodes: the slot is plain text, its adjacent nodes already merged. function slotText(content: readonly AdfNode[]): string | undefined { if (content.length === 0) return '' diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 8f5aac2..d510ec6 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -251,6 +251,16 @@ test('names the directive name no node reads back to', () => { assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1]) }) +test('names the position a directive name the other one spells belongs to', () => { + assert.equal(content(markdownToAdf(':::em\na\n:::\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive') + assert.equal(content(markdownToAdf('::underline\n')), 'unsupported-node-shape: underline is spelled :underline[…], never as a block directive') + assert.equal(content(markdownToAdf('::text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, :text') + assert.equal(content(markdownToAdf('::date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, :date') + assert.equal(content(markdownToAdf(':paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, ::paragraph') + assert.equal(code(markdownToAdf(':::widget\na\n:::\n')), 'unknown-directive-name') + assert.equal(code(markdownToAdf(':widget[a]\n')), 'unknown-directive-name') +}) + test('names the reserved carry name a block directive spells', () => { const reserved = 'malformed-directive: the name adf is reserved for the opaque carry, whose block form is the fence' assert.equal(content(markdownToAdf('::adf\n')), reserved) diff --git a/src/markdown/text-directive.ts b/src/markdown/text-directive.ts index 63ab681..8cf4ddf 100644 --- a/src/markdown/text-directive.ts +++ b/src/markdown/text-directive.ts @@ -2,14 +2,17 @@ import type { ConvertFault } from '../result.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts' import { spellAttributes, spellLeafDirective, spellStringAttribute } from './directive-syntax.ts' -const name = 'text' +export const textDirectiveName = 'text' + const whitespaceRun = /^(?:[ \t]+|\n+)$/ export function spellTextDirective(text: string): string { + const name = textDirectiveName return spellLeafDirective(name, spellAttributes([[name, spellStringAttribute(text)]])) } export function readTextDirective(span: DirectiveSpan): Read | undefined { + const name = textDirectiveName if (span.name !== name) return undefined if (span.content !== undefined) return { fault: unsupported(`${name} takes no content`) } const spelled = span.attributes.get(name) diff --git a/src/spec.test.ts b/src/spec.test.ts index 04b399a..9017051 100644 --- a/src/spec.test.ts +++ b/src/spec.test.ts @@ -8,6 +8,7 @@ import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabul import { blockDirectives } from './adf/block-directives.ts' import { inlineDirectives } from './adf/inline-directives.ts' import { markAttributes } from './adf/mark-attributes.ts' +import { textDirectiveName } from './markdown/text-directive.ts' type Declared = { attributes: AttributeVocabulary } @@ -96,6 +97,12 @@ test('the inline node table holds the attributes spec/flavour.md gives each node assert.deepEqual(declarations('Inline nodes'), vocabularies(inlineDirectives)) }) +// A name in two tables would make the position a directive is read in ambiguous. +test('no name is spelled in more than one position', () => { + const names = [...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes), textDirectiveName] + assert.equal(new Set(names).size, names.length) +}) + test('the mark table holds the attributes spec/flavour.md gives each mark', () => { assert.deepEqual(declarations('Marks'), vocabularies(Object.fromEntries(Object.entries(markAttributes).map(([type, attributes]) => [type, { attributes }])))) }) diff --git a/todo-history.md b/todo-history.md index 9b48aaf..382dd41 100644 --- a/todo-history.md +++ b/todo-history.md @@ -364,6 +364,12 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: fixtures, its cause now a marked slot rather than a slot at all. The slot's own whitespace answers the rule the spelling does: `:text{text="\n"}` and ` ` alike reach a slot the emitter refuses a line ending in, so one function answers both directions. + **Settled** (the maintainer, 2026-09-01): a name the other position spells names that + spelling rather than reading as unknown — `:::em` and `::date` take + `unsupported-node-shape` naming the inline form, `:paragraph[a]` the block one — leaving + `unknown-directive-name` for a name no table holds, which is the meaning §8 gives it. The + two readers lean on the tables being disjoint, so that is a test beside the spec drift + guard now. The same read found the hole the other way: `attemptLine` refused a line edged with a vertical tab or a form feed, where CommonMark strips spaces and tabs alone, so valid CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered