Read the node tables backwards, a directive to the node and marks it names #36

Merged
lilleman merged 5 commits from tick-3g into main 2026-09-01 14:47:43 +02:00
8 changed files with 55 additions and 17 deletions
Showing only changes of commit f10353dc78 - Show all commits
+11
View File
@@ -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 live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against
`markdownToAdf` and `markdownToHtml`. `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 ## 11. Code rules
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order - 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. 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 - 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
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 - 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 file does not repeat its directory in its name — `adf/document.ts`, never
`adf/adf-document.ts`. `adf/adf-document.ts`.
+3 -2
View File
@@ -113,8 +113,9 @@ assigns types.
Canonical form orders keys alphabetically, spells values bare wherever allowed, escapes inside 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 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 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 quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` the name or the
outside its canonical JSON spelling are each a named error naming the spelling to write instead. `[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 **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 directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right
+9 -3
View File
@@ -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('::rule '), header(2, 'rule')) assert.deepEqual(readDirectiveLine('::rule '), header(2, 'rule'))
assert.deepEqual(readDirectiveLine('::taskItem TODO'), header(2, 'taskItem', 'TODO')) 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('::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 {panelColor="#ff0000"} '), header(2, 'panel', 'info', ['panelColor', '#ff0000', '"#ff0000"']))
assert.deepEqual(readDirectiveLine(':::panel info'), header(3, 'panel', 'info')) 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'])) 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', () => { 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('::Panel'), 'a directive name reads [a-z][A-Za-z0-9]*')
assert.equal(fault('::1panel'), 'a directive name reads [a-z][A-Za-z0-9]*') assert.equal(fault('::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 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{}'), '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 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', () => { 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[a `b c]', 'underline', 'a `b c')
spans(':underline[:status[x]{color=red}]', 'underline', ':status[x]{color=red}') 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 "']) 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(':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' }) assert.deepEqual(inline(':text{text=" "} and more'), { attributes: attributes(['text', ' ', '" "']), content: undefined, length: 15, name: 'text' })
}) })
+3
View File
@@ -37,6 +37,7 @@ const quotedEscapes = new RegExp(reservedSource, 'g')
const rawReserved = new RegExp(reservedSource) const rawReserved = new RegExp(reservedSource)
const noAttributes: DirectiveAttributes = new Map() 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 nameFault = 'a directive name reads [a-z][A-Za-z0-9]*'
const orderFault = 'the {attrs} keys read in alphabetical order' const orderFault = 'the {attrs} keys read in alphabetical order'
const pairFault = 'an attribute reads key=value, the value bare or double-quoted' 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) === '{') { if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') {
const read = readAttributes(rest, cursor + 1) const read = readAttributes(rest, cursor + 1)
if (read.fault !== undefined) return { fault: read.fault } if (read.fault !== undefined) return { fault: read.fault }
if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) }
attributes = read.value.attributes attributes = read.value.attributes
cursor += 1 + read.value.length cursor += 1 + read.value.length
} }
@@ -161,6 +163,7 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
if (text.charAt(cursor) === '{') { if (text.charAt(cursor) === '{') {
const read = readAttributes(text, cursor) const read = readAttributes(text, cursor)
if (read.fault !== undefined) return { fault: read.fault } if (read.fault !== undefined) return { fault: read.fault }
if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) }
attributes = read.value.attributes attributes = read.value.attributes
cursor += read.value.length cursor += read.value.length
} }
+10 -7
View File
@@ -12,7 +12,7 @@ import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode } export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode }
type Elsewhere = { key: string; place: string } type Elsewhere = { key: string; slot: 'argument' | 'content' }
export function readBlockDirectiveNode( export function readBlockDirectiveNode(
name: string, name: string,
@@ -28,7 +28,7 @@ export function readBlockDirectiveNode(
const argumentKey = blockArgument(name) const argumentKey = blockArgument(name)
const rest = new Map(attributes) const rest = new Map(attributes)
rest.delete(marksAttribute) rest.delete(marksAttribute)
const elsewhere = argumentKey === undefined ? undefined : { key: argumentKey, place: 'as the directive argument' } const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
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) {
@@ -38,7 +38,7 @@ export function readBlockDirectiveNode(
const spelled = attributes.get(marksAttribute) const spelled = attributes.get(marksAttribute)
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path) const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
if (!marks.ok) return marks 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<AdfNode> { export function readInlineDirectiveNode(span: DirectiveSpan, path: ConvertErrorPath): Result<AdfNode> {
@@ -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` 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) 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) const attrs = readVocabulary(span.name, span.attributes, directive.attributes, elsewhere, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
return success(directiveNode(span.name, attrs.value, undefined)) return success(namedNode(span.name, attrs.value, undefined))
} }
function readVocabulary( function readVocabulary(
@@ -64,7 +64,10 @@ function readVocabulary(
): Result<AdfAttributes> { ): Result<AdfAttributes> {
const attrs: AdfAttributes = {} const attrs: AdfAttributes = {}
for (const [key, spelled] of attributes) { 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 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', `a ${type} holds no ${key} attribute`, path)
const read = attributeValue(spelled.text, kind) const read = attributeValue(spelled.text, kind)
@@ -85,7 +88,7 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath
return success(marks) 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 } const named = Object.keys(attrs).length === 0 ? { type } : { attrs, type }
return marks === undefined ? named : { ...named, marks: [...marks] } return marks === undefined ? named : { ...named, marks: [...marks] }
} }
@@ -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' 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\n')), reserved)
assert.equal(content(markdownToAdf(':::adf\nx\n:::\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', () => { test('reads each attribute value as the type its section assigns', () => {
+5 -3
View File
@@ -1,6 +1,7 @@
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 { LinkDefinitions } from './inline-content.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 { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
@@ -32,7 +33,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
case 'bulletList': case 'bulletList':
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
case 'code': case 'code':
return success(codeBlockNode(block.language, block.text)) return codeBlockNode(block.language, block.text, path)
case 'directive': case 'directive':
return directiveNode(block, definitions, path, depth) return directiveNode(block, definitions, path, depth)
case 'fault': case 'fault':
@@ -92,9 +93,10 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
return success({ ...node, content }) return success({ ...node, content })
} }
function codeBlockNode(language: string, text: string): AdfNode { function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result<AdfNode> {
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' } 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. // spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
+12 -2
View File
@@ -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 - [ ] **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 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. 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 **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 `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 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 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 merging half lands here, `text-whitespace` being the first fixture that forces it, and 4's
`toEditorNormal` is built on it. `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: - [ ] **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
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 - [ ] **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 — the version it ships — the one `html-blocks.ts` names for its start conditions —
`corpus/README.md` gaining the kind. `corpus/README.md` gaining the kind.