24 Commits

Author SHA1 Message Date
lilleman 33a5e01511 4.1: an iterative canonical serializer
CI / gate (push) Successful in 19s
CI / publish (push) Has been skipped
2026-09-14 19:38:34 +02:00
lilleman 016d5eba26 4.1: the review's test and comment fixes 2026-09-14 19:37:37 +02:00
lilleman ba50364ae7 4.1: editor-normal and the node accessors
CI / gate (push) Successful in 17s
CI / publish (push) Has been skipped
2026-09-14 17:03:00 +02:00
lilleman e41f9719da 4.1: a text node carrying attributes never merges 2026-09-14 16:53:56 +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
lilleman 5c62b07530 Extend the merge grant through 0.2.0 and drop the finished reserved acts
CI / publish (push) Successful in 3s
CI / gate (push) Successful in 17s
2026-09-13 11:43:48 +02:00
lilleman 1e20b0b6cc Tick 3k and milestone 3, moving their text to todo-history.md
CI / gate (push) Successful in 17s
CI / publish (push) Successful in 4s
2026-09-13 11:10:41 +02:00
lilleman e989cc3edd Name the pending CommonMark divergences and complete the spec attribution
CI / gate (push) Successful in 17s
CI / publish (push) Successful in 4s
2026-09-12 20:18:41 +02:00
lilleman f81bebe8d0 Spell the check set once; fix the doubled spec version and a stale filename
CI / gate (push) Successful in 18s
CI / publish (push) Has been skipped
2026-09-09 15:40:29 +02:00
lilleman d1331a6612 Delete the unreachable entity decoder, hash-pin spec.json, and fold the exception pin loop
CI / gate (push) Successful in 24s
CI / publish (push) Has been skipped
2026-09-09 14:08:30 +02:00
lilleman 6e3b7e692c Cite §14 instead of the ticked 3e item in the count comment
CI / gate (push) Successful in 17s
CI / publish (push) Has been skipped
2026-09-06 23:15:16 +02:00
lilleman dfbb92b7ab Pin refusal codes and divergences; fix the text oracle and count by heading level
CI / gate (push) Successful in 18s
CI / publish (push) Has been skipped
2026-09-06 23:09:01 +02:00
lilleman 12b57a06a9 Pin the suite run, gate the exception list, and trim the corpus README
CI / gate (push) Successful in 19s
CI / publish (push) Has been skipped
2026-09-06 21:58:58 +02:00
lilleman e6610d7057 Check in the CommonMark spec suite and pin its exception list
CI / gate (push) Successful in 19s
CI / publish (push) Has been skipped
2026-09-05 18:56:28 +02:00
lilleman 026ea5e1b6 Rework the roadmap: fold perf and docs fixes into 0.2.0, add 0.2.1
CI / gate (push) Successful in 18s
CI / publish (push) Successful in 4s
2026-09-05 18:14:05 +02:00
lilleman 5d19bdbae7 Add the online sandbox, lossy conversion and @atlaskit/adf-schema evaluation
CI / gate (push) Successful in 17s
CI / publish (push) Successful in 3s
2026-09-05 16:41:34 +02:00
lilleman 75f35eef0b Tick the 0.1.0 release and record the publish token's deadline
CI / gate (push) Successful in 18s
CI / publish (push) Successful in 3s
2026-09-05 14:16:29 +02:00
37 changed files with 14091 additions and 184 deletions
+28 -14
View File
@@ -17,12 +17,14 @@ When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically — back yields the library's canonical spelling, and that spelling round-trips byte-identically —
where there is a way back. CommonMark spells link destinations the flavour has no escape for, so a where there is a way back. CommonMark spells some things the flavour has no escape for — a link
parse succeeding does not imply a spellable document; `todo.md` 3k's exception list names those. destination or title holding a backslash or newline, a paragraph opening with a code span whose
backticks read back as a fence — so a parse succeeding does not imply a spellable document;
`corpus/commonmark-spec/exceptions.json` names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks "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 and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
key — the only domain markdown can restore. 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. Round-trip equality is a property tested over a corpus, not a claim made in prose.
@@ -57,8 +59,15 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than
a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own
module, so entity decoding is complete without one. `devDependencies`: few, each earning its keep; module, so entity decoding is complete without one. The CommonMark spec suite is the same shape of
they never reach a consumer. 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. 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 ## 6. The package contract
@@ -198,7 +207,8 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor. `node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js` A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js`
over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey over HTTP and converts the round-trip, normalization and error fixtures — the `commonmark-spec`
sort is the Node suite's to check — which is §6's browser half and the only SpiderMonkey
there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's. there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's.
A WebDriver session is what carries a verdict back out, the driver and the page's server sharing A WebDriver session is what carries a verdict back out, the driver and the page's server sharing
one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
@@ -213,8 +223,7 @@ functions, and a branch floor that only ever moves upward. It sits below 100 bec
compared against `undefined` — have a half no valid document reaches. compared against `undefined` — have a half no valid document reaches.
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against live Atlassian APIs; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
`markdownToAdf` and `markdownToHtml`.
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they `spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
@@ -224,6 +233,12 @@ of a bullet; fenced examples are skipped. It guards the attributes alone: nodes
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
both answer to the round-trip corpus and to nothing else where a node has no fixture. both answer to the round-trip corpus and to nothing else where a node has no fixture.
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
documentation, since any value round-trips. What the schema holds and the tables do not spell is
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
someone spells it or pins it.
## 11. Code rules ## 11. Code rules
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order - Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
@@ -316,17 +331,16 @@ worth deliberating; what matters is that nothing is left undone in the end. Per
gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a
result exists for the commit under review, or when the diff since that result cannot affect result exists for the commit under review, or when the diff since that result cannot affect
it (docs-only) — re-run only what its own findings or fixes invalidate. it (docs-only) — re-run only what its own findings or fixes invalidate.
3. Merge the PR (standing authorization, this repo only, granted through the `0.1.0` release — 3. Merge the PR (standing authorization, this repo only, granted through the `0.2.0` release —
PR #3), check the box in `todo.md` and move the item's text to `todo-history.md`, leaving its the maintainer, 2026-09-13), check the box in `todo.md` and move the item's text to
title behind, report, stop. The next chunk gets a fresh session. `todo-history.md`, leaving its title behind, report, stop. The next chunk gets a fresh session.
Ask, don't guess: any choice where what the maintainer would pick is not near-certain gets asked, Ask, don't guess: any choice where what the maintainer would pick is not near-certain gets asked,
and the answer lands as a decision in this file. The confidence bar is very high — asking too and the answer lands as a decision in this file. The confidence bar is very high — asking too
often is the accepted cost, guessing wrong is not. often is the accepted cost, guessing wrong is not.
Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on
`main` publishes, §9 — every release including `0.1.0` is the maintainer's), making the repo `main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret.
public, and creating the `NPM_TOKEN` secret.
A continuous loop session (`/loop`) counts as a chain of sessions: one chunk per iteration, each A continuous loop session (`/loop`) counts as a chain of sessions: one chunk per iteration, each
iteration starting by re-reading `AGENTS.md` and `todo.md` and trusting them over anything iteration starting by re-reading `AGENTS.md` and `todo.md` and trusting them over anything
+7 -2
View File
@@ -3,8 +3,8 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect. an HTML dialect.
**Status: pre-release — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML not **Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
yet.** `0.3.0`.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
[`spec/flavour.md`](spec/flavour.md). [`spec/flavour.md`](spec/flavour.md).
@@ -115,6 +115,11 @@ emit refuses:
library's canonical spelling, which round-trips byte-identically — where it converts back at library's canonical spelling, which round-trips byte-identically — where it converts back at
all: a parse succeeding is no promise of that, so keep the source until the way back succeeds. all: a parse succeeding is no promise of that, so keep the source until the way back succeeds.
`[a](/a\b)`, `<http://x?a=1&amp;b=2>` and `[a](/x&#10;y)` read cleanly and then refuse. `[a](/a\b)`, `<http://x?a=1&amp;b=2>` and `[a](/x&#10;y)` read cleanly and then refuse.
- Three CommonMark spellings parse without an error and build a document the reference renders
differently: `[](/url)` and `[]()` stay literal text against CommonMark's empty link, a list
continuing past a marker change stays one list against CommonMark's two, and a shortcut
reference matching its definition only under Unicode case folding stays unresolved. Each is
pinned `pending` in `corpus/commonmark-spec/exceptions.json`.
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`. processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
+1
View File
@@ -72,6 +72,7 @@ assert.deepEqual(
readdirSync(corpusRoot, { withFileTypes: true }) readdirSync(corpusRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()) .filter((entry) => entry.isDirectory())
.map((entry) => entry.name) .map((entry) => entry.name)
.filter((name) => name !== 'commonmark-spec')
.sort(), .sort(),
['errors', 'normalization', 'round-trip'], ['errors', 'normalization', 'round-trip'],
'a corpus kind the browser leg does not convert', 'a corpus kind the browser leg does not convert',
+11 -1
View File
@@ -12,5 +12,15 @@ One directory per contract kind, each landing with its milestone:
pins which error. pins which error.
- `real-payloads/` — `<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No - `real-payloads/` — `<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
expected markdown. expected markdown.
- `commonmark-spec/` — the CommonMark suite run against `markdownToAdf` by three checks. `spec.json`
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later
milestone may close).
JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. `spec.json` is the vendored,
upstream machine-readable suite, byte-exact from
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
re-serialized by the corpus gate.
+436
View File
@@ -0,0 +1,436 @@
[
{
"check": "fixpoint",
"divergence": "unspellable-link",
"example": 196,
"kind": "unspellable",
"reason": "The link title holds literal newlines no escape spells."
},
{
"check": "fixpoint",
"divergence": "unspellable-link",
"example": 202,
"kind": "unspellable",
"reason": "The link destination holds a backslash the flavour cannot escape."
},
{
"check": "count",
"divergence": "ul 2/1",
"example": 301,
"kind": "pending",
"reason": "A list continuing past a marker change renders as two lists, the parser opens one."
},
{
"check": "count",
"divergence": "ol 2/1",
"example": 302,
"kind": "pending",
"reason": "A list continuing past a marker change renders as two lists, the parser opens one."
},
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 330,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 331,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 340,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{
"check": "count",
"divergence": "em 2/1",
"example": 369,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 2/1",
"example": 373,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 2/1",
"example": 389,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 1/3",
"example": 393,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/5",
"example": 394,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/3",
"example": 395,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/3",
"example": 399,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 404,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/3",
"example": 406,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 2/1",
"example": 407,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 2/1",
"example": 408,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 2/1",
"example": 409,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 1/3",
"example": 410,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/3",
"example": 411,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 413,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 414,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 415,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 3/1",
"example": 417,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 2/5 strong 1/3",
"example": 418,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/2",
"example": 422,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/3",
"example": 424,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 2/1",
"example": 425,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 2/1",
"example": 426,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 2/1",
"example": 427,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 1/3",
"example": 428,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/3",
"example": 429,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/2",
"example": 430,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/2",
"example": 431,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/3 strong 2/5",
"example": 432,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "strong 1/2",
"example": 433,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 2/1",
"example": 461,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 2/1",
"example": 463,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 2/1",
"example": 464,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 2/1",
"example": 465,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 3/1",
"example": 466,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "strong 2/1",
"example": 468,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
},
{
"check": "count",
"divergence": "em 1/3",
"example": 470,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 478,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 479,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "a 1/0",
"example": 484,
"kind": "pending",
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
},
{
"check": "text",
"divergence": "\"\" against \"[](./target.md)\"",
"example": 484,
"kind": "pending",
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
},
{
"check": "count",
"divergence": "a 1/0",
"example": 487,
"kind": "pending",
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
},
{
"check": "text",
"divergence": "\"\" against \"[]()\"",
"example": 487,
"kind": "pending",
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
},
{
"check": "fixpoint",
"divergence": "unspellable-link",
"example": 502,
"kind": "unspellable",
"reason": "The link destination holds a backslash the flavour cannot escape."
},
{
"check": "count",
"divergence": "a 1/5 em 1/4",
"example": 516,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/3",
"example": 519,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "a 1/5 em 1/4",
"example": 530,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 533,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "a 1/0",
"example": 540,
"kind": "pending",
"reason": "The case-folding shortcut reference is unresolved; CommonMark folds case and links."
},
{
"check": "text",
"divergence": "\"ẞ\" against \"[ẞ]\"",
"example": 540,
"kind": "pending",
"reason": "A case-folding shortcut reference is unresolved; CommonMark folds case and links."
},
{
"check": "count",
"divergence": "a 1/2",
"example": 554,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "a 1/2",
"example": 558,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "a 1/2",
"example": 559,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 638,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 639,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}
]
+346
View File
@@ -0,0 +1,346 @@
[
{
"code": "unmappable-html",
"example": 21
},
{
"code": "unmappable-html",
"example": 31
},
{
"code": "unmappable-html",
"example": 148
},
{
"code": "unmappable-html",
"example": 149
},
{
"code": "unmappable-html",
"example": 150
},
{
"code": "unmappable-html",
"example": 151
},
{
"code": "unmappable-html",
"example": 152
},
{
"code": "unmappable-html",
"example": 153
},
{
"code": "unmappable-html",
"example": 154
},
{
"code": "unmappable-html",
"example": 155
},
{
"code": "unmappable-html",
"example": 156
},
{
"code": "unmappable-html",
"example": 157
},
{
"code": "unmappable-html",
"example": 158
},
{
"code": "unmappable-html",
"example": 159
},
{
"code": "unmappable-html",
"example": 160
},
{
"code": "unmappable-html",
"example": 161
},
{
"code": "unmappable-html",
"example": 162
},
{
"code": "unmappable-html",
"example": 163
},
{
"code": "unmappable-html",
"example": 164
},
{
"code": "unmappable-html",
"example": 165
},
{
"code": "unmappable-html",
"example": 166
},
{
"code": "unmappable-html",
"example": 167
},
{
"code": "unmappable-html",
"example": 168
},
{
"code": "unmappable-html",
"example": 169
},
{
"code": "unmappable-html",
"example": 170
},
{
"code": "unmappable-html",
"example": 171
},
{
"code": "unmappable-html",
"example": 172
},
{
"code": "unmappable-html",
"example": 173
},
{
"code": "unmappable-html",
"example": 174
},
{
"code": "unmappable-html",
"example": 175
},
{
"code": "unmappable-html",
"example": 176
},
{
"code": "unmappable-html",
"example": 177
},
{
"code": "unmappable-html",
"example": 178
},
{
"code": "unmappable-html",
"example": 179
},
{
"code": "unmappable-html",
"example": 180
},
{
"code": "unmappable-html",
"example": 181
},
{
"code": "unmappable-html",
"example": 182
},
{
"code": "unmappable-html",
"example": 183
},
{
"code": "unmappable-html",
"example": 184
},
{
"code": "unmappable-html",
"example": 185
},
{
"code": "unmappable-html",
"example": 186
},
{
"code": "unmappable-html",
"example": 187
},
{
"code": "unmappable-html",
"example": 188
},
{
"code": "unmappable-html",
"example": 189
},
{
"code": "unmappable-html",
"example": 190
},
{
"code": "unmappable-html",
"example": 191
},
{
"code": "unmappable-html",
"example": 201
},
{
"code": "unmappable-html",
"example": 308
},
{
"code": "unmappable-html",
"example": 309
},
{
"code": "unmappable-html",
"example": 344
},
{
"code": "unmappable-html",
"example": 475
},
{
"code": "unmappable-html",
"example": 476
},
{
"code": "unmappable-html",
"example": 477
},
{
"code": "unmappable-html",
"example": 491
},
{
"code": "unmappable-html",
"example": 494
},
{
"code": "unmappable-image",
"example": 517
},
{
"code": "unmappable-html",
"example": 524
},
{
"code": "unmappable-image",
"example": 531
},
{
"code": "unmappable-html",
"example": 536
},
{
"code": "unmappable-image",
"example": 572
},
{
"code": "unmappable-image",
"example": 573
},
{
"code": "unmappable-image",
"example": 576
},
{
"code": "unmappable-image",
"example": 577
},
{
"code": "unmappable-image",
"example": 579
},
{
"code": "unmappable-image",
"example": 584
},
{
"code": "unmappable-image",
"example": 585
},
{
"code": "unmappable-image",
"example": 586
},
{
"code": "unmappable-image",
"example": 587
},
{
"code": "unmappable-image",
"example": 588
},
{
"code": "unmappable-image",
"example": 589
},
{
"code": "unmappable-image",
"example": 591
},
{
"code": "unmappable-html",
"example": 613
},
{
"code": "unmappable-html",
"example": 614
},
{
"code": "unmappable-html",
"example": 615
},
{
"code": "unmappable-html",
"example": 616
},
{
"code": "unmappable-html",
"example": 617
},
{
"code": "unmappable-html",
"example": 623
},
{
"code": "unmappable-html",
"example": 625
},
{
"code": "unmappable-html",
"example": 626
},
{
"code": "unmappable-html",
"example": 627
},
{
"code": "unmappable-html",
"example": 628
},
{
"code": "unmappable-html",
"example": 629
},
{
"code": "unmappable-html",
"example": 630
},
{
"code": "unmappable-html",
"example": 631
},
{
"code": "unmappable-html",
"example": 642
},
{
"code": "unmappable-html",
"example": 643
}
]
File diff suppressed because it is too large Load Diff
@@ -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 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 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 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 — parsing to anything but one unmarked text node — adjacent text nodes with identical marks and no
is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach attributes merged first — is a named error, and so is a `text` key in `{attrs}`. An enclosing mark
into the slot. The rest take no content, `:text` included; content on a node that takes none is a spelling does not reach into the slot. The rest take no content, `:text` included; content on a
named error. node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` - `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 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 `: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 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 the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
(AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and tabs, or one attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — is a named tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
error. is a named error.
``` ```
:text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines. :text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines.
+14
View File
@@ -0,0 +1,14 @@
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'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema')
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')
})
+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 { export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false if (nodeMarks(node).length > 0 || node.text !== undefined) return false
return holdsOnly(node.attrs ?? {}, attributes) 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. // 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']) 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[] { function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
const pending: unknown[] = [...value] const pending: unknown[] = [...value]
while (pending.length > 0) { while (pending.length > 0) {
@@ -95,23 +107,23 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
while (pending.length > 0) { while (pending.length > 0) {
const node = pending.pop() const node = pending.pop()
if (node === undefined) continue 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 if (fault !== undefined) return fault
pending.push(...(node.content ?? [])) pending.push(...nodeContent(node))
} }
return undefined return undefined
} }
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined { function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
for (const mark of marks ?? []) { for (const mark of marks) {
const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting) const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting)
if (fault !== undefined) return fault if (fault !== undefined) return fault
} }
return undefined return undefined
} }
function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined { function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs ?? {})) { for (const [key, value] of Object.entries(attrs)) {
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) } if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
} }
return undefined 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' import { serializeCanonicalJson } from '../canonical-json.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean { export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
return markKey(candidate) === markKey(mark) return markKey(candidate) === markKey(mark)
} }
// AGENTS.md §2: adjacent text nodes carrying identical marks are one node.
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] { export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = [] const merged: AdfNode[] = []
for (const node of nodes) { for (const node of nodes) {
const previous = merged[merged.length - 1] 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 ?? ''}` } merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue continue
} }
@@ -19,8 +24,61 @@ export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
return merged 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 { 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 { function marksKey(marks: readonly AdfMark[]): string {
@@ -28,5 +86,5 @@ function marksKey(marks: readonly AdfMark[]): string {
} }
function markKey(mark: 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 assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { JsonValue } from './json-value.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
test('sorts object keys recursively', () => { test('sorts object keys recursively', () => {
@@ -39,3 +40,15 @@ test('leaves non-ASCII raw', () => {
test('spells scalars in canonical JSON', () => { 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"]') 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)}`)
})
+32 -18
View File
@@ -2,28 +2,42 @@ import type { JsonValue } from './json-value.ts'
export type JsonSpelling = 'compact' | 'two-space' 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 { 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 { function objectMembers(value: { [key: string]: JsonValue }, indent: string): Member[] {
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 separator = indent === '' ? ':' : ': '
const entries = keys.map((key) => `${JSON.stringify(key)}${separator}${serialize(value[key] ?? null, indent, depth + 1)}`) return Object.keys(value)
return `{${join(entries, indent, depth)}}` .sort()
} .map((key) => ({ label: `${JSON.stringify(key)}${separator}`, value: value[key] ?? null }))
return JSON.stringify(value)
} }
function join(parts: readonly string[], indent: string, depth: number): string { function schedule(pending: Pending[], open: string, members: readonly Member[], close: string, indent: string, depth: number): void {
if (indent === '') return parts.join(',') if (members.length === 0) {
const inner = `\n${indent.repeat(depth + 1)}` pending.push(`${open}${close}`)
return `${inner}${parts.join(`,${inner}`)}\n${indent.repeat(depth)}` 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)
} }
+340
View File
@@ -0,0 +1,340 @@
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 { AdfDocument, AdfNode } from './adf/document.ts'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus', 'commonmark-spec')
const checks = ['count', 'fixpoint', 'text'] as const
type Check = (typeof checks)[number]
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable'
type SpecExample = { example: number; html: string; markdown: string; section: string }
type Exception = { check: Check; divergence: string; example: number; kind: ExceptionKind; reason: string }
type Refusal = { code: string; example: number }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isCheck(value: unknown): value is Check {
return checks.some((check) => check === value)
}
function isKind(value: unknown): value is ExceptionKind {
return value === 'mark-model' || value === 'pending' || value === 'unspellable'
}
function isSpecExample(value: unknown): value is SpecExample {
if (!isRecord(value)) return false
return typeof value['example'] === 'number' && typeof value['html'] === 'string' && typeof value['markdown'] === 'string' && typeof value['section'] === 'string'
}
function isException(value: unknown): value is Exception {
if (!isRecord(value)) return false
return (
isCheck(value['check']) &&
typeof value['divergence'] === 'string' &&
value['divergence'].length > 0 &&
typeof value['example'] === 'number' &&
isKind(value['kind']) &&
typeof value['reason'] === 'string' &&
value['reason'].length > 0
)
}
function isRefusal(value: unknown): value is Refusal {
if (!isRecord(value)) return false
return typeof value['code'] === 'string' && value['code'].length > 0 && typeof value['example'] === 'number'
}
function readJson<T>(name: string, guard: (value: unknown) => value is T, shape: string): T[] {
const parsed: unknown = JSON.parse(readFileSync(join(root, name), 'utf8'))
assert.ok(Array.isArray(parsed), `${name} is not an array`)
return parsed.map((value, index) => {
assert.ok(guard(value), `${name} holds a ${shape} with the wrong shape at ${index}`)
return value
})
}
const spec = readJson('spec.json', isSpecExample, 'spec example')
const exceptions = readJson('exceptions.json', isException, 'exception')
const refusals = readJson('refusals.json', isRefusal, 'refusal')
const exampleToRefusal = new Map(refusals.map((refusal) => [refusal.example, refusal.code]))
const exceptionIndex = new Map(exceptions.map((entry) => [`${entry.example}:${entry.check}`, entry]))
test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
const digest = createHash('sha256').update(readFileSync(join(root, 'spec.json'))).digest('hex')
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
})
test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => {
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
for (const entry of exceptions) {
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`)
}
})
test('the refusal list is unique per example and names real examples', () => {
assert.equal(exampleToRefusal.size, refusals.length, 'one refusal repeats an example another holds')
for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`)
})
// A mark is counted once per text node it touches (AGENTS.md §14).
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul']
const nodeElement: Record<string, string> = {
blockquote: 'blockquote',
bulletList: 'ul',
codeBlock: 'pre',
hardBreak: 'br',
listItem: 'li',
media: 'img',
mediaInline: 'img',
orderedList: 'ol',
rule: 'hr',
}
const markElement: Record<string, string> = { code: 'code', em: 'em', link: 'a', strong: 'strong' }
const blockTags = new Set(['blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'li', 'ol', 'p', 'pre', 'ul'])
function tagName(tag: string): string {
return tag.slice(1).replace(/^\//, '').split(/[\s/>]/)[0] ?? ''
}
function emptyCounts(): Record<string, number> {
return Object.fromEntries(countKeys.map((key) => [key, 0]))
}
function referenceCounts(html: string): Record<string, number> {
const counts = emptyCounts()
let inPre = false
for (let index = 0; index < html.length; index += 1) {
if (html[index] !== '<') continue
const close = html.indexOf('>', index)
if (close === -1) break
const tag = html.slice(index, close + 1)
if (tag.startsWith('</')) {
if (tagName(tag) === 'pre') inPre = false
index = close
continue
}
const name = tagName(tag)
if (name === 'pre') {
inPre = true
counts['pre'] = (counts['pre'] ?? 0) + 1
} else if (name === 'code' && inPre) {
// A code block's `<code>` is the `<pre>`'s body, already counted.
} else if (countKeys.includes(name)) {
counts[name] = (counts[name] ?? 0) + 1
}
index = close
}
return counts
}
function nodeCounts(document: AdfNode): Record<string, number> {
const counts = emptyCounts()
const pending: AdfNode[] = [document]
while (pending.length > 0) {
const node = pending.pop()
if (node === undefined) continue
if (node.text !== undefined) {
const seen = new Set<string>()
for (const mark of node.marks ?? []) {
const element = markElement[mark.type]
if (element !== undefined) seen.add(element)
}
for (const element of seen) counts[element] = (counts[element] ?? 0) + 1
continue
}
if (node.type === 'heading') {
const level = node.attrs?.['level']
if (typeof level === 'number') counts[`h${level}`] = (counts[`h${level}`] ?? 0) + 1
pending.push(...(node.content ?? []))
continue
}
const element = nodeElement[node.type]
if (element !== undefined) counts[element] = (counts[element] ?? 0) + 1
pending.push(...(node.content ?? []))
}
return counts
}
const namedEntity: Record<string, string> = { amp: '&', gt: '>', lt: '<', ouml: 'ö', quot: '"' }
function decodeHtmlEntity(text: string, index: number): { length: number; text: string } | undefined {
if (text[index] !== '&') return undefined
const end = text.indexOf(';', index)
if (end === -1 || end - index > 8) return undefined
const reference = text.slice(index, end + 1)
const named = namedEntity[reference.slice(1, -1)]
return named === undefined ? undefined : { length: reference.length, text: named }
}
test('the oracle decodes every entity the reference HTML holds', () => {
for (const example of spec) {
for (const [reference] of example.html.matchAll(/&#?[0-9A-Za-z]+;/g)) {
assert.ok(decodeHtmlEntity(reference, 0) !== undefined, `example ${example.example} holds ${reference}, which the oracle would leave literal`)
}
}
})
function referenceText(html: string): string {
const parts: string[] = []
let preDepth = 0
let atBoundary = true
let skipNewline = false
for (let index = 0; index < html.length; index += 1) {
const character = html.charAt(index)
if (character === '<') {
const close = html.indexOf('>', index)
if (close === -1) break
const tag = html.slice(index, close + 1)
const name = tagName(tag)
if (name === 'br') {
parts.push(' ')
atBoundary = false
skipNewline = true
index = close
continue
}
if (name === 'pre') {
if (tag.startsWith('</')) {
preDepth -= 1
trimTrailingNewline(parts)
} else {
preDepth += 1
}
atBoundary = true
} else {
atBoundary = blockTags.has(name)
}
index = close
continue
}
if (character === '\n') {
if (skipNewline) {
skipNewline = false
continue
}
if (preDepth > 0) {
parts.push('\n')
continue
}
if (!atBoundary && !followedByBlock(html, index + 1)) parts.push(' ')
continue
}
const reference = decodeHtmlEntity(html, index)
if (reference !== undefined) {
parts.push(reference.text)
atBoundary = false
index += reference.length - 1
continue
}
parts.push(character)
atBoundary = false
}
return parts.join('')
}
function trimTrailingNewline(parts: string[]): void {
const last = parts[parts.length - 1]
if (last === undefined) return
parts[parts.length - 1] = last.endsWith('\n') ? last.slice(0, -1) : last
}
// A newline beside a block open or close is a boundary rather than a soft break, so it spells no space.
function followedByBlock(html: string, index: number): boolean {
let next = index
while (next < html.length && (html[next] === '\n' || html[next] === ' ' || html[next] === '\t')) next += 1
if (next >= html.length) return true
if (html[next] !== '<') return false
const close = html.indexOf('>', next)
return close !== -1 && blockTags.has(tagName(html.slice(next, close + 1)))
}
function concatenatedText(document: AdfNode): string {
const parts: string[] = []
const pending: { inCode: boolean; node: AdfNode }[] = [{ inCode: false, node: document }]
while (pending.length > 0) {
const frame = pending.pop()
if (frame === undefined) continue
const { inCode, node } = frame
if (node.text !== undefined) {
parts.push(inCode ? node.text : node.text.replace(/\n/g, ' '))
continue
}
if (node.type === 'hardBreak') {
parts.push(' ')
continue
}
const childInCode = inCode || node.type === 'codeBlock'
const content = node.content ?? []
for (let index = content.length - 1; index >= 0; index -= 1) {
const child = content[index]
if (child !== undefined) pending.push({ inCode: childInCode, node: child })
}
}
return parts.join('')
}
function fixpointRefused(example: SpecExample, document: AdfDocument): string | undefined {
const emitted = adfToMarkdown(document)
if (!emitted.ok) return emitted.error.code
const again = markdownToAdf(emitted.value)
assert.ok(again.ok, `example ${example.example} emits markdown it cannot read back`)
assert.deepEqual(again.value, document, `example ${example.example} does not hold its own round-trip`)
return undefined
}
function textMismatch(example: SpecExample, document: AdfDocument): string | undefined {
const expected = referenceText(example.html)
const actual = concatenatedText(document)
return expected === actual ? undefined : `${JSON.stringify(expected)} against ${JSON.stringify(actual)}`
}
function countMismatch(example: SpecExample, document: AdfDocument): string | undefined {
const expected = referenceCounts(example.html)
const actual = nodeCounts(document)
const names = countKeys.filter((key) => expected[key] !== actual[key])
return names.length === 0 ? undefined : names.map((name) => `${name} ${expected[name]}/${actual[name]}`).join(' ')
}
for (const example of spec) {
test(`CommonMark example ${example.example} => ${example.section}`, () => {
const parse = markdownToAdf(example.markdown)
const refused = exampleToRefusal.get(example.example)
if (refused !== undefined) {
assert.ok(!parse.ok, `example ${example.example} was expected to refuse with ${refused} but parsed`)
assert.equal(parse.error.code, refused, `example ${example.example} refused with a different code`)
return
}
if (!parse.ok) assert.fail(`example ${example.example} was expected to parse but refused with ${parse.error.code}`)
const divergences: Record<Check, string | undefined> = {
count: countMismatch(example, parse.value),
fixpoint: fixpointRefused(example, parse.value),
text: textMismatch(example, parse.value),
}
for (const check of checks) {
const entry = exceptionIndex.get(`${example.example}:${check}`)
const divergence = divergences[check]
if (divergence === undefined) {
assert.equal(entry, undefined, `example ${example.example} passes its ${check} check but files an exception`)
} else {
assert.ok(entry !== undefined, `example ${example.example} ${check} check fails: ${divergence}`)
assert.equal(entry.divergence, divergence, `example ${example.example} ${check} diverged differently than filed`)
}
}
})
}
+7 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { basename, dirname, join } from 'node:path' import { basename, dirname, join, sep } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@@ -9,6 +9,7 @@ import { isAdfDocument } from './adf/document.ts'
import { isJsonValue } from './json-value.ts' import { isJsonValue } from './json-value.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
import { toEditorNormal } from './adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors') const errorsRoot = join(corpusRoot, 'errors')
@@ -53,12 +54,13 @@ function pairedNames(root: string, first: string, second: string): string[] {
function corpusJsonPaths(): string[] { function corpusJsonPaths(): string[] {
return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true }) return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
.filter((name) => name.endsWith('.json')) .filter((name) => name.endsWith('.json'))
.filter((name) => name !== `commonmark-spec${sep}spec.json`)
.map((name) => join(corpusRoot, name)) .map((name) => join(corpusRoot, name))
.sort() .sort()
} }
test('every corpus directory is a kind the runner reads', () => { test('every corpus directory is a kind the runner reads', () => {
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip']) assert.deepEqual(directoryNames(corpusRoot), ['commonmark-spec', 'errors', 'normalization', 'round-trip'])
}) })
test('every round-trip directory is a kind the runner reads', () => { test('every round-trip directory is a kind the runner reads', () => {
@@ -94,7 +96,7 @@ for (const directory of roundTripDirectories) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')) const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected) assert.deepEqual(toEditorNormal(result.value), expected)
}) })
} }
} }
@@ -183,12 +185,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) 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) const emitted = adfToMarkdown(result.value)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const again = markdownToAdf(emitted.value) const again = markdownToAdf(emitted.value)
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`) 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 { AdfMark } from '../adf/document.ts'
import type { JsonValue } from '../json-value.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' import { serializeCanonicalJson } from '../canonical-json.ts'
export const marksAttribute = 'marks' export const marksAttribute = 'marks'
export function markValues(marks: readonly AdfMark[]): JsonValue { export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => { 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 } 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 type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts' import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } 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 roundTrips = (node: AdfNode): void => {
const spelled = adfToMarkdown(document(node)) const spelled = adfToMarkdown(document(node))
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) 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)) 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 { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.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 { blockDirective } from '../../adf/block-directives.ts'
import { carriedBlock } from '../opaque-carry.ts' import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
@@ -26,7 +26,7 @@ export function adfToMarkdown(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) 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 if (!blocks.ok) return blocks
return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`) 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 { function interruptsParagraph(node: AdfNode): boolean {
const items = node.content ?? [] const items = nodeContent(node)
const empty = (items[0]?.content ?? []).length === 0 const empty = items[0] === undefined || nodeContent(items[0]).length === 0
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty) if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, 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> { 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) 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 === '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) if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
const header = spellDirectiveHeader(node, directive) 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 { function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return 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 if (!inner.ok) return inner
const text = inner.value.text const text = inner.value.text
.split('\n') .split('\n')
@@ -145,7 +145,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['language'])) return 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 if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
if (!text.ok) return text 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> { 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']) const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const text = codeBlockText(node, path) 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> { function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = '' let text = ''
for (const [index, child] of (node.content ?? []).entries()) { for (const [index, child] of nodeContent(node).entries()) {
const childPath = [...path, 'content', index] const childPath = [...path, 'content', index]
if ( if (
child.type !== 'text' || child.type !== 'text' ||
typeof child.text !== 'string' || typeof child.text !== 'string' ||
child.text === '' || child.text === '' ||
(child.content ?? []).length > 0 || nodeContent(child).length > 0 ||
(child.marks ?? []).length > 0 || nodeMarks(child).length > 0 ||
Object.keys(child.attrs ?? {}).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) 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 { function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return 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 if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level) const hashes = '#'.repeat(level)
const content = node.content ?? [] const content = nodeContent(node)
if (content.length === 0) return success(commonMarkText(hashes)) if (content.length === 0) return success(commonMarkText(hashes))
const line = emitInlineLine(content, 'heading', path) const line = emitInlineLine(content, 'heading', path)
if (!line.ok) return line 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 { function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList' const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
const items = node.content ?? [] const items = nodeContent(node)
const start = listStart(node, items.length) const start = listStart(node, items.length)
if (start === undefined || items.length === 0) return undefined if (start === undefined || items.length === 0) return undefined
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) 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 { function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0 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 if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start return start + items - 1 > largestListMarker ? undefined : start
} }
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined { 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.ok) return inner
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
const indent = ' '.repeat(marker.length) 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 { 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 if (content.length === 0 || !carriesOnly(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path) const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line if (!line.ok) return line
@@ -240,6 +240,6 @@ function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
} }
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined { 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('---')) return success(commonMarkText('---'))
} }
@@ -3,6 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts' import { markValues, marksAttribute } from '../block-directive-marks.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { 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) const argument = spellArgument(node, argumentAttribute)
if (argument === undefined) return undefined if (argument === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] 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 if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = spellVocabulary(pairs)
const marks = node.marks ?? [] const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelledPairs) const attributes = spellAttributes(spelledPairs)
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
} }
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined { 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 (value === undefined) return ''
if (typeof value !== 'string' || !isBareToken(value)) return undefined if (typeof value !== 'string' || !isBareToken(value)) return undefined
return ` ${value}` return ` ${value}`
+5 -5
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../../adf/document.ts' 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 type { ConvertErrorPath } from '../../result.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts' import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './inline-line.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 { function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = node.content ?? [] const content = nodeContent(node)
const media = content[0] const media = content[0]
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined
const attrs = media.attrs ?? {} const attrs = nodeAttrs(media)
const alt = attrs['alt'] const alt = attrs['alt']
const url = attrs['url'] const url = attrs['url']
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
@@ -1,9 +1,10 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts' import type { InlineDirective } from '../../adf/inline-directives.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { 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)) 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 { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts' import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts' import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
import { spellDestination, spellTitle } from '../link-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> { 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) { if (holdsContent || node.text !== undefined) {
const held = holdsContent ? 'content' : 'text' const held = holdsContent ? 'content' : 'text'
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path) 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()) { for (const [offset, node] of nodes.entries()) {
const index = firstIndex + offset const index = firstIndex + offset
// spec/flavour.md, Marks. // 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) { if (mark === undefined) {
runs.push({ index, kind: 'plain', node }) runs.push({ index, kind: 'plain', node })
continue continue
@@ -184,7 +185,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
if (!carried.ok) return carried if (!carried.ok) return carried
return success({ segments: [syntax(carried.value)] }) 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) 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) const directive = inlineDirective(node.type)
if (directive === undefined) return emitText(node, context, index, path) 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 if (!empty.ok) return empty
const attributes = spellInlineNodeAttributes(node, directive) const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } }) 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 (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot) 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> { 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 (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 (/\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) 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' 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> { function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
let text = '' let text = ''
for (const node of nodes) { 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 (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 text += node.text
} }
if (/[\n\r]/.test(text)) return success({ carry: range }) 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> { function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
const href = mark.attrs?.['href'] const href = nodeAttrs(mark)['href']
const title = mark.attrs?.['title'] const title = nodeAttrs(mark)['title']
if (typeof href !== 'string') return success({ carry: range }) if (typeof href !== 'string') return success({ carry: range })
const node = nodes[0] 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}>`)] }) if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
const destination = spellDestination(href, path) const destination = spellDestination(href, path)
if (!destination.ok) return destination if (!destination.ok) return destination
+6 -6
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../../adf/document.ts' 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 { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
import { tryPipeCell } from './inline-line.ts' import { tryPipeCell } from './inline-line.ts'
import type { ConvertErrorPath } from '../../result.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()) { for (const [rowIndex, row] of rows.entries()) {
const cells: string[] = [] const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) { 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]) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
if (line === undefined) return undefined if (line === undefined) return undefined
cells.push(line) cells.push(line)
@@ -23,12 +23,12 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
} }
function pipeRows(node: AdfNode): AdfNode[][] | undefined { function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = node.content ?? [] const rows = nodeContent(node)
const columns = (rows[0]?.content ?? []).length const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
if (!carriesOnly(node, []) || columns === 0) return undefined if (!carriesOnly(node, []) || columns === 0) return undefined
const grid: AdfNode[][] = [] const grid: AdfNode[][] = []
for (const [index, row] of rows.entries()) { 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 if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
const wanted = index === 0 ? 'tableHeader' : 'tableCell' const wanted = index === 0 ? 'tableHeader' : 'tableCell'
const paragraphs: AdfNode[] = [] const paragraphs: AdfNode[] = []
@@ -43,7 +43,7 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
} }
function plainParagraph(cell: AdfNode): AdfNode | undefined { function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = cell.content ?? [] const content = nodeContent(cell)
const paragraph = content[0] const paragraph = content[0]
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
return paragraph 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 { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { MarkType } from '../adf/mark-attributes.ts' import type { MarkType } from '../adf/mark-attributes.ts'
import { isMarkType, markAttributes } 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 { spellAttributes, spellVocabulary } from './directive-syntax.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.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 { 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)) 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 { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.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 { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.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 { function slotText(content: readonly AdfNode[]): string | undefined {
if (content.length === 0) return '' if (content.length === 0) return ''
const only = content.length === 1 ? content[0] : undefined 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 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 { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readCarriedInline } from '../opaque-carry.ts' import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.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 { function altText(node: AdfNode): string {
if (node.type === 'hardBreak') return ' ' if (node.type === 'hardBreak') return ' '
const slot = inlineDirective(node.type)?.textAttribute 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 ?? '') 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). // A node cannot carry one mark type twice (AGENTS.md §14).
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] { function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
return nodes.map((node) => { 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] } 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 { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { listBreakName, listBreakSpelling } from '../list-break.ts' import { listBreakName, listBreakSpelling } from '../list-break.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.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> { function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
const only = blocks.length === 1 ? blocks[0] : undefined 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) 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 fromFence = only.language !== ''
const slot = languageSlot(fromFence ? only.language : attribute) const slot = languageSlot(fromFence ? only.language : attribute)
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) { if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
+58 -3
View File
@@ -154,9 +154,19 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph 2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
beside a plain one now refuses every container body that is a directive's — a panel, an beside a plain one now refuses every container body that is a directive's — a panel, an
expand, a table cell — where before 2f the attribute refused the document anyway. expand, a table cell — where before 2f the attribute refused the document anyway.
- [x] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and
Under **3 — `markdownToAdf` (`0.1.0`)**: the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
indented code, loose lists, `*`/`+`
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
and `:hardBreak{}` sit in it — so 3b through 3f answer to their own tests and the one-way
fixtures they land, and 3g is where the first directory reads back. The raw-HTML element
mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a
block, an inline tag, a comment, a processing instruction — is a named error. Input is where
unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards
here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and
3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it.
- [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and - [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and
`src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared `src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared
inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole, inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
@@ -386,6 +396,26 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
`index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the `index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the
last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole
corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces. corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces.
- [x] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
pinned to the version it ships — the one `commonmark-grammar.ts` names for its start
conditions — `corpus/README.md` gaining the kind.
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
concatenated `text`. And a count of the dozen elements the CommonMark subset covers
against the marks and nodes they map to — counting distinct mark types per text node, since
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
`em`. The fixpoint alone is self-consistency a parser
returning the empty document passes, and the text alone one dropping every emphasis; the
counts close both. The exception list stays the maintainer's, and one entry is owed
already: 3h continues a list across the marker change CommonMark splits on, so an example
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a
document `adfToMarkdown` refuses — which is what `corpus/unspellable/` held until 3c, 3e
and 3h landed their answers and emptied it. The permanent ones — a link destination or
title no escape spells, a paragraph opening with a code span — are the exceptions, named
by AGENTS.md §2.
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being - [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
the published identity: `package.json` `name` and `repository`, the Gitea repo and its the published identity: `package.json` `name` and `repository`, the Gitea repo and its
remote, the README title, §6's published-as line, the checkout directory. remote, the README title, §6's published-as line, the checkout directory.
@@ -532,3 +562,28 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real `instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
Firefox majors behind with a publisher that may go quiet while Renovate stays silent. Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
- [ ] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).**
- [x] **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.
## 5 — Ship `0.1.0`
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
public (§6), create the `NPM_TOKEN` secret, confirm the Actions token may push tags — the
publish succeeds and the tag push then reddens the run, though the next push to `main`
retries the tag alone — and open the bump PR that sets `version` to `0.1.0` and drops
`private: true`, the guard against any earlier publish. The bump and the drop go in one
commit: dropping `private` alone publishes `0.0.0`, which also differs from npm's nothing. `0.1.0` is the
markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the
checked-in corpus.
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it —
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed
yet, and §8's pre-1.0 rules cover what the wider proof then finds.
**Shipped** 2026-09-05: `@larvit/adf-codec@0.1.0` published and `v0.1.0` tagged on `8a847de`. Publishing needed a
bypass-2FA token — the account carrying no write-2FA requirement was not enough, npm demanded an
OTP until the token itself bypassed it.
+205 -64
View File
@@ -5,9 +5,11 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
## Milestones ## Milestones
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0`; 4b, 4c and 4d → `0.1.1`; 4, 3k → `0.2.0`; 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`. 6, 7 → `0.3.0`; 9 → TBD; 5e last.
The numbering is the order the work was planned in, not the order it ships. The numbering is the order the work was planned in, not the order it ships. `0.2.0`'s order is settled
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change.
- [x] **0 — Scaffold.** - [x] **0 — Scaffold.**
- [x] **1a — The directive grammar.** - [x] **1a — The directive grammar.**
@@ -29,19 +31,7 @@ The numbering is the order the work was planned in, not the order it ships.
- [x] **2e4 — The carry's fallback triggers.** - [x] **2e4 — The carry's fallback triggers.**
- [x] **2e5 — Combined documents and the collision property.** - [x] **2e5 — Combined documents and the collision property.**
- [x] **2f — The attributes CommonMark cannot hold.** - [x] **2f — The attributes CommonMark cannot hold.**
- [ ] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and - [x] **3 — `markdownToAdf`.**
the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
indented code, loose lists, `*`/`+`
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
and `:hardBreak{}` sit in it — so 3b through 3f answer to their own tests and the one-way
fixtures they land, and 3g is where the first directory reads back. The raw-HTML element
mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a
block, an inline tag, a comment, a processing instruction — is a named error. Input is where
unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards
here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and
3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it.
- [x] **3a — The hierarchy.** - [x] **3a — The hierarchy.**
- [x] **3b — The leaf blocks.** - [x] **3b — The leaf blocks.**
- [x] **3c — The container blocks.** - [x] **3c — The container blocks.**
@@ -52,39 +42,36 @@ The numbering is the order the work was planned in, not the order it ships.
- [x] **3h — The block nodes.** - [x] **3h — The block nodes.**
- [x] **3i — The inline nodes and the marks.** - [x] **3i — The inline nodes and the marks.**
- [x] **3j — The carry and the combinations.** - [x] **3j — The carry and the combinations.**
- [ ] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`, - [x] **3k — The CommonMark spec suite.**
pinned to the version it ships — the one `html-blocks.ts` names for its start
conditions — `corpus/README.md` gaining the kind.
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
concatenated `text`. And a count of the dozen elements the CommonMark subset covers
against the marks and nodes they map to — counting distinct mark types per text node, since
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
`em`. The fixpoint alone is self-consistency a parser
returning the empty document passes, and the text alone one dropping every emphasis; the
counts close both. The exception list stays the maintainer's, and one entry is owed
already: 3h continues a list across the marker change CommonMark splits on, so an example
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
exception and must not be filed as one: valid CommonMark parsing to a document
`adfToMarkdown` refuses is a §2 hole, which is what `corpus/unspellable/` held until 3c,
3e and 3h landed their answers and emptied it.
- [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the - [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
documents a human wrote — the thing that proves 2 and 3 beyond them. Editor-normal (§2) is documents a human wrote — the thing that proves 2 and 3 beyond them.
finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip **Settled** (the maintainer, 2026-09-13): `fast-check` generates and shrinks. The gate runs a
asserts, which over normalized input is the canonical serializer's compact spelling — fixed seed, the properties together adding about five seconds per engine; an environment
rather than staying spelled inline as `?? []` at every reader. The variable raises the runs and randomizes the seed for local digging, and a counterexample
reading half is `nodeContent`/`nodeAttrs`/`nodeMarks` over the ~28 sites spelling it found becomes a round-trip fixture. The generators draw from the node tables — each node's
inline today, which also lifts the branch floor §10 keeps below 100 for exactly those content model and attribute vocabulary as `adf/` records them, which 11 holds to Atlassian's
halves. schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker
Generators emit editor-normal ADF (§2). Real sanitized ADF from live Atlassian APIs lands enters the tests. `toEditorNormal` stays internal. 2e5's collision test goes, since a
here too (§10), in `corpus/real-payloads/`: an ADF→markdown→ADF check with no expected collision already fails the round-trip on the same fixtures; the fixture-duplicate test
markdown, the payloads supplied by the maintainer. This subsumes 2e5's collision property — stays.
a document that round-trips proves no other document shares its spelling — so decide here - [ ] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in
whether that gate stays as the parser-free, faster-failing signal or goes; the half holding `src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks
no fixture duplicates is hygiene rather than a round-trip claim, and stays either way. merged, an empty `attrs`, `marks` or `content` the absent key (§2), and the round-trip
- [ ] **4b — The block walk's retry (`0.1.1`).** `emitBlock` walks a subtree twice wherever tests compare through it. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 49 inline
`?? []`/`?? {}` reads in `src/` (27 `content`, 12 `marks`, 10 `attrs`), and the branch floor
rises to what the suite then measures.
- [ ] **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 `readableBlock` reads it whole and then gives up — a list item whose first line reads back
as a thematic break — and the walk below does the same, so the cost doubles per level: as a thematic break — and the walk below does the same, so the cost doubles per level:
3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It 3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It
@@ -100,7 +87,7 @@ The numbering is the order the work was planned in, not the order it ships.
export persona runs in bulk walks the document twice. Both walks are linear, so this is a export persona runs in bulk walks the document twice. Both walks are linear, so this is a
constant factor rather than 4b's class change, and the parting is what gives depth its own constant factor rather than 4b's class change, and the parting is what gives depth its own
code (§8) — measure before joining them back. code (§8) — measure before joining them back.
- [ ] **4c — The scanning rule's remaining sites (`0.1.1`).** A trailing-anchored regex re-walks - [ ] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
its run from every start position, so an interior whitespace run costs quadratic time rather its run from every start position, so an interior whitespace run costs quadratic time rather
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in
@@ -112,7 +99,7 @@ The numbering is the order the work was planned in, not the order it ships.
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents
nobody typed. nobody typed.
- [ ] **4d — What the gate says while it runs (`0.1.1`).** `ci.sh` runs nine legs and announces - [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
@@ -126,19 +113,41 @@ The numbering is the order the work was planned in, not the order it ships.
rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg
timing is what turns "slow or hung" from a guess into a reading; the browser leg's own timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap. 5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo - [x] **5 — Ship `0.1.0`.**
public (§6), create the `NPM_TOKEN` secret, confirm the Actions token may push tags — the - [ ] **5e — The publish token's deadline.** `0.1.0` published only once the npm
publish succeeds and the tag push then reddens the run, though the next push to `main` token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
retries the tag alone — and open the bump PR that sets `version` to `0.1.0` and drops answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct
`private: true`, the guard against any earlier publish. The bump and the drop go in one publishing around January 2027, leaving them `npm stage publish`, which a maintainer
commit: dropping `private` alone publishes `0.0.0`, which also differs from npm's nothing. `0.1.0` is the approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted
markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned
checked-in corpus. without a date. So the release path has an expiry date and no drop-in successor yet. Revisit:
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it — release moves to the staged publish — which fits badly with publish-on-merge,
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry and is the trade to weigh rather than discover on a red release run.
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed **Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
yet, and §8's pre-1.0 rules cover what the wider proof then finds. 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
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
advertise (marklassian's "12kb") and the only apple-to-apple one, since ours ships tsc's
unminified output and no minifier yet (decide here whether to minify for the build or report
the unminified gzip). A publish/pipeline leg measures it and fails when the README figure
drifts, so the number can't rot; the figure lands in README §The package beside the
"no runtime dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked
221.5 kB, JS gzipped 45.6 kB.
- [ ] **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.
**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] **5a — Rename to `@larvit/adf-codec`.**
- [x] **5b — The consumer's error surface.** - [x] **5b — The consumer's error surface.**
- [x] **5b1 — The error's source position.** - [x] **5b1 — The error's source position.**
@@ -150,8 +159,140 @@ The numbering is the order the work was planned in, not the order it ships.
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10). `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10). The
README's tagline and `package.json`'s `description` regain HTML (5g).
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists. - [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
- [ ] **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.
**Settled** (the maintainer, 2026-09-13): `adfToPlainMarkdown(doc)` is one export whose body
reduces the document ADF→ADF in `src/adf/` and hands the result to `adfToMarkdown`, so §1's
four conversions stay four. Its markdown is the flavour without directives — CommonMark, the
pipe table and `~~` — and it refuses only what the document guard refuses
(`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`); every
other shape degrades. The reduction:
- `panel`, `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`,
`multiBodiedExtension` and `extensionFrame` unwrap to their body blocks in order; `expand`
and `nestedExpand` put their title first as a strong paragraph.
- The CommonMark blocks keep their spelling, attributes dropped.
- `taskList` and `decisionList` become bullet lists, a task item's state leading its text as
`[x]` or `[ ]`, the way Obsidian and GFM write a checkbox: `- [x] Write the spec`.
- `mention` and `status` become their text, `emoji` its text or else its `shortName`, and
`date` its ISO date in UTC (`2026-09-13`).
- `inlineCard`, `blockCard` and `embedCard` become a link to their `url`, dropped when they
carry only `data`; a `mediaSingle` holding an external image stays `![alt](url)`; `media`,
`mediaGroup` and `mediaInline` become 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;
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.
- [ ] **10a — Obsidian's formats.** Look up the formats Obsidian-flavoured markdown adds —
callouts, highlights, embeds, task states and whatever else it writes — and propose which
of the reduction's rows should adopt one; the maintainer settles the proposal, revising the
rows above, before 10b starts (the maintainer's request, 2026-09-13).
- [ ] **10b — The reduction.** The reduction in `src/adf/`, tests first, a test per row as 10a
leaves them.
- [ ] **10c — `adfToPlainMarkdown`.** The export and its README section, and a property over
4.2's generators: it refuses only the guard's codes, and its output reads back through
`markdownToAdf` holding no node or mark the flavour spells as a directive. AGENTS.md §1
records the reduction as what keeps the conversions at four.
- [ ] **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.**
- [ ] **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.
- [ ] **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
from close, nestable without a fence-length discipline, so the `::::`/`:::::` runs and their
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:`. 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/`), 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.
- [ ] **12c — The block form.** Openers and `!adf:/name` closers, leaf vs container by content
model, empty pairs, `listBreak` and the `carry` fence, spelled and read; the fence-length
rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled
and `errors/` re-derived under the shifted codes, and the gate green.
- [ ] **12d — The README, `MIGRATION.md` and the sweep.** The README's examples and error tables
follow, `MIGRATION.md` linked from one README line; docs and fixtures swept for any stale
`::`/`:name` spelling.
- [ ] **13 — The schema's gap attributes (`0.2.0`).** Spell the attributes 11b pins as gaps, in
12's grammar, and empty the list.
**Settled** (the maintainer, 2026-09-13): a link `[text](url "title")` cannot hold takes the
directive mark `!adf:link[text]{attrs}` — one carrying `collection`, `id` or `occurrenceKey`,
or an `href` or `title` no CommonMark escape writes — and a directive link CommonMark could
spell is `unsupported-node-shape`. That leaves `unspellable-link` no cause, so it leaves
`ConvertErrorCode` in `0.2.0`, §8 recording the removal.
- [ ] **13a — `rule` and `layoutSection`.** `rule`'s `color`, `style` and `weight` and
`layoutSection`'s `columnRuleStyle` join their tables and `spec/flavour.md` bullets, with
round-trip fixtures; their gap entries go.
- [ ] **13b — The directive link.** `link` spelled as above in both directions, with a round-trip
fixture per trigger, `spec/flavour.md`'s Marks section following; `unspellable-link` removed
from the code list, its `errors/` fixtures and the CommonMark suite's `unspellable`
exceptions it cures re-derived, and the README's code table and its "not every document
converts back" guarantee following; the gap list is empty.
## The ADF inventory to cover ## The ADF inventory to cover