Compare commits

13 Commits

Author SHA1 Message Date
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
lilleman 37f59cec40 Design 5g: the README's order, badges and tagline
CI / gate (push) Successful in 17s
CI / publish (push) Successful in 3s
2026-09-14 16:11:14 +02:00
lilleman aa34d6e7fd Design 10: adfToPlainMarkdown as a reduction, its mapping and Obsidian's formats
CI / gate (push) Successful in 17s
CI / publish (push) Waiting to run
2026-09-14 15:09:48 +02:00
lilleman 3c1bacace6 Design 12 and 13: settle the !adf: grammar and split both by construct
CI / gate (push) Successful in 17s
CI / publish (push) Has been cancelled
2026-09-14 15:02:16 +02:00
lilleman f8a5d5e9b3 Design 4: fast-check generators on a fixed seed, in four sub-items
CI / gate (push) Successful in 17s
CI / publish (push) Has been cancelled
2026-09-14 15:02:15 +02:00
lilleman c9fb85f9db Tick 11a, moving its text to todo-history.md
CI / gate (push) Successful in 18s
CI / publish (push) Successful in 3s
2026-09-14 15:02:15 +02:00
lilleman 24b2e8cbbe 11a: the vendored schema
CI / gate (push) Successful in 25s
CI / publish (push) Successful in 4s
2026-09-13 15:40:34 +02:00
lilleman f66efabcbb Design 11: vendor Atlassian's ADF schema and gate the node tables against it
CI / publish (push) Successful in 3s
CI / gate (push) Successful in 17s
2026-09-13 15:34:32 +02:00
lilleman e424b35fd6 Settle 0.2.0's order and move 5e to last
CI / gate (push) Successful in 17s
CI / publish (push) Successful in 4s
2026-09-13 15:08:45 +02:00
10 changed files with 7483 additions and 57 deletions
+12 -4
View File
@@ -63,8 +63,11 @@ module, so entity decoding is complete without one. The CommonMark spec suite is
data and ships vendored at `corpus/commonmark-spec/` rather than as the `commonmark-spec` dev data and ships vendored at `corpus/commonmark-spec/` rather than as the `commonmark-spec` dev
dependency — that package is CommonJS-only, and Renovate auto-bumping a spec version would silently dependency — that package is CommonJS-only, and Renovate auto-bumping a spec version would silently
point the vendored exception list's example numbers at a renumbered suite. A spec bump is a point the vendored exception list's example numbers at a renumbered suite. A spec bump is a
deliberate re-pin, exceptions re-derived by hand beside it. `devDependencies`: few, each earning its deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON Schemas ship
keep; they never reach a consumer. vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev
dependency — CommonJS-only, some fifty packages with React among them, and a release most days for
Renovate to automerge — re-pinned by hand when a payload or a report shows the need.
`devDependencies`: few, each earning its keep; they never reach a consumer.
## 6. The package contract ## 6. The package contract
@@ -220,8 +223,7 @@ functions, and a branch floor that only ever moves upward. It sits below 100 bec
compared against `undefined` — have a half no valid document reaches. compared against `undefined` — have a half no valid document reaches.
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against live Atlassian APIs; 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 `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 copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
@@ -231,6 +233,12 @@ of a bullet; fenced examples are skipped. It guards the attributes alone: nodes
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so 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. both answer to the round-trip corpus and to nothing else where a node has no fixture.
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
documentation, since any value round-trips. What the schema holds and the tables do not spell is
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
someone spells it or pins it.
## 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
+13
View File
@@ -0,0 +1,13 @@
Copyright 2019 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+6
View File
@@ -0,0 +1,6 @@
# Atlassian's ADF JSON Schemas
`full.json` and `stage-0.json` are byte-exact from `dist/json-schema/v1/` in
[`@atlaskit/adf-schema` 57.4.9](https://registry.npmjs.org/@atlaskit/adf-schema/-/adf-schema-57.4.9.tgz)
(© Atlassian Pty Ltd, Apache-2.0: `LICENSE` is the package's own notice, `LICENSE-2.0.txt` the
licence it names).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
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 schemaFiles = ['full.json', 'stage-0.json']
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')
assert.equal(digest('full.json'), '75f080928a970250eb8289e9cae5374e3c2a6c0ac3ca22478acaa9d3f39484a3')
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)
}
+21
View File
@@ -562,6 +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.
- [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
`@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
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`
+187 -53
View File
@@ -5,9 +5,11 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
## Milestones ## Milestones
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 5e before 2027-01; 3k, 4, 4b, 4c, 5g, 10, 11, 12 → `0.2.0`; 4d, 5f → `0.2.1`; Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, 4c, 10, 5g → `0.2.0`; 4d, 5f → `0.2.1`;
6, 7 → `0.3.0`; 9 → TBD. 6, 7 → `0.3.0`; 9 → TBD; 5e last.
The numbering is the order the work was planned in, not the order it ships. The numbering is the order the work was planned in, not the order it ships. `0.2.0`'s order is settled
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change.
- [x] **0 — Scaffold.** - [x] **0 — Scaffold.**
- [x] **1a — The directive grammar.** - [x] **1a — The directive grammar.**
@@ -42,19 +44,37 @@ The numbering is the order the work was planned in, not the order it ships.
- [x] **3j — The carry and the combinations.** - [x] **3j — The carry and the combinations.**
- [x] **3k — The CommonMark spec suite.** - [x] **3k — The CommonMark spec suite.**
- [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the - [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
documents a human wrote — the thing that proves 2 and 3 beyond them. Editor-normal (§2) is documents a human wrote — the thing that proves 2 and 3 beyond them.
finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip **Settled** (the maintainer, 2026-09-13): `fast-check` generates and shrinks. The gate runs a
asserts, which over normalized input is the canonical serializer's compact spelling — fixed seed, the properties together adding about five seconds per engine; an environment
rather than staying spelled inline as `?? []` at every reader. The variable raises the runs and randomizes the seed for local digging, and a counterexample
reading half is `nodeContent`/`nodeAttrs`/`nodeMarks` over the ~28 sites spelling it found becomes a round-trip fixture. The generators draw from the node tables — each node's
inline today, which also lifts the branch floor §10 keeps below 100 for exactly those content model and attribute vocabulary as `adf/` records them, which 11 holds to Atlassian's
halves. schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker
Generators emit editor-normal ADF (§2). Real sanitized ADF from live Atlassian APIs lands enters the tests. `toEditorNormal` stays internal. 2e5's collision test goes, since a
here too (§10), in `corpus/real-payloads/`: an ADF→markdown→ADF check with no expected collision already fails the round-trip on the same fixtures; the fixture-duplicate test
markdown, the payloads supplied by the maintainer. This subsumes 2e5's collision property — stays.
a document that round-trips proves no other document shares its spelling — so decide here - [ ] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in
whether that gate stays as the parser-free, faster-failing signal or goes; the half holding `src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks and
no fixture duplicates is hygiene rather than a round-trip claim, and stays either way. no attributes merged, an empty `attrs`, `marks` or `content` the absent key, `-0` read as `0`
(§2); the round-trip tests compare the parser's output through it, and `serializeCanonicalJson`
beneath it walks iteratively. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 46 inline
`?? []`/`?? {}` 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
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`
with a `ConvertError` or reads back through `markdownToAdf` to an equal document, and
nothing throws, under Node, Deno and Bun alike. 2e5's collision test is deleted.
- [ ] **4.3 — The markdown property.** Generated markdown through `markdownToAdf` never throws,
and the runs fit the budget; where it parses and `adfToMarkdown` spells the result, that
spelling parses and emits to itself byte for byte (§2).
- [ ] **4.4 — The real payloads.** `corpus/real-payloads/` holds the maintainer's sanitized
payloads, each round-tripped ADF→markdown→ADF with no expected markdown. It waits on the
maintainer placing the files.
- [ ] **4b — The block walk's retry (`0.2.0`).** `emitBlock` walks a subtree twice wherever - [ ] **4b — The block walk's retry (`0.2.0`).** `emitBlock` walks a subtree twice wherever
`readableBlock` reads it whole and then gives up — a list item whose first line reads back `readableBlock` reads it whole and then gives up — a list item whose first line reads back
as a thematic break — and the walk below does the same, so the cost doubles per level: as a thematic break — and the walk below does the same, so the cost doubles per level:
@@ -98,15 +118,18 @@ The numbering is the order the work was planned in, not the order it ships.
timing is what turns "slow or hung" from a guess into a reading; the browser leg's own timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap. 5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
- [x] **5 — Ship `0.1.0`.** - [x] **5 — Ship `0.1.0`.**
- [ ] **5e — The publish token's deadline (before 2027-01).** `0.1.0` published only once the npm - [ ] **5e — The publish token's deadline.** `0.1.0` published only once the npm
token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct
publishing around January 2027, and its replacement — trusted publishing over OIDC — publishing around January 2027, leaving them `npm stage publish`, which a maintainer
supports GitHub Actions, GitLab CI, CircleCI and Buildkite, not Gitea or self-hosted approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted
runners. So the release path has an expiry date and no drop-in successor yet. Revisit before Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned
the deadline: whether npm has added Gitea or self-hosted OIDC, and otherwise whether the without a date. So the release path has an expiry date and no drop-in successor yet. Revisit:
release moves to a human-approved staged publish — which fits badly with publish-on-merge, whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
release moves to the staged publish — which fits badly with publish-on-merge,
and is the trade to weigh rather than discover on a red release run. and is the trade to weigh rather than discover on a red release run.
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
there knowing the cutoff may land before `0.2.0` ships.
- [ ] **5f — Publish the bundle size (`0.2.1`).** Measure the shipped artifact and put the number in the - [ ] **5f — Publish the bundle size (`0.2.1`).** Measure the shipped artifact and put the number in the
README, kept honest by the release pipeline rather than by a human re-reading it. The README, kept honest by the release pipeline rather than by a human re-reading it. The
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
@@ -120,12 +143,15 @@ The numbering is the order the work was planned in, not the order it ships.
- [ ] **5g — Reweight the README for the reader (`0.2.0`).** It opens with the pre-launch rationale — - [ ] **5g — Reweight the README for the reader (`0.2.0`).** It opens with the pre-launch rationale —
Atlassian's REST APIs, `pf-editor-service/convert` being decommissioned, a link to Atlassian's REST APIs, `pf-editor-service/convert` being decommissioned, a link to
JRACLOUD-77436 — where a shipped package should answer what it is, what it does and for whom JRACLOUD-77436 — where a shipped package should answer what it is, what it does and for whom
first, then the shortest runnable example; the reader's top seconds go to "why this exists" first, then the shortest runnable example.
instead of "what I can do with it". Demote the Jira/endpoint background to a later "why **Settled** (the maintainer, 2026-09-13): the background goes entirely, no endpoint, ticket or
losslessness" note or drop it — the internal references (the `jira.atlassian.com` URL, "why" note left. The top follows the package-README order: an npm version badge and the Gitea
`pf-editor-service/convert`) don't belong in published text at all, no ticket IDs or internal Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
URLs. The `0.3.0` HTML future should read as an aside, not the lede: the package reads as a one-line table of contents, then install and the shortest runnable example; a table of
shipped `0.1.0`, not a work-in-progress. everything exported sits near the bottom. The HTML directions are one aside line under the API
until `0.3.0` ships them, the `// 0.3.0` signatures and the `0.3.0` guarantee going until then.
The tagline and `description` read "Lossless conversion between Atlassian Document Format and
extended markdown" until 7 restores HTML.
- [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5a — Rename to `@larvit/adf-codec`.**
- [x] **5b — The consumer's error surface.** - [x] **5b — The consumer's error surface.**
- [x] **5b1 — The error's source position.** - [x] **5b1 — The error's source position.**
@@ -137,11 +163,77 @@ The numbering is the order the work was planned in, not the order it ships.
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10). `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10). The
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 supports, keeping the ADF's data while dropping what markdown cannot hold — format, design and the richer nodes. - [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
- [ ] **11 — Evaluate `@atlaskit/adf-schema` (`0.2.0`).** Whether to add `@atlaskit/adf-schema` as a dev dependency to use as truth for the ADF schema. keeping the content while dropping what markdown cannot hold — format, design and the richer
nodes.
**Settled** (the maintainer, 2026-09-14): two exports composed around the lossless pair, so §1's
four conversions stay four. `adfToPlainMarkdown(doc)` reduces the document ADF→ADF and hands it
to `adfToMarkdown`; `plainMarkdownToAdf(markdown)` hands the markdown to `markdownToAdf` and
lifts the result ADF→ADF. Both carry markdown conventions, so the reduction sits in
`src/markdown/emit/`, the lift in `src/markdown/parse/` and what both read in `src/markdown/`
(§11). The markdown is the flavour without directives — CommonMark, the pipe table and `~~` —
plus the conventions below, chosen for readability from a survey of GitHub, GitLab, Gitea,
Obsidian, Pandoc, MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and
Discord, GitHub's renderer confirming each shape. Writing refuses only what the document guard
refuses (`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`)
and degrades every other shape; reading refuses what `markdownToAdf` refuses. A lifted node
carries no `localId`. The lift also reads other tools' spellings — type words in any case,
Obsidian's aliases, `[X]` — since it reads their output and never writes those spellings.
- A `panel` is an alert: the marker alone on the quote's first line, a blank `>`, then the body
(`> [!WARNING]`), in GitHub's five words by colour — info `NOTE`, note `IMPORTANT`, tip and
success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words
back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and
Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger,
failure, fail, missing and bug error; any other word info). Text after a marker in its
paragraph is the panel's first body paragraph.
- An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`,
then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the
rest of the marker's paragraph as its title, and an expand inside an expand as a
`nestedExpand`.
- 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
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
merge.
- Rejected in the survey: `~sub~` and `^sup^`, underline and colour spellings, raw HTML
(`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, footnotes, definition
lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
- [ ] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
row above.
- [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
other tools' spellings included; the editor's default highlight colour looked up and cited.
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
sections, and two properties over 4.2's generators: writing refuses only the guard's codes,
and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
- [x] **11 — Atlassian's ADF schema as the tables' truth.**
- [x] **11a — The vendored schema.**
- [x] **11b — The gate.**
- [ ] **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
@@ -149,29 +241,71 @@ The numbering is the order the work was planned in, not the order it ships.
length rule go and every container opens the constant `!adf:`; block leaf `!adf:name arg length rule go and every container opens the constant `!adf:`; block leaf `!adf:name arg
{attrs}` with no closer; inline node `!adf:name[content]{attrs}`; directive marks {attrs}` with no closer; inline node `!adf:name[content]{attrs}`; directive marks
`!adf:border`/`subsup`/`textColor`/`underline` `[content]{attrs}`. Attributes and their `!adf:border`/`subsup`/`textColor`/`underline` `[content]{attrs}`. Attributes and their
escaping stay `{key=value}`; the literal escape is `\!adf:`; a line opening `!adf:` claims as escaping stay `{key=value}`; the literal escape is `\!adf:`. Leaf vs container is decided by
today's colon-run does. Leaf vs container is decided by the node's content model rather than the node's content model rather than syntax — the `::`/`:::` split goes, a simplification the
syntax — the `::`/`:::` split and §4's name-set-independent recognition go, a simplification carry makes safe (an unknown *block* node already rides the fence, not the directive). The
the carry makes safe (an unknown *block* node already rides the fence, not the directive). carry's reserved name becomes `carry`, both spellings — the block fence info string `carry`
The carry's reserved name becomes `carry`, both spellings — the block fence info string and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node verbatim,
`` `carry` `` and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node never "unknown-node", since a known node no section spells where it stands rides it too. No
verbatim, never "unknown-node", since a known node no section spells where it stands rides it `ConvertErrorCode` is added, removed or renamed, and the round-trip guarantee and the carry
too. A spelling change, not a semantic one: no `ConvertErrorCode` is added, removed or renamed, both hold through it. Mechanical surface: the grammar in `spec/flavour.md`,
the round-trip guarantee and the carry both hold through it. Mechanical surface: the grammar in `src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s
`spec/flavour.md`, `src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s
`directive-syntax.ts`, `opaque-carry.ts` and the `emit/` + `parse/` readers, every corpus `directive-syntax.ts`, `opaque-carry.ts` and the `emit/` + `parse/` readers, every corpus
fixture (round-trip, normalization and `errors/`), `spec.test.ts`'s prose reader, and the fixture (round-trip, normalization and `errors/`), the prose reader over `spec/flavour.md`,
README's examples. and the README's examples.
- [ ] **12a — The spec and the decision.** Rewrite `spec/flavour.md` to the `!adf:` grammar, and **Settled** (the maintainer, 2026-09-13):
record the departures in `AGENTS.md` §4 (leaf/container by content model, carry renamed - A line opening `!adf:name` is a block line when a space or the line's end follows the name,
`carry`). and a paragraph when `[` or `{` does. Claiming stays syntactic and structure comes from the
- [ ] **12b — The emit side.** `adfToMarkdown` spells `!adf:` / `!adf:/name` / `!adf:carry`; its tables: an unknown name is `unknown-directive-name` at the opener, whatever follows it.
fixtures re-spelled, green. - An unescaped `!adf:` claims on its own anywhere inline: one completing no directive is
- [ ] **12c — The parse side and the round-trip.** `markdownToAdf` reads it back; the round-trip `malformed-directive`, the emitter escapes every literal `!adf:`, and `!adf:hardBreak{}`
corpus, the `errors/` fixtures and the CommonMark spec suite re-spelled, keeps its braces. Block and inline share the one `\!adf:` escape hint.
`markdownToAdf(adfToMarkdown(doc))` still equals `doc`. - A closer names the innermost open container, crosses no list-item or blockquote edge,
- [ ] **12d — The README and the sweep.** The README's examples follow; sweep docs and fixtures indents as a fence does and carries nothing after the name; anything else is
for any stale `::`/`:name` spelling. `malformed-directive`.
- A node holding no content whose content model takes some is an empty opener–closer pair,
never a leaf.
- A spelled node's content model is frozen with its spelling: changing it is MAJOR (§8).
- The colon spellings are dropped, not refused: `0.1.0` markdown reads back as prose, `adf`
is no longer a reserved language, and `MIGRATION.md` tells a consumer to convert stored
markdown through `0.1.0`'s parser and `0.2.0`'s emitter.
- Inputs moving between codes ride the break: a leaf given a body, a container missing its
closer and `listBreak` with a body are `malformed-directive`, and an empty inline-body
container parses.
- Split by construct, each sub-item both directions: 55 of 78 round-trip fixtures feed both
the emit and the read-back test, so an emit-only chunk cannot land green.
- [ ] **12a — The spec and the decision.** `spec/flavour.md` rewritten to the `!adf:` grammar and
the settled answers above, no colon directive form left in it; AGENTS.md §4's directive
bullet and prior-art line, and §8's escape hints and `::adf`/`::listBreak` examples, name
the new forms, §8 gaining the frozen content model.
- [ ] **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
and its escape; the round-trip, normalization and `errors/` fixtures holding inline forms
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
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
and `errors/` re-derived under the shifted codes, and the gate green.
- [ ] **12d — The README, `MIGRATION.md` and the sweep.** The README's examples and error tables
follow, `MIGRATION.md` linked from one README line; docs and fixtures swept for any stale
`::`/`:name` spelling.
- [ ] **13 — The schema's gap attributes (`0.2.0`).** Spell the attributes 11b pins as gaps, in
12's grammar, and empty the list.
**Settled** (the maintainer, 2026-09-13): a link `[text](url "title")` cannot hold takes the
directive mark `!adf:link[text]{attrs}` — one carrying `collection`, `id` or `occurrenceKey`,
or an `href` or `title` no CommonMark escape writes — and a directive link CommonMark could
spell is `unsupported-node-shape`. That leaves `unspellable-link` no cause, so it leaves
`ConvertErrorCode` in `0.2.0`, §8 recording the removal.
- [ ] **13a — `rule` and `layoutSection`.** `rule`'s `color`, `style` and `weight` and
`layoutSection`'s `columnRuleStyle` join their tables and `spec/flavour.md` bullets, with
round-trip fixtures; their gap entries go.
- [ ] **13b — The directive link.** `link` spelled as above in both directions, with a round-trip
fixture per trigger, `spec/flavour.md`'s Marks section following; `unspellable-link` removed
from the code list, its `errors/` fixtures and the CommonMark suite's `unspellable`
exceptions it cures re-derived, and the README's code table and its "not every document
converts back" guarantee following; the gap list is empty.
## The ADF inventory to cover ## The ADF inventory to cover