9 Commits

Author SHA1 Message Date
lilleman 758a5724e9 4.1: an iterative canonical serializer
CI / gate (push) Successful in 19s
CI / publish (push) Has been skipped
2026-09-14 20:11:25 +02:00
lilleman 276433d635 4.1: the review's test and comment fixes 2026-09-14 20:11:25 +02:00
lilleman 7672531cdc 4.1: editor-normal and the node accessors 2026-09-14 20:11:25 +02:00
lilleman ec5f80e47f 4.1: a text node carrying attributes never merges 2026-09-14 20:11:25 +02:00
lilleman da58a59f70 Revise 10: an alert-and-callout lossy pair, to and from markdown
CI / publish (push) Successful in 3s
CI / gate (push) Successful in 17s
2026-09-14 19:34:30 +02:00
lilleman 2796487b30 Tick 11, moving its text to todo-history.md, and correct 4.1's text
CI / gate (push) Successful in 16s
CI / publish (push) Has been cancelled
2026-09-14 19:34:02 +02:00
lilleman dcea401ec2 11b: fail on combinators an attribute kind cannot read
CI / gate (push) Successful in 17s
CI / publish (push) Successful in 3s
2026-09-14 17:33:13 +02:00
lilleman 25d851cc67 11b: guard the gate against empty gaps and unread keywords
CI / gate (push) Successful in 17s
CI / publish (push) Has been skipped
2026-09-14 16:54:32 +02:00
lilleman 0f03edc8ef 11b: the gate
CI / gate (push) Successful in 17s
CI / publish (push) Has been skipped
2026-09-14 16:16:49 +02:00
4 changed files with 260 additions and 62 deletions
+169
View File
@@ -5,10 +5,179 @@ import { dirname, join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
import { blockDirectives } from './adf/block-directives.ts'
import { inlineDirectives } from './adf/inline-directives.ts'
import { markAttributes } from './adf/mark-attributes.ts'
import { blockArgument } from './markdown/block-directive-arguments.ts'
type Held = Map<string, Set<AttributeKind>>
type Properties = Map<string, SchemaObject[]>
type SchemaObject = Readonly<Record<string, unknown>>
type Spelled = [string, Map<string, AttributeKind>]
const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder']
const definitionReference = '#/definitions/'
const gaps = ['layoutSection.columnRuleStyle', 'link.collection', 'link.id', 'link.occurrenceKey', 'rule.color', 'rule.style', 'rule.weight']
const grammarOwn = ['doc', 'text']
const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type']
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema') const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema')
const schemaFiles = ['full.json', 'stage-0.json']
test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => { test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => {
const digest = (name: string) => createHash('sha256').update(readFileSync(join(root, name))).digest('hex') const digest = (name: string) => createHash('sha256').update(readFileSync(join(root, name))).digest('hex')
assert.equal(digest('full.json'), '75f080928a970250eb8289e9cae5374e3c2a6c0ac3ca22478acaa9d3f39484a3') assert.equal(digest('full.json'), '75f080928a970250eb8289e9cae5374e3c2a6c0ac3ca22478acaa9d3f39484a3')
assert.equal(digest('stage-0.json'), '56747e5a71c0d5f8c58f94180d69e4482f62c08020d66aaf327333681fcc1c8f') assert.equal(digest('stage-0.json'), '56747e5a71c0d5f8c58f94180d69e4482f62c08020d66aaf327333681fcc1c8f')
}) })
test('the tables spell the attribute names and kinds the ADF JSON Schemas give each type they spell, the pinned gaps apart', () => {
const held = schemaTypes()
const spelledTypes = spelled()
const found: string[] = []
const gapsHeld = new Set<string>()
for (const [type, spelledKinds] of spelledTypes) {
const kinds = held.get(type)
if (kinds === undefined) {
found.push(`${type}: the tables spell the type, the schema holds no definition of it`)
continue
}
for (const [attribute, schemaKinds] of kinds) {
const kind = spelledKinds.get(attribute)
const holds = [...schemaKinds].sort().join(' or ')
if (kind === undefined && gaps.includes(`${type}.${attribute}`)) gapsHeld.add(`${type}.${attribute}`)
else if (kind === undefined) found.push(`${type}.${attribute}: the schema holds ${holds}, the tables spell nothing and the gaps list does not name it`)
else if (schemaKinds.size !== 1 || !schemaKinds.has(kind)) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds ${holds}`)
}
for (const [attribute, kind] of spelledKinds) if (!kinds.has(attribute)) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds nothing`)
}
for (const gap of gaps) {
if (gapsHeld.has(gap)) continue
const [type = '', attribute = ''] = gap.split('.')
const spelledKinds = spelledTypes.find(([name]) => name === type)?.[1]
const kind = spelledKinds?.get(attribute)
if (spelledKinds === undefined) found.push(`${gap}: the gaps list names it, the tables spell no ${type} type`)
else if (kind === undefined) found.push(`${gap}: the gaps list names it, the schema holds nothing`)
else found.push(`${gap}: the gaps list names it, the tables spell ${kind}`)
}
assert.deepEqual(found, [])
})
test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned carried ones and the grammar's own apart", () => {
const held = schemaTypes()
const spelledNames = new Set(spelled().map(([type]) => type))
const found: string[] = []
for (const type of held.keys()) {
if (!spelledNames.has(type) && !carried.includes(type) && !grammarOwn.includes(type)) found.push(`${type}: the schema holds the type, the tables spell none of it and the carried list does not name it`)
}
for (const type of carried) {
if (!held.has(type)) found.push(`${type}: the carried list names the type, the schema holds no definition of it`)
if (spelledNames.has(type)) found.push(`${type}: the carried list names the type, and the tables spell it`)
}
assert.deepEqual(found, [])
})
function spelled(): Spelled[] {
return [
...Object.entries(blockDirectives).map(([type, entry]) => spelledType(type, entry.attributes, blockArgument(type))),
...Object.entries(inlineDirectives).map(([type, entry]) => spelledType(type, entry.attributes)),
...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, attributes)),
]
}
function spelledType(type: string, attributes: AttributeVocabulary, argument?: string): Spelled {
const kinds = new Map(Object.entries(attributes))
if (argument !== undefined) kinds.set(argument, 'string')
return [type, kinds]
}
function schemaTypes(): Map<string, Held> {
const held = new Map<string, Held>()
for (const file of schemaFiles) {
const definitions = schemaObject(schemaObject(JSON.parse(readFileSync(join(root, file), 'utf8')), file)['definitions'], `${file} definitions`)
for (const [name, definition] of Object.entries(definitions)) {
const where = `${file} ${name}`
for (const properties of alternatives(schemaObject(definition, where), definitions, where)) {
const attributes = (properties.get('attrs') ?? []).flatMap((attrs) => alternatives(attrs, definitions, `${where} attrs`)).flatMap((alternative) => [...alternative])
for (const type of (properties.get('type') ?? []).flatMap((schema) => enumStrings(schema, `${where} type`))) {
const kinds = held.get(type) ?? new Map<string, Set<AttributeKind>>()
held.set(type, kinds)
for (const [attribute, schemas] of attributes) {
const attributeKinds = kinds.get(attribute) ?? new Set<AttributeKind>()
kinds.set(attribute, attributeKinds)
for (const schema of schemas) for (const kind of propertyKinds(schema, `${where} ${type}.${attribute}`)) attributeKinds.add(kind)
}
}
}
}
}
return held
}
function alternatives(schema: SchemaObject, definitions: SchemaObject, where: string): Properties[] {
const own = Object.entries(schemaObject(readSchema(schema, where)['properties'] ?? {}, `${where} properties`))
let found: Properties[] = [new Map(own.map(([name, property]): [string, SchemaObject[]] => [name, [readSchema(schemaObject(property, `${where} ${name}`), `${where} ${name}`)]]))]
if (schema['$ref'] !== undefined) found = intersect(found, alternatives(referenced(schema['$ref'], definitions, where), definitions, `${where} ${String(schema['$ref'])}`))
for (const branch of branches(schema['allOf'], `${where} allOf`)) found = intersect(found, alternatives(branch, definitions, `${where} allOf`))
if (schema['anyOf'] !== undefined) found = intersect(found, branches(schema['anyOf'], `${where} anyOf`).flatMap((branch) => alternatives(branch, definitions, `${where} anyOf`)))
return found
}
function readSchema(schema: SchemaObject, where: string): SchemaObject {
const unread = Object.keys(schema).find((keyword) => !readKeywords.includes(keyword))
if (unread !== undefined) return assert.fail(`${where}: the schema holds the keyword ${unread}, which the gate does not read`)
const extra = schema['additionalProperties']
return extra === undefined || typeof extra === 'boolean' ? schema : assert.fail(`${where}: additionalProperties holds a schema, which the gate does not read`)
}
function intersect(left: readonly Properties[], right: readonly Properties[]): Properties[] {
return left.flatMap((own) =>
right.map((other) => {
const merged = new Map(own)
for (const [name, schemas] of other) merged.set(name, [...(merged.get(name) ?? []), ...schemas])
return merged
}),
)
}
function referenced(reference: unknown, definitions: SchemaObject, where: string): SchemaObject {
const name = typeof reference === 'string' && reference.startsWith(definitionReference) ? reference.slice(definitionReference.length) : undefined
return schemaObject(name === undefined ? undefined : definitions[name], `${where}: the reference ${String(reference)}`)
}
function branches(value: unknown, where: string): SchemaObject[] {
if (value === undefined) return []
return Array.isArray(value) ? value.map((branch, index) => schemaObject(branch, `${where} ${index}`)) : assert.fail(`${where} is no array of schemas`)
}
function enumStrings(schema: SchemaObject, where: string): string[] {
const values = schema['enum']
return Array.isArray(values) ? values.filter((value: unknown): value is string => typeof value === 'string') : assert.fail(`${where}: the type property holds no enum naming the type`)
}
function propertyKinds(property: SchemaObject, where: string): AttributeKind[] {
const type = property['type']
if (type === 'boolean') return ['boolean']
if (type === 'integer' || type === 'number') return ['number']
if (type === 'string') return ['string']
if (type === 'array' || type === 'object') return ['json']
if (type !== undefined) return assert.fail(`${where}: the schema types it ${JSON.stringify(type)}, which reads as no attribute kind`)
const combinator = ['$ref', 'allOf', 'anyOf'].find((keyword) => property[keyword] !== undefined)
if (combinator !== undefined) return assert.fail(`${where}: the schema holds the keyword ${combinator}, which the gate reads as no attribute kind`)
const values = property['enum']
return Array.isArray(values) ? values.map(valueKind) : ['json']
}
function valueKind(value: unknown): AttributeKind {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
if (typeof value === 'string') return 'string'
return 'json'
}
function schemaObject(value: unknown, where: string): SchemaObject {
return isSchemaObject(value) ? value : assert.fail(`${where} is no JSON Schema object`)
}
function isSchemaObject(value: unknown): value is SchemaObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+17 -1
View File
@@ -562,11 +562,27 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real `instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
Firefox majors behind with a publisher that may go quiet while Renovate stays silent. Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
- [ ] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).** - [x] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).** `@atlaskit/adf-schema`'s two
JSON Schemas vendored rather than the package installed (AGENTS.md §5), and the node tables
gated against them (§10). **Settled** (the maintainer, 2026-09-13): vendored at
`spec/adf-schema/` and re-pinned by hand when a need shows; the gate compares attribute names
and kinds, never value sets, over `full.json` and `stage-0.json` together.
- [x] **11a — The vendored schema.** `full.json` and `stage-0.json`, byte-exact from - [x] **11a — The vendored schema.** `full.json` and `stage-0.json`, byte-exact from
`@atlaskit/adf-schema@57.4.9`'s `dist/json-schema/v1/`, at `spec/adf-schema/`, each pinned `@atlaskit/adf-schema@57.4.9`'s `dist/json-schema/v1/`, at `spec/adf-schema/`, each pinned
by its SHA-256 in a test the way `spec.json` is. The version, the source and the Apache-2.0 by its SHA-256 in a test the way `spec.json` is. The version, the source and the Apache-2.0
attribution sit beside them with the licence text; no gate re-serializes either file. attribution sit beside them with the licence text; no gate re-serializes either file.
- [x] **11b — The gate.** For each node and mark type the tables spell, the attribute names and
kinds equal the union over every definition in both files whose `type` enum names it,
`anyOf`/`allOf` branches included, the argument slot (`panelType`, `state`) counting as
spelled. Kinds: `string`; `number`, `integer` included; `boolean`; `json` for an object, an
array or an untyped value; an `enum`-only attribute takes its values' kind. What the schema
holds past the tables is pinned in two exact lists — an entry the schema no longer needs is
red, like a difference neither list names: gaps, attributes of a spelled type (57.4.9:
`link` `collection` `id` `occurrenceKey`, `rule` `color` `style` `weight`, `layoutSection`
`columnRuleStyle`), emptied by 13; and carried, types the tables do not spell (`alignment`
`annotation` `backgroundColor` `blockCard` `bodiedRule` `breakout` `dataConsumer`
`embedCard` `fontSize` `fragment` `indentation` `inlineExtension` `placeholder`), `doc` and
`text` counting as the grammar's own.
## 5 — Ship `0.1.0` ## 5 — Ship `0.1.0`
+74 -61
View File
@@ -55,11 +55,15 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
collision already fails the round-trip on the same fixtures; the fixture-duplicate test collision already fails the round-trip on the same fixtures; the fixture-duplicate test
stays. stays.
- [ ] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in - [ ] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in
`src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks `src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks and
merged, an empty `attrs`, `marks` or `content` the absent key (§2), and the round-trip no attributes merged, an empty `attrs`, `marks` or `content` the absent key, `-0` read as `0`
tests compare through it. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 49 inline (§2); the round-trip tests compare the parser's output through it, and `serializeCanonicalJson`
`?? []`/`?? {}` reads in `src/` (27 `content`, 12 `marks`, 10 `attrs`), and the branch floor beneath it walks iteratively. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 46 inline
rises to what the suite then measures. `?? []`/`?? {}` reads in `src/` (23 `content`, 12 `marks`, 11 `attrs`) and the `attrs?.[key]`
reads, and the branch floor rises to the integer floor of what the suite then measures.
**Settled** (the maintainer, 2026-09-14): a text node carrying attributes never merges —
`0.1.0` merged a carried one into its neighbour on read-back — and the fix lands here, as does
the iterative serializer.
- [ ] **4.2 — The ADF property.** `fast-check` joins `devDependencies`, AGENTS.md §5 naming what - [ ] **4.2 — The ADF property.** `fast-check` joins `devDependencies`, AGENTS.md §5 naming what
it earns — shrinking a failing document to the nodes that break it — and §10 the properties it earns — shrinking a failing document to the nodes that break it — and §10 the properties
beside the corpus. A generated editor-normal document either refuses in `adfToMarkdown` beside the corpus. A generated editor-normal document either refuses in `adfToMarkdown`
@@ -163,66 +167,73 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
README's tagline and `package.json`'s `description` regain HTML (5g). README's tagline and `package.json`'s `description` regain HTML (5g).
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists. - [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build. - [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
- [ ] **10 — Lossy conversion (`0.2.0`).** A direction that only converts what Markdown actually - [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
supports, keeping the ADF's data while dropping what markdown cannot hold — format, design keeping the content while dropping what markdown cannot hold — format, design and the richer
and the richer nodes. nodes.
**Settled** (the maintainer, 2026-09-13): `adfToPlainMarkdown(doc)` is one export whose body **Settled** (the maintainer, 2026-09-14): two exports composed around the lossless pair, so §1's
reduces the document ADF→ADF in `src/adf/` and hands the result to `adfToMarkdown`, so §1's four conversions stay four. `adfToPlainMarkdown(doc)` reduces the document ADF→ADF and hands it
four conversions stay four. Its markdown is the flavour without directives — CommonMark, the to `adfToMarkdown`; `plainMarkdownToAdf(markdown)` hands the markdown to `markdownToAdf` and
pipe table and `~~` — and it refuses only what the document guard refuses lifts the result ADF→ADF. Both carry markdown conventions, so the reduction sits in
(`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`); every `src/markdown/emit/`, the lift in `src/markdown/parse/` and what both read in `src/markdown/`
other shape degrades. The reduction: (§11). The markdown is the flavour without directives — CommonMark, the pipe table and `~~` —
- `panel`, `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, plus the conventions below, chosen for readability from a survey of GitHub, GitLab, Gitea,
`multiBodiedExtension` and `extensionFrame` unwrap to their body blocks in order; `expand` Obsidian, Pandoc, MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and
and `nestedExpand` put their title first as a strong paragraph. Discord, GitHub's renderer confirming each shape. Writing refuses only what the document guard
- The CommonMark blocks keep their spelling, attributes dropped. refuses (`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`)
- `taskList` and `decisionList` become bullet lists, a task item's state leading its text as and degrades every other shape; reading refuses what `markdownToAdf` refuses. A lifted node
`[x]` or `[ ]`, the way Obsidian and GFM write a checkbox: `- [x] Write the spec`. carries no `localId`. The lift also reads other tools' spellings — type words in any case,
- `mention` and `status` become their text, `emoji` its text or else its `shortName`, and Obsidian's aliases, `[X]` — since it reads their output and never writes those spellings.
`date` its ISO date in UTC (`2026-09-13`). - A `panel` is an alert: the marker alone on the quote's first line, a blank `>`, then the body
- `inlineCard`, `blockCard` and `embedCard` become a link to their `url`, dropped when they (`> [!WARNING]`), in GitHub's five words by colour — info `NOTE`, note `IMPORTANT`, tip and
carry only `data`; a `mediaSingle` holding an external image stays `![alt](url)`; `media`, success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words
`mediaGroup` and `mediaInline` become their `alt` text or nothing; `caption` its text as a back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and
paragraph; `extension`, `inlineExtension` and `syncBlock` their `text` attribute or nothing; Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger,
`placeholder` nothing; a node no row names, or one standing where no spelling holds it, its failure, fail, missing and bug error; any other word info). Text after a marker in its
blocks or its text. paragraph is the panel's first body paragraph.
- A table stays a pipe table: the first row becomes the header, a cell's blocks join on one - An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`,
line with spaces, and spans and the cells they cover drop. then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text; rest of the marker's paragraph as its title, and an expand inside an expand as a
a link no CommonMark escape writes becomes its text, and a mark run CommonMark's flanking or `nestedExpand`.
matching cannot spell drops its mark. - A `taskList` is a bullet list whose items lead with `[x]` or `[ ]` (`- [x] Write the spec`).
The lift reads a list whose every item is so marked back as a `taskList` — a `blockTaskItem`
where an item holds more than one block, a nested task list moved beside its item — and
leaves mixed and ordered lists plain. A `decisionList` is a plain bullet list.
- `backgroundColor` is `==text==`, and the lift gives `==text==` the Atlassian editor's default
highlight colour.
- `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`
and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their
spelling, attributes dropped.
- `mention` and `status` become their text, the mention's `@` kept; `emoji` its text or else its
`shortName`; `date` its ISO date in UTC (`2026-09-13`); `inlineCard`, `blockCard` and
`embedCard` a link to their `url`, dropped when they carry only `data`; a `mediaSingle`
holding an external image stays `![alt](url)`; `media`, `mediaGroup` and `mediaInline` their
`alt` text or nothing; `caption` its text as a paragraph; `extension`, `inlineExtension` and
`syncBlock` their `text` attribute or nothing; `placeholder` nothing; a node no row names, or
one standing where no spelling holds it, its blocks or its text.
- A table stays a pipe table: the first row becomes the header, a cell's blocks join on one line
with spaces, and spans and the cells they cover drop.
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text —
`subsup` too, since `~2~` is a strike on GitHub; a link no CommonMark escape writes becomes its
text, and a mark run CommonMark's flanking or matching cannot spell drops its mark.
- A newline in text becomes a hard break and edge whitespace is trimmed; carriage returns and - A newline in text becomes a hard break and edge whitespace is trimmed; carriage returns and
null characters are removed; a paragraph line opening with a code span whose backticks would null characters are removed; a paragraph line opening with a code span whose backticks would
read as a fence loses the code mark; an empty paragraph drops, and adjacent lists of one type read as a fence loses the code mark; an empty paragraph drops, and adjacent lists of one type
merge. merge.
- [ ] **10a — Obsidian's formats.** Look up the formats Obsidian-flavoured markdown adds — - Rejected in the survey: `~sub~` and `^sup^`, underline and colour spellings, raw HTML
callouts, highlights, embeds, task states and whatever else it writes — and propose which (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, footnotes, definition
of the reduction's rows should adopt one; the maintainer settles the proposal, revising the lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
rows above, before 10b starts (the maintainer's request, 2026-09-13). and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
- [ ] **10b — The reduction.** The reduction in `src/adf/`, tests first, a test per row as 10a - [ ] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
leaves them. row above.
- [ ] **10c — `adfToPlainMarkdown`.** The export and its README section, and a property over - [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
4.2's generators: it refuses only the guard's codes, and its output reads back through other tools' spellings included; the editor's default highlight colour looked up and cited.
`markdownToAdf` holding no node or mark the flavour spells as a directive. AGENTS.md §1 - [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
records the reduction as what keeps the conversions at four. sections, and two properties over 4.2's generators: writing refuses only the guard's codes,
- [ ] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).** `@atlaskit/adf-schema`'s two and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
JSON Schemas vendored rather than the package installed (AGENTS.md §5), and the node tables again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
gated against them (§10). **Settled** (the maintainer, 2026-09-13): vendored at - [x] **11 — Atlassian's ADF schema as the tables' truth.**
`spec/adf-schema/` and re-pinned by hand when a need shows; the gate compares attribute names
and kinds, never value sets, over `full.json` and `stage-0.json` together.
- [x] **11a — The vendored schema.** - [x] **11a — The vendored schema.**
- [ ] **11b — The gate.** For each node and mark type the tables spell, the attribute names and - [x] **11b — The gate.**
kinds equal the union over every definition in both files whose `type` enum names it,
`anyOf`/`allOf` branches included, the argument slot (`panelType`, `state`) counting as
spelled. Kinds: `string`; `number`, `integer` included; `boolean`; `json` for an object, an
array or an untyped value; an `enum`-only attribute takes its values' kind. What the schema
holds past the tables is pinned in two exact lists — an entry the schema no longer needs is
red, like a difference neither list names: gaps, attributes of a spelled type (57.4.9:
`link` `collection` `id` `occurrenceKey`, `rule` `color` `style` `weight`, `layoutSection`
`columnRuleStyle`), emptied by 13; and carried, types the tables do not spell (`alignment`
`annotation` `backgroundColor` `blockCard` `bodiedRule` `breakout` `dataConsumer`
`embedCard` `fontSize` `fragment` `indentation` `inlineExtension` `placeholder`), `doc` and
`text` counting as the grammar's own.
- [ ] **12 — The `!adf:` re-spelling (`0.2.0`).** Replace the colon directive grammar with the - [ ] **12 — The `!adf:` re-spelling (`0.2.0`).** Replace the colon directive grammar with the
namespaced prefix, a breaking change to the emitted contract (shipped `0.1.0`, so §8 makes it namespaced prefix, a breaking change to the emitted contract (shipped `0.1.0`, so §8 makes it
`0.2.0`). Forms: block container `!adf:name arg {attrs}` … `!adf:/name` — the `/` parts open `0.2.0`). Forms: block container `!adf:name arg {attrs}` … `!adf:/name` — the `/` parts open
@@ -270,7 +281,9 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
- [ ] **12b — The inline form.** Inline nodes, directive marks, `text` and the inline carry - [ ] **12b — The inline form.** Inline nodes, directive marks, `text` and the inline carry
`!adf:carry{json=…}` spelled and read as `!adf:name[content]{attrs}`, with the prefix claim `!adf:carry{json=…}` spelled and read as `!adf:name[content]{attrs}`, with the prefix claim
and its escape; the round-trip, normalization and `errors/` fixtures holding inline forms and its escape; the round-trip, normalization and `errors/` fixtures holding inline forms
re-spelled, and the gate green. re-spelled, and the gate green. The content slot of `emoji`, `mention` and `status` refuses a
text node carrying attributes as `unsupported-node-shape`, which it drops silently today (the
maintainer, 2026-09-14).
- [ ] **12c — The block form.** Openers and `!adf:/name` closers, leaf vs container by content - [ ] **12c — The block form.** Openers and `!adf:/name` closers, leaf vs container by content
model, empty pairs, `listBreak` and the `carry` fence, spelled and read; the fence-length model, empty pairs, `listBreak` and the `carry` fence, spelled and read; the fence-length
rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled