Answer the architecture pass: the empty {attrs}, the reserved fence and where the reader lives
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit is contained in:
@@ -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`.
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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<D
|
||||
if (text.charAt(cursor) === '{') {
|
||||
const read = readAttributes(text, cursor)
|
||||
if (read.fault !== undefined) return { fault: read.fault }
|
||||
if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) }
|
||||
attributes = read.value.attributes
|
||||
cursor += read.value.length
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
|
||||
|
||||
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode }
|
||||
|
||||
type Elsewhere = { key: string; place: string }
|
||||
type Elsewhere = { key: string; slot: 'argument' | 'content' }
|
||||
|
||||
export function readBlockDirectiveNode(
|
||||
name: string,
|
||||
@@ -28,7 +28,7 @@ export function readBlockDirectiveNode(
|
||||
const argumentKey = blockArgument(name)
|
||||
const rest = new Map(attributes)
|
||||
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)
|
||||
if (!attrs.ok) return attrs
|
||||
if (argument !== undefined) {
|
||||
@@ -38,7 +38,7 @@ export function readBlockDirectiveNode(
|
||||
const spelled = attributes.get(marksAttribute)
|
||||
const marks: Result<AdfMark[] | undefined> = 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<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`
|
||||
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<AdfAttributes> {
|
||||
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] }
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<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' }
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user