From f10353dc788d63ae9307746b11e2ed30f884f091 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 1 Sep 2026 13:42:57 +0200 Subject: [PATCH] Answer the architecture pass: the empty {attrs}, the reserved fence and where the reader lives --- AGENTS.md | 11 +++++++++++ spec/flavour.md | 5 +++-- src/markdown/directive-syntax.test.ts | 12 +++++++++--- src/markdown/directive-syntax.ts | 3 +++ src/markdown/parse/directive-nodes.ts | 17 ++++++++++------- src/markdown/parse/markdown-to-adf.test.ts | 2 ++ src/markdown/parse/markdown-to-adf.ts | 8 +++++--- todo.md | 14 ++++++++++++-- 8 files changed, 55 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e5b018..412187e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,14 @@ The corpus, all checked in: hand-built fixtures per node and combination; real s live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`. +`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they +copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes +named before its first em dash, with the attributes following `Attributes: ` — a parenthesized +value set reading `string` — and must equal the tables in `adf/`. Keep prose in those sections out +of a bullet; fenced examples are skipped. It guards the attributes alone: nodes that differ in +content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so +both answer to the round-trip corpus and to nothing else where a node has no fixture. + ## 11. Code rules - Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order @@ -165,6 +173,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga there too, never a copy per direction, however conservative the copy would be. - 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 + decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a + number is the markdown flavour's choice, not ADF's. - Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a file does not repeat its directory in its name — `adf/document.ts`, never `adf/adf-document.ts`. diff --git a/spec/flavour.md b/spec/flavour.md index 26a6a96..80847f9 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -113,8 +113,9 @@ assigns types. Canonical form orders keys alphabetically, spells values bare wherever allowed, escapes inside quotes in the shortest form each escape has, and omits empty `{attrs}` except where the `{` itself claims the directive (`:hardBreak{}`). Input reads that spelling alone: keys out of order, a value -quoted where bare carries it, an escape longer than it need be, and a number or `json` value -outside its canonical JSON spelling are each a named error naming the spelling to write instead. +quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` the name or the +`[content]` already claims, and a number or `json` value outside its canonical JSON spelling are +each a named error naming the spelling to write instead. **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index da5395b..d0bb93c 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -49,7 +49,6 @@ 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', '"#ff0000"'])) assert.deepEqual(readDirectiveLine(':::panel info'), header(3, 'panel', 'info')) @@ -70,6 +69,14 @@ test('names the {attrs} keys read out of the alphabetical order canonical form s assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file'])) }) +test('spells an empty {attrs} only where the brace itself claims the directive', () => { + const omitted = 'an empty {attrs} is omitted unless the { itself claims the directive' + assert.equal(fault('::rule {}'), omitted) + assert.equal(fault(':::panel info {}'), omitted) + assert.equal(inline(':underline[a]{}'), omitted) + spans(':hardBreak{}', 'hardBreak', undefined) +}) + test('names the directive line no spelling reads', () => { 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]*') @@ -77,7 +84,7 @@ test('names the directive line no spelling reads', () => { 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') + assert.equal(fault('::panel {a=1} x'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') }) test('names the attributes no spelling reads', () => { @@ -119,7 +126,6 @@ test('binds an inline directive as a unit, its content balancing brackets like l 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 ', '"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' }) }) diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 298dd7f..7939149 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -37,6 +37,7 @@ const quotedEscapes = new RegExp(reservedSource, 'g') const rawReserved = new RegExp(reservedSource) const noAttributes: DirectiveAttributes = new Map() +const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive' const nameFault = 'a directive name reads [a-z][A-Za-z0-9]*' const orderFault = 'the {attrs} keys read in alphabetical order' const pairFault = 'an attribute reads key=value, the value bare or double-quoted' @@ -136,6 +137,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined; if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { const read = readAttributes(rest, cursor + 1) if (read.fault !== undefined) return { fault: read.fault } + if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) } attributes = read.value.attributes cursor += 1 + read.value.length } @@ -161,6 +163,7 @@ function readNestedDirective(text: string, index: number, depth: number): Read = spelled === undefined ? success(undefined) : readMarks(name, spelled, path) if (!marks.ok) return marks - return success({ contentModel: directive.contentModel, node: directiveNode(name, attrs.value, marks.value) }) + return success({ contentModel: directive.contentModel, node: namedNode(name, attrs.value, marks.value) }) } export function readInlineDirectiveNode(span: DirectiveSpan, path: ConvertErrorPath): Result { @@ -49,10 +49,10 @@ export function readInlineDirectiveNode(span: DirectiveSpan, path: ConvertErrorP const message = slot === undefined ? `a ${span.name} takes no content` : `the content slot a ${span.name} spells its ${slot} attribute in is unsupported` return failure('unsupported-node-shape', message, path) } - const elsewhere = slot === undefined ? undefined : { key: slot, place: 'in the content slot' } + const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } const attrs = readVocabulary(span.name, span.attributes, directive.attributes, elsewhere, path) if (!attrs.ok) return attrs - return success(directiveNode(span.name, attrs.value, undefined)) + return success(namedNode(span.name, attrs.value, undefined)) } function readVocabulary( @@ -64,7 +64,10 @@ function readVocabulary( ): Result { const attrs: AdfAttributes = {} for (const [key, spelled] of attributes) { - if (key === elsewhere?.key) return failure('unsupported-node-shape', `a ${type} spells its ${key} attribute ${elsewhere.place}`, path) + 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) + } 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) @@ -85,7 +88,7 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath return success(marks) } -function directiveNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined): AdfNode { +function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined): AdfNode { const named = Object.keys(attrs).length === 0 ? { type } : { attrs, type } return marks === undefined ? named : { ...named, marks: [...marks] } } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 50c506b..e58c2d9 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -159,6 +159,8 @@ 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) assert.equal(content(markdownToAdf(':::adf\nx\n:::\n')), reserved) + assert.equal(content(markdownToAdf('```adf\nx\n```\n')), 'malformed-directive: the info string adf is reserved for the opaque carry') + assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }]) }) test('reads each attribute value as the type its section assigns', () => { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 25aea81..4fd402c 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -1,6 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { Block, DirectiveBlock } from './blocks.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' import { largestNesting } from '../../nesting.ts' import { parseBlocks } from './blocks.ts' @@ -32,7 +33,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro case 'bulletList': return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) case 'code': - return success(codeBlockNode(block.language, block.text)) + return codeBlockNode(block.language, block.text, path) case 'directive': return directiveNode(block, definitions, path, depth) case 'fault': @@ -92,9 +93,10 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef return success({ ...node, content }) } -function codeBlockNode(language: string, text: string): AdfNode { +function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result { + if (language === carryName) return failure('malformed-directive', `the info string ${carryName} is reserved for the opaque carry`, path) const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' } - return text === '' ? node : { ...node, content: [{ text, type: 'text' }] } + return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] }) } // spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs. diff --git a/todo.md b/todo.md index fc42b7f..5bea29c 100644 --- a/todo.md +++ b/todo.md @@ -318,7 +318,8 @@ detail is settled at its own milestone. - [ ] **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; and both - table forms, the pipe table's cell split and its named errors. + 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. **Settled** (the maintainer, 2026-08-27): 1d's last pick, the one `container-block-separation` holds — a CommonMark block and a directive block sit adjacent in a container body with no blank line between them. That reduces the three cases to one @@ -335,10 +336,19 @@ detail is settled at its own milestone. outermost first; and `:em[x]` as the error `spec/flavour.md` promises. Editor-normal's merging half lands here, `text-whitespace` being the first fixture that forces it, and 4's `toEditorNormal` is built on it. + 3g's shape leaves three: `readInlineDirectiveNode` takes the name, the attributes and the + slot's parsed text rather than the span, since `inline-content.ts` already imports it and + parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts` + that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart + from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline + 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. - [ ] **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 - reading as the literal text they hold. + reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a + `codeBlock` from it; the refusal goes when the carry reads it. - [ ] **3k — The CommonMark spec suite.** Checked in at `corpus/commonmark-spec/`, pinned to the version it ships — the one `html-blocks.ts` names for its start conditions — `corpus/README.md` gaining the kind.