17 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
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
31 changed files with 7829 additions and 157 deletions
+14 -6
View File
@@ -23,8 +23,8 @@ backticks read back as a fence — so a parse succeeding does not imply a spella
`corpus/commonmark-spec/exceptions.json` names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
merged, JSON number semantics, an empty attrs object, marks array or content array the absent
key — the only domain markdown can restore.
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
Round-trip equality is a property tested over a corpus, not a claim made in prose.
@@ -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
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
deliberate re-pin, exceptions re-derived by hand beside it. `devDependencies`: few, each earning its
keep; they never reach a consumer.
deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON Schemas ship
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
@@ -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.
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
`markdownToAdf` and `markdownToHtml`.
live Atlassian APIs; 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
@@ -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
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
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
@@ -0,0 +1,57 @@
{
"content": [
{
"content": [
{
"attrs": {
"localId": "01a0a067-68cf-78af-abd6-c660ec0d189b"
},
"text": "Owner",
"type": "text"
},
{
"text": " signs off.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Signed by ",
"type": "text"
},
{
"attrs": {
"localId": "01a0a067-68d2-787e-afc5-2459776aa029"
},
"text": "the owner",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"localId": "01a0a067-68d5-7372-9962-29a039654056"
},
"text": "One ",
"type": "text"
},
{
"attrs": {
"localId": "01a0a067-68d5-7372-9962-29a039654056"
},
"text": "anchor",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,5 @@
:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68cf-78af-abd6-c660ec0d189b\"},\"text\":\"Owner\",\"type\":\"text\"}"} signs off.
Signed by :adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d2-787e-afc5-2459776aa029\"},\"text\":\"the owner\",\"type\":\"text\"}"}
:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"One \",\"type\":\"text\"}"}:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"anchor\",\"type\":\"text\"}"}
+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
+8 -8
View File
@@ -404,10 +404,10 @@ Right.
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot
as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first —
is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach
into the slot. The rest take no content, `:text` included; content on a node that takes none is a
named error.
parsing to anything but one unmarked text node — adjacent text nodes with identical marks and no
attributes merged first — is a named error, and so is a `text` key in `{attrs}`. An enclosing mark
spelling does not reach into the slot. The rest take no content, `:text` included; content on a
node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
@@ -434,10 +434,10 @@ CommonMark strips or refuses one — a block's inline content edges, either side
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled
`:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks
(AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and tabs, or one
run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — is a named
error.
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
is a named error.
```
:text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines.
+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 -9
View File
@@ -52,8 +52,8 @@ export function attributeNestingMessage(key: string, type: string, levels: numbe
}
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
return holdsOnly(node.attrs ?? {}, attributes)
if (nodeMarks(node).length > 0 || node.text !== undefined) return false
return holdsOnly(nodeAttrs(node), attributes)
}
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
@@ -72,6 +72,18 @@ export function isAdfMark(value: unknown): value is AdfMark {
return !('attrs' in value) || isAttributes(value['attrs'])
}
export function nodeAttrs(node: { attrs?: AdfAttributes }): Readonly<AdfAttributes> {
return node.attrs ?? {}
}
export function nodeContent(node: { content?: AdfNode[] }): readonly AdfNode[] {
return node.content ?? []
}
export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? []
}
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
const pending: unknown[] = [...value]
while (pending.length > 0) {
@@ -95,23 +107,23 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
while (pending.length > 0) {
const node = pending.pop()
if (node === undefined) continue
const fault = attributesFault(node.attrs, node.type) ?? marksFault(node.marks)
const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
if (fault !== undefined) return fault
pending.push(...(node.content ?? []))
pending.push(...nodeContent(node))
}
return undefined
}
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined {
for (const mark of marks ?? []) {
const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting)
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
for (const mark of marks) {
const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting)
if (fault !== undefined) return fault
}
return undefined
}
function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs ?? {})) {
function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs)) {
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
}
return undefined
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { toEditorNormal } from './editor-normal.ts'
test('merges adjacent text nodes carrying identical marks, at every level', () => {
const content: AdfNode[] = [
{ text: 'a', type: 'text' },
{ text: 'b', type: 'text' },
{ marks: [{ type: 'strong' }], text: 'c', type: 'text' },
{ marks: [{ attrs: {}, type: 'strong' }], text: 'd', type: 'text' },
{ type: 'hardBreak' },
{ text: 'e', type: 'text' },
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
{ text: 'g', type: 'text' },
{ attrs: {}, text: 'h', type: 'text' },
]
assert.deepEqual(toEditorNormal({ content: [{ attrs: { panelType: 'info' }, content: [{ content, type: 'paragraph' }], type: 'panel' }], type: 'doc', version: 1 }), {
content: [
{
attrs: { panelType: 'info' },
content: [
{
content: [
{ text: 'ab', type: 'text' },
{ marks: [{ type: 'strong' }], text: 'cd', type: 'text' },
{ type: 'hardBreak' },
{ text: 'e', type: 'text' },
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
{ text: 'gh', type: 'text' },
],
type: 'paragraph',
},
],
type: 'panel',
},
],
type: 'doc',
version: 1,
})
})
test('reads negative zero as zero, as JSON does', () => {
const marks = [{ attrs: { size: -0 }, type: 'border' }]
assert.deepEqual(
toEditorNormal({ content: [{ attrs: { a: -0, b: [{ c: -0 }, null, 'd'] }, content: [{ marks, text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: -0 }),
{ content: [{ attrs: { a: 0, b: [{ c: 0 }, null, 'd'] }, content: [{ marks: [{ attrs: { size: 0 }, type: 'border' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 0 },
)
})
test('reads an empty attrs object, marks array or content array as the absent key', () => {
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
type: 'doc',
version: 1,
})
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 })
})
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
const levels = 100000
let node: AdfNode = { content: [], type: 'paragraph' }
for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' }
let normal = toEditorNormal({ content: [node], type: 'doc', version: 1 }).content?.[0]
let depth = 0
for (; normal?.content !== undefined; depth += 1) normal = normal.content[0]
assert.equal(depth, levels)
assert.deepEqual(normal, { type: 'paragraph' })
let deep: JsonValue = 1
for (let level = 0; level < 2 * levels; level += 1) deep = [deep]
const marks = [{ attrs: { deep }, type: 'textColor' }]
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
})
+63 -5
View File
@@ -1,16 +1,21 @@
import type { AdfMark, AdfNode } from './document.ts'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
return markKey(candidate) === markKey(mark)
}
// AGENTS.md §2: adjacent text nodes carrying identical marks are one node.
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && previous.type === 'text' && node.type === 'text' && sameMarks(previous, node)) {
if (previous !== undefined && mergesText(previous) && mergesText(node) && sameMarks(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
@@ -19,8 +24,61 @@ export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
return merged
}
export function toEditorNormal(document: AdfDocument): AdfDocument {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source))
if (content.length === 0) continue
entry.holder.content = content.map((source) => {
const holder = normalNode(source)
pending.push({ holder, source })
return holder
})
}
return normal
}
function normalNode(node: AdfNode): AdfNode {
const normal: AdfNode = { type: node.type }
const attrs = normalAttributes(nodeAttrs(node))
if (attrs !== undefined) normal.attrs = attrs
const marks = nodeMarks(node).map(normalMark)
if (marks.length > 0) normal.marks = marks
if (node.text !== undefined) normal.text = node.text
return normal
}
function normalMark(mark: AdfMark): AdfMark {
const attrs = normalAttributes(nodeAttrs(mark))
return attrs === undefined ? { type: mark.type } : { attrs, type: mark.type }
}
function normalAttributes(attrs: AdfAttributes): AdfAttributes | undefined {
if (Object.keys(attrs).length === 0) return undefined
const normal = { ...attrs }
const pending: JsonContainer[] = [normal]
for (let held = pending.pop(); held !== undefined; held = pending.pop()) {
if (Array.isArray(held)) for (const [index, value] of held.entries()) held[index] = normalValue(value, pending)
else for (const [key, value] of Object.entries(held)) held[key] = normalValue(value, pending)
}
return normal
}
function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
if (Object.is(value, -0)) return 0
if (value === null || typeof value !== 'object') return value
const copy = Array.isArray(value) ? [...value] : { ...value }
pending.push(copy)
return copy
}
function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(previous.marks ?? []) === marksKey(node.marks ?? [])
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
}
function marksKey(marks: readonly AdfMark[]): string {
@@ -28,5 +86,5 @@ function marksKey(marks: readonly AdfMark[]): string {
}
function markKey(mark: AdfMark): string {
return `${mark.type} ${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}`
return `${mark.type} ${serializeCanonicalJson(nodeAttrs(mark), 'compact')}`
}
+13
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { JsonValue } from './json-value.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
test('sorts object keys recursively', () => {
@@ -39,3 +40,15 @@ test('leaves non-ASCII raw', () => {
test('spells scalars in canonical JSON', () => {
assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]')
})
test('spells a value nesting far past the levels a recursive walk survives', () => {
const levels = 200000
let array: JsonValue = 1
let object: JsonValue = 1
for (let level = 0; level < levels; level += 1) {
array = [array]
object = { a: object }
}
assert.equal(serializeCanonicalJson(array, 'compact'), `${'['.repeat(levels)}1${']'.repeat(levels)}`)
assert.equal(serializeCanonicalJson(object, 'compact'), `${'{"a":'.repeat(levels)}1${'}'.repeat(levels)}`)
})
+33 -19
View File
@@ -2,28 +2,42 @@ import type { JsonValue } from './json-value.ts'
export type JsonSpelling = 'compact' | 'two-space'
type Member = { label: string; value: JsonValue }
type Pending = string | { depth: number; value: JsonValue }
export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string {
return serialize(value, spelling === 'compact' ? '' : ' ', 0)
const indent = spelling === 'compact' ? '' : ' '
const text: string[] = []
const pending: Pending[] = [{ depth: 0, value }]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === 'string') {
text.push(next)
continue
}
const { depth, value: held } = next
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
else text.push(JSON.stringify(held))
}
return text.join('')
}
function serialize(value: JsonValue, indent: string, depth: number): string {
if (Array.isArray(value)) {
if (value.length === 0) return '[]'
const items = value.map((item) => serialize(item, indent, depth + 1))
return `[${join(items, indent, depth)}]`
}
if (value !== null && typeof value === 'object') {
const keys = Object.keys(value).sort()
if (keys.length === 0) return '{}'
const separator = indent === '' ? ':' : ': '
const entries = keys.map((key) => `${JSON.stringify(key)}${separator}${serialize(value[key] ?? null, indent, depth + 1)}`)
return `{${join(entries, indent, depth)}}`
}
return JSON.stringify(value)
function objectMembers(value: { [key: string]: JsonValue }, indent: string): Member[] {
const separator = indent === '' ? ':' : ': '
return Object.keys(value)
.sort()
.map((key) => ({ label: `${JSON.stringify(key)}${separator}`, value: value[key] ?? null }))
}
function join(parts: readonly string[], indent: string, depth: number): string {
if (indent === '') return parts.join(',')
const inner = `\n${indent.repeat(depth + 1)}`
return `${inner}${parts.join(`,${inner}`)}\n${indent.repeat(depth)}`
function schedule(pending: Pending[], open: string, members: readonly Member[], close: string, indent: string, depth: number): void {
if (members.length === 0) {
pending.push(`${open}${close}`)
return
}
const inner = indent === '' ? '' : `\n${indent.repeat(depth + 1)}`
const scheduled: Pending[] = []
for (const [index, member] of members.entries()) scheduled.push(`${index === 0 ? open : ','}${inner}${member.label}`, { depth: depth + 1, value: member.value })
scheduled.push(indent === '' ? close : `\n${indent.repeat(depth)}${close}`)
for (const item of scheduled.reverse()) pending.push(item)
}
+4 -3
View File
@@ -9,6 +9,7 @@ import { isAdfDocument } from './adf/document.ts'
import { isJsonValue } from './json-value.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
import { toEditorNormal } from './adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors')
@@ -95,7 +96,7 @@ for (const directory of roundTripDirectories) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected)
assert.deepEqual(toEditorNormal(result.value), expected)
})
}
}
@@ -184,12 +185,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected)
assert.deepEqual(toEditorNormal(result.value), expected)
const emitted = adfToMarkdown(result.value)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const again = markdownToAdf(emitted.value)
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
assert.deepEqual(again.value, expected)
assert.deepEqual(toEditorNormal(again.value), expected)
})
}
+2 -2
View File
@@ -1,13 +1,13 @@
import type { AdfMark } from '../adf/document.ts'
import type { JsonValue } from '../json-value.ts'
import { isAdfMark } from '../adf/document.ts'
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export const marksAttribute = 'marks'
export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => {
const attrs = mark.attrs ?? {}
const attrs = nodeAttrs(mark)
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
}
+4 -1
View File
@@ -6,6 +6,7 @@ import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
@@ -353,7 +354,9 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
const roundTrips = (node: AdfNode): void => {
const spelled = adfToMarkdown(document(node))
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) })
const read = markdownToAdf(spelled.value)
assert.ok(read.ok, read.ok ? '' : read.error.message)
assert.deepEqual(toEditorNormal(read.value), document(node))
}
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
+19 -19
View File
@@ -1,6 +1,6 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts'
import { adfDocumentFault, carriesOnly } from '../../adf/document.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockDirective } from '../../adf/block-directives.ts'
import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts'
@@ -26,7 +26,7 @@ export function adfToMarkdown(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
const blocks = emitBlocks(nodeContent(document), 'document', [], 0)
if (!blocks.ok) return blocks
return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`)
}
@@ -63,8 +63,8 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
}
function interruptsParagraph(node: AdfNode): boolean {
const items = node.content ?? []
const empty = (items[0]?.content ?? []).length === 0
const items = nodeContent(node)
const empty = items[0] === undefined || nodeContent(items[0]).length === 0
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
}
@@ -110,7 +110,7 @@ function commonMarkText(text: string): EmittedBlock {
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
const content = node.content ?? []
const content = nodeContent(node)
if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
const header = spellDirectiveHeader(node, directive)
@@ -134,7 +134,7 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
const inner = emitBlocks(nodeContent(node), 'document', path, depth + 1)
if (!inner.ok) return inner
const text = inner.value.text
.split('\n')
@@ -145,7 +145,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['language'])) return undefined
const slot = languageSlot(node.attrs?.['language'])
const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path)
if (!text.ok) return text
@@ -153,7 +153,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
}
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(node.attrs?.['language'])
const slot = languageSlot(nodeAttrs(node)['language'])
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const text = codeBlockText(node, path)
@@ -164,15 +164,15 @@ function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: Conve
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = ''
for (const [index, child] of (node.content ?? []).entries()) {
for (const [index, child] of nodeContent(node).entries()) {
const childPath = [...path, 'content', index]
if (
child.type !== 'text' ||
typeof child.text !== 'string' ||
child.text === '' ||
(child.content ?? []).length > 0 ||
(child.marks ?? []).length > 0 ||
Object.keys(child.attrs ?? {}).length > 0
nodeContent(child).length > 0 ||
nodeMarks(child).length > 0 ||
Object.keys(nodeAttrs(child)).length > 0
) {
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
}
@@ -185,10 +185,10 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return undefined
const level = node.attrs?.['level']
const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level)
const content = node.content ?? []
const content = nodeContent(node)
if (content.length === 0) return success(commonMarkText(hashes))
const line = emitInlineLine(content, 'heading', path)
if (!line.ok) return line
@@ -198,7 +198,7 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
const items = node.content ?? []
const items = nodeContent(node)
const start = listStart(node, items.length)
if (start === undefined || items.length === 0) return undefined
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
@@ -216,13 +216,13 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0
const start = node.attrs?.['order']
const start = nodeAttrs(node)['order']
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start
}
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined {
const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1)
const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1)
if (!inner.ok) return inner
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
const indent = ' '.repeat(marker.length)
@@ -232,7 +232,7 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep
}
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
const content = node.content ?? []
const content = nodeContent(node)
if (content.length === 0 || !carriesOnly(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line
@@ -240,6 +240,6 @@ function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
}
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
return success(commonMarkText('---'))
}
@@ -3,6 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
@@ -10,17 +11,17 @@ export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, s
const argument = spellArgument(node, argumentAttribute)
if (argument === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled)
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled)
if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs)
const marks = node.marks ?? []
const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelledPairs)
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
}
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined {
const value = argumentAttribute === undefined ? undefined : node.attrs?.[argumentAttribute]
const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute]
if (value === undefined) return ''
if (typeof value !== 'string' || !isBareToken(value)) return undefined
return ` ${value}`
+5 -5
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../../adf/document.ts'
import { carriesOnly } from '../../adf/document.ts'
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './inline-line.ts'
@@ -14,11 +14,11 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi
}
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = node.content ?? []
const content = nodeContent(node)
const media = content[0]
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined
const attrs = media.attrs ?? {}
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined
const attrs = nodeAttrs(media)
const alt = attrs['alt']
const url = attrs['url']
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
@@ -1,9 +1,10 @@
import type { AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
}
+12 -11
View File
@@ -9,6 +9,7 @@ import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
import { spellDestination, spellTitle } from '../link-syntax.ts'
@@ -127,7 +128,7 @@ function syntax(text: string): InlineSegment {
}
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
const holdsContent = (node.content ?? []).length > 0
const holdsContent = nodeContent(node).length > 0
if (holdsContent || node.text !== undefined) {
const held = holdsContent ? 'content' : 'text'
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path)
@@ -156,7 +157,7 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
for (const [offset, node] of nodes.entries()) {
const index = firstIndex + offset
// spec/flavour.md, Marks.
const mark = carries(node, carried, index) ? undefined : (node.marks ?? [])[depth]
const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth]
if (mark === undefined) {
runs.push({ index, kind: 'plain', node })
continue
@@ -184,7 +185,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
if (!carried.ok) return carried
return success({ segments: [syntax(carried.value)] })
}
const types = (node.marks ?? []).map((mark) => mark.type)
const types = nodeMarks(node).map((mark) => mark.type)
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
const directive = inlineDirective(node.type)
if (directive === undefined) return emitText(node, context, index, path)
@@ -206,7 +207,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
if (!empty.ok) return empty
const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute]
const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute]
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot)
@@ -217,9 +218,9 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
}
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } })
if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
@@ -260,9 +261,9 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
let text = ''
for (const node of nodes) {
if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range })
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
text += node.text
}
if (/[\n\r]/.test(text)) return success({ carry: range })
@@ -278,11 +279,11 @@ function needsPadding(text: string): boolean {
}
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
const href = mark.attrs?.['href']
const title = mark.attrs?.['title']
const href = nodeAttrs(mark)['href']
const title = nodeAttrs(mark)['title']
if (typeof href !== 'string') return success({ carry: range })
const node = nodes[0]
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
const destination = spellDestination(href, path)
if (!destination.ok) return destination
+6 -6
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../../adf/document.ts'
import { carriesOnly } from '../../adf/document.ts'
import { carriesOnly, nodeContent } from '../../adf/document.ts'
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
import { tryPipeCell } from './inline-line.ts'
import type { ConvertErrorPath } from '../../result.ts'
@@ -11,7 +11,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
for (const [rowIndex, row] of rows.entries()) {
const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) {
const content = paragraph.content ?? []
const content = nodeContent(paragraph)
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
if (line === undefined) return undefined
cells.push(line)
@@ -23,12 +23,12 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
}
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = node.content ?? []
const columns = (rows[0]?.content ?? []).length
const rows = nodeContent(node)
const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
if (!carriesOnly(node, []) || columns === 0) return undefined
const grid: AdfNode[][] = []
for (const [index, row] of rows.entries()) {
const cells = row.content ?? []
const cells = nodeContent(row)
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
const wanted = index === 0 ? 'tableHeader' : 'tableCell'
const paragraphs: AdfNode[] = []
@@ -43,7 +43,7 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
}
function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = cell.content ?? []
const content = nodeContent(cell)
const paragraph = content[0]
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
return paragraph
+2 -1
View File
@@ -2,6 +2,7 @@ import type { AdfMark } from '../adf/document.ts'
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { MarkType } from '../adf/mark-attributes.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs } from '../adf/document.ts'
import { spellAttributes, spellVocabulary } from './directive-syntax.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
@@ -30,6 +31,6 @@ export function markSpelling(type: string): MarkSpelling | undefined {
}
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, [])
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
}
+2 -2
View File
@@ -3,7 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage } from '../../adf/document.ts'
import { attributeNestingMessage, nodeMarks } from '../../adf/document.ts'
import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts'
@@ -89,7 +89,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
function slotText(content: readonly AdfNode[]): string | undefined {
if (content.length === 0) return ''
const only = content.length === 1 ? content[0] : undefined
if (only?.type !== 'text' || (only.marks ?? []).length > 0 || typeof only.text !== 'string') return undefined
if (only?.type !== 'text' || nodeMarks(only).length > 0 || typeof only.text !== 'string') return undefined
return only.text
}
+3 -2
View File
@@ -8,6 +8,7 @@ import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.t
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts'
@@ -337,7 +338,7 @@ function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<strin
function altText(node: AdfNode): string {
if (node.type === 'hardBreak') return ' '
const slot = inlineDirective(node.type)?.textAttribute
const spelled = slot === undefined ? undefined : node.attrs?.[slot]
const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot]
return typeof spelled === 'string' ? spelled : (node.text ?? '')
}
@@ -409,7 +410,7 @@ function markType(character: string, used: number): string {
// A node cannot carry one mark type twice (AGENTS.md §14).
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
return nodes.map((node) => {
const marks = node.marks ?? []
const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
})
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { failure, faulted, positioned, success, type ConvertErrorPath, type Pars
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { listBreakName, listBreakSpelling } from '../list-break.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts'
@@ -106,7 +107,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, de
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path)
const attribute = node.attrs?.['language']
const attribute = nodeAttrs(node)['language']
const fromFence = only.language !== ''
const slot = languageSlot(fromFence ? only.language : attribute)
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
+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
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.
- [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`
+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
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`;
6, 7 → `0.3.0`; 9 → TBD.
The numbering is the order the work was planned in, not the order it ships.
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; 5e last.
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] **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] **3k — The CommonMark spec suite.**
- [ ] **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
finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip
asserts, which over normalized input is the canonical serializer's compact spelling —
rather than staying spelled inline as `?? []` at every reader. The
reading half is `nodeContent`/`nodeAttrs`/`nodeMarks` over the ~28 sites spelling it
inline today, which also lifts the branch floor §10 keeps below 100 for exactly those
halves.
Generators emit editor-normal ADF (§2). Real sanitized ADF from live Atlassian APIs lands
here too (§10), in `corpus/real-payloads/`: an ADF→markdown→ADF check with no expected
markdown, the payloads supplied by the maintainer. This subsumes 2e5's collision property —
a document that round-trips proves no other document shares its spelling — so decide here
whether that gate stays as the parser-free, faster-failing signal or goes; the half holding
no fixture duplicates is hygiene rather than a round-trip claim, and stays either way.
documents a human wrote — the thing that proves 2 and 3 beyond them.
**Settled** (the maintainer, 2026-09-13): `fast-check` generates and shrinks. The gate runs a
fixed seed, the properties together adding about five seconds per engine; an environment
variable raises the runs and randomizes the seed for local digging, and a counterexample
found becomes a round-trip fixture. The generators draw from the node tables — each node's
content model and attribute vocabulary as `adf/` records them, which 11 holds to Atlassian's
schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker
enters the tests. `toEditorNormal` stays internal. 2e5's collision test goes, since a
collision already fails the round-trip on the same fixtures; the fixture-duplicate test
stays.
- [ ] **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 and
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
`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:
@@ -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
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
- [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
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 —
supports GitHub Actions, GitLab CI, CircleCI and Buildkite, not Gitea or self-hosted
runners. So the release path has an expiry date and no drop-in successor yet. Revisit before
the deadline: whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
release moves to a human-approved staged publish — which fits badly with publish-on-merge,
publishing around January 2027, leaving them `npm stage publish`, which a maintainer
approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted
Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned
without a date. So the release path has an expiry date and no drop-in successor yet. Revisit:
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.
**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
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
@@ -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 —
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
first, then the shortest runnable example; the reader's top seconds go to "why this exists"
instead of "what I can do with it". Demote the Jira/endpoint background to a later "why
losslessness" note or drop it — the internal references (the `jira.atlassian.com` URL,
`pf-editor-service/convert`) don't belong in published text at all, no ticket IDs or internal
URLs. The `0.3.0` HTML future should read as an aside, not the lede: the package reads as a
shipped `0.1.0`, not a work-in-progress.
first, then the shortest runnable example.
**Settled** (the maintainer, 2026-09-13): the background goes entirely, no endpoint, ticket or
"why" note left. The top follows the package-README order: an npm version badge and the Gitea
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
one-line table of contents, then install and the shortest runnable example; a table of
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] **5b — The consumer's error surface.**
- [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
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
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
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
@@ -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
{attrs}` with no closer; inline node `!adf:name[content]{attrs}`; directive marks
`!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
today's colon-run does. Leaf vs container is decided by the node's content model rather than
syntax — the `::`/`:::` split and §4's name-set-independent recognition go, a simplification
the carry makes safe (an unknown *block* node already rides the fence, not the directive).
The carry's reserved name becomes `carry`, both spellings — the block fence info string
`` `carry` `` and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node
verbatim, never "unknown-node", since a known node no section spells where it stands rides it
too. A spelling change, not a semantic one: no `ConvertErrorCode` is added, removed or renamed,
the round-trip guarantee and the carry both hold through it. Mechanical surface: the grammar in
`spec/flavour.md`, `src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s
escaping stay `{key=value}`; the literal escape is `\!adf:`. Leaf vs container is decided by
the node's content model rather than syntax — the `::`/`:::` split goes, a simplification the
carry makes safe (an unknown *block* node already rides the fence, not the directive). The
carry's reserved name becomes `carry`, both spellings — the block fence info string `carry`
and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node verbatim,
never "unknown-node", since a known node no section spells where it stands rides it too. No
`ConvertErrorCode` is added, removed or renamed, and the round-trip guarantee and the carry
both hold through it. Mechanical surface: the grammar in `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
fixture (round-trip, normalization and `errors/`), `spec.test.ts`'s prose reader, and the
README's examples.
- [ ] **12a — The spec and the decision.** Rewrite `spec/flavour.md` to the `!adf:` grammar, and
record the departures in `AGENTS.md` §4 (leaf/container by content model, carry renamed
`carry`).
- [ ] **12b — The emit side.** `adfToMarkdown` spells `!adf:` / `!adf:/name` / `!adf:carry`; its
fixtures re-spelled, green.
- [ ] **12c — The parse side and the round-trip.** `markdownToAdf` reads it back; the round-trip
corpus, the `errors/` fixtures and the CommonMark spec suite re-spelled,
`markdownToAdf(adfToMarkdown(doc))` still equals `doc`.
- [ ] **12d — The README and the sweep.** The README's examples follow; sweep docs and fixtures
for any stale `::`/`:name` spelling.
fixture (round-trip, normalization and `errors/`), the prose reader over `spec/flavour.md`,
and the README's examples.
**Settled** (the maintainer, 2026-09-13):
- A line opening `!adf:name` is a block line when a space or the line's end follows the name,
and a paragraph when `[` or `{` does. Claiming stays syntactic and structure comes from the
tables: an unknown name is `unknown-directive-name` at the opener, whatever follows it.
- An unescaped `!adf:` claims on its own anywhere inline: one completing no directive is
`malformed-directive`, the emitter escapes every literal `!adf:`, and `!adf:hardBreak{}`
keeps its braces. Block and inline share the one `\!adf:` escape hint.
- A closer names the innermost open container, crosses no list-item or blockquote edge,
indents as a fence does and carries nothing after the name; anything else is
`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