36a - §1–§6's decisions move to docs/decisions.md, indexed from AGENTS.md #134

Merged
lilleman merged 2 commits from 36a into main 2026-09-28 00:48:21 +02:00
6 changed files with 310 additions and 180 deletions
+61 -129
View File
@@ -1,106 +1,33 @@
# Working in this repo
Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or
agent. Using the library: `README.md`. What is still to build: `todo.md`; a bare `(28)` cites
that item's entry in `todo-history.md`.
The rules for every collaborator, human or agent, and an index of the decisions a reader would
otherwise relitigate. Using the library: `README.md`. What is still to build: `todo.md`; a bare
`(28)` cites that item's entry in `todo-history.md`.
## 1. ADF is the hub
## Decisions
README Goal 2. The lossy pair is the plain flavour: the markdown grammar's reader and writer with
the flavour set, its spellings — alerts, callouts, task markers, `==` — read and written there, so
a marker line and a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF
ahead of the writer (the maintainer, 2026-09-27; 35).
In `docs/decisions.md`:
## 2. The round-trip is the product
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins.
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 —
where there is a way back. CommonMark spells some things the flavour has no escape for — 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
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
Round-trip equality is a property tested over a corpus, not a claim made in prose.
## 3. Unknown input policy
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So
does a known node no section spells where it stands: a markdown serializer spells a node by type
without checking its position, and refusing loses a document ADF itself keeps in an
`unsupportedBlock`. Where a container's own spelling cannot hold the child it has — a
`bulletList` outside `listItem`, a `codeBlock` outside text — the error result names that
instead.
- Unmappable foreign HTML element: error result naming the element — never a silent drop.
- Bare `@name` / `:smile:` in typed text: stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
## 4. The flavour
- Directives, one grammar for everything markdown lacks, namespaced under `!adf:`: `!adf:panel info`
… `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the one escape. Not
CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and its
fence-length discipline ties a container's opener to its own body, where closing from the opener
nests by itself and leaf versus container falls out of the node's content model.
- Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
- Tables: one header row plus plain inline cells → pipe table; anything richer → directive form.
- Links: `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark
spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot
hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark
spelling would read as a link reference definition — and a directive link CommonMark could spell
is refused (the maintainer, 2026-09-13). No link wraps a link — the bracket form goes literal,
the directive form refused — which is CommonMark's prose where its reference implementation
nests one `<a>` in another (the maintainer, 2026-09-17).
- Identity-bearing nodes carry their ids in attributes; a document is only portable within its
site — accepted.
- The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML
cannot express, text always escaped. No stylesheet ships.
## 5. Dependencies
`dependencies` is empty. A runtime dependency enters only through a decision entry here stating
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
a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own
module, so entity decoding is complete without one. The CommonMark spec suite is the same shape of
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`: `fast-check` earns its place shrinking a failing generated document to the nodes
that break it, `oxlint` measuring §10's size ratchet — TypeScript 7 is a native compiler publishing
no in-process parser, only the `unstable/` AST surface an out-of-process handshake reaches.
## 6. The package contract
- Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source
is ECMAScript and nothing else: no host import, no host global, no DOM. `tsconfig.build.json` is
that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and an
ES2024 method are compile errors here rather than a consumer's crash there. The standard is the line, never an
engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes
and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and
the build are the repo's own,
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
never the higher one those repo-only tools want.
- ESM only — no CommonJS build, no dual-package hazard.
- One entrypoint: built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint —
Node refuses to type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),
so it cannot serve an npm consumer.
- Published to public npmjs as `@larvit/adf-codec`. Public source: the Gitea repo
goes public, LICENSE in place, before the first publish.
- Exact versions: `save-exact=true` in `.npmrc`.
- Plain markdown is a flavour of the grammar
- The round-trip is the product
- Markdown in is a canonical fixpoint
- Equality is editor-normal
- Unknown nodes ride the carry
- Foreign HTML is refused by name
- Names stay text
- Directives under `!adf:`
- CommonMark is a subset
- Tables
- Links
- Ids stay site-local
- The HTML dialect
- No runtime dependencies
- Standards ship as data
- fast-check
- Any ES2022 engine
- ESM only
- One built entrypoint
- Public on npm
## 7. Nothing about any consumer
@@ -191,6 +118,7 @@ types in `src/result.ts` hold its shape.
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
deterministic, so the two builds agree, and promoting an artifact would make the release path
depend on a store that the gate would then have to keep.
- Exact versions: `save-exact=true` in `.npmrc`.
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
- Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
@@ -219,16 +147,16 @@ 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.
A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js`
over HTTP and converts the round-trip, normalization and error fixtures and the real payloads —
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.
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
channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser.
The leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed
document, its error code — leaving the corpus's pairing, uniqueness, source positions and
byte-level equality to the Node suite that owns them.
over HTTP and converts the round-trip, normalization and error fixtures and the real payloads — the
`commonmark-spec` sort is the Node suite's to check — which is the browser half of
`docs/decisions.md` §Any ES2022 engine and the only SpiderMonkey 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 one network namespace so each is the
other's `127.0.0.1`; `--headless --screenshot` has no such channel, and loading `dist/index.js` in a
globals-stripped realm buys one by not running a browser. The leg re-checks the conversions and
nothing else — each fixture's emitted markdown, its parsed document, its error code — leaving the
corpus's pairing, uniqueness, source positions and byte-level equality to the Node suite that owns
them.
Every leg announces its name and, where a container is in play, the image, before it runs and its
elapsed time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as
@@ -244,14 +172,16 @@ functions, and a branch floor that only ever moves upward. It sits below 100 bec
compared against `undefined` — have a half no valid document reaches.
The size ratchet is the other such number, `.oxlintrc.json`'s single rule over the files
`tsconfig.build.json` builds: a per-function line ceiling, set at that set's worst and moving only
downward. It covers the built files alone, since one ceiling over the tests too would have to be
their worst, loosening the guard over the shipped code. It guards against drift and never drives a
refactor, so no cyclomatic rule and no second lint rule join it: neither measure picked out what
nine readers found hard (the comprehension panel, 2026-09-20). Three switches guard a silent green:
`IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a config gone missing
fails the leg instead of falling back to oxlint's own defaults; and `--deny-warnings`, since a rule
from a category this config never names arrives as a warning it exits 0 on.
`tsconfig.build.json` builds, measured by `oxlint` since TypeScript 7 is a native compiler
publishing no in-process parser, only the `unstable/` AST surface an out-of-process handshake
reaches. It is a per-function line ceiling, set at that set's worst and moving only downward. It
covers the built files alone, since one ceiling over the tests too would have to be their worst,
loosening the guard over the shipped code. It guards against drift and never drives a refactor, so
no cyclomatic rule and no second lint rule join it: neither measure picked out what nine readers
found hard (the comprehension panel, 2026-09-20). Three switches guard a silent green: `IIFEs:
true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a config gone missing fails the
leg instead of falling back to oxlint's own defaults; and `--deny-warnings`, since a rule from a
category this config never names arrives as a warning it exits 0 on.
The corpus, all checked in: hand-built fixtures per node and combination; real ADF Atlassian's
editor wrote; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
@@ -269,11 +199,11 @@ of a bullet; fenced examples are skipped. It guards the attributes alone: nodes
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
both answer to the round-trip corpus and to nothing else where a node has no fixture.
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
documentation, since any value round-trips. What the schema holds and the tables do not spell is
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
someone spells it or pins it.
The tables answer to Atlassian's schema too (`docs/decisions.md` §Standards ship as data): 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
@@ -388,12 +318,13 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
## 14. Non-goals
No network or filesystem I/O, no name→id resolution (§3), no ADF schema
validation or exported validator — a refusal that keeps the round-trip is not schema validation,
so the one a spelled node carrying the same mark type twice earns stays, and input nesting a
spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS (§4), no
streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no
figure is promised. A CLI is a later goal (`todo.md`), not a non-goal.
No network or filesystem I/O, no name→id resolution (`docs/decisions.md` §Names stay text), no ADF
schema validation or exported validator — a refusal that keeps the round-trip is not schema
validation, so the one a spelled node carrying the same mark type twice earns stays, and input
nesting a spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS
(`docs/decisions.md` §The HTML dialect), no streaming APIs, no performance budget past §11's
scanning rule — nothing here is tuned, and no figure is promised. A CLI is a later goal (`todo.md`),
not a non-goal.
## 15. The working loop
@@ -441,8 +372,9 @@ The verdict lands in the item it settles (the maintainer, 2026-09-25).
### Rules the loop has settled (the maintainer, 2026-09-18)
- A finding inside the chunk's item is fixed in the chunk. Outside it, a new `todo.md` item, always
in a release, weighed against every item on that release by the personas and §1–§3 — an item it
outweighs moves later. A weighing no rule decides is asked as a gap.
in a release, weighed against every item on that release by the personas and `docs/decisions.md`
§Plain markdown is a flavour of the grammar through §Names stay text — an item it outweighs moves
later. A weighing no rule decides is asked as a gap.
- A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks,
naming the number it can reach. A number the code needs and no rule states is a gap.
- An earliest release with no items left and nothing shipped toward it is planned as the chunk:
+6 -4
View File
@@ -5,7 +5,8 @@ an HTML dialect.
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
`0.2.0`.**
Plan: `todo.md`. Decisions: `AGENTS.md`. Changes:
Plan: `todo.md`. Decisions:
[`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md). Changes:
[`CHANGELOG.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/CHANGELOG.md). The lossless flavour's grammar:
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md).
@@ -143,7 +144,7 @@ saving what this pair read replaces mentions, attachments and macros with text.
## The errors
An ADF node type this version does not know is not an error: it is carried opaquely and restores
unchanged (AGENTS.md §3).
unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
`ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`,
stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
@@ -195,7 +196,7 @@ emit refuses:
## The guarantees
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
(AGENTS.md §3).
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
@@ -234,4 +235,5 @@ emit refuses:
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it.
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: `AGENTS.md` §5–6.
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
ES2022 engine to §Public on npm.
+4 -4
View File
@@ -3,8 +3,8 @@
One directory per contract kind, each landing with its milestone:
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that
document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped
by what the fixture exercises.
document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The
round-trip is the product). Grouped by what the fixture exercises.
- `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document
`markdownToAdf` must build from it, which must in turn emit and read back to itself. The
markdown is not canonical.
@@ -19,8 +19,8 @@ One directory per contract kind, each landing with its milestone:
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. `spec.json` is the vendored,
upstream machine-readable suite, byte-exact from
JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), 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.
+185
View File
@@ -0,0 +1,185 @@
# Decisions
## Plain markdown is a flavour of the grammar
2026-09-27, the maintainer. Goal 2. Valid while the plain flavour's spellings are ones the markdown
grammar can read and write.
The lossy pair is the plain flavour: the markdown grammar's reader and writer with the flavour set,
its spellings — alerts, callouts, task markers, `==` — read and written there, so a marker line and
a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF ahead of the writer.
## The round-trip is the product
2026-08-23, the maintainer. Goal 1. Valid while a consumer saves back through the lossless pair.
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins. Round-trip equality is a property
tested over a corpus, not a claim made in prose.
## Markdown in is a canonical fixpoint
2026-08-23, the maintainer. Goals 1 and 3. Valid while markdown input may be written by hand.
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 —
where there is a way back. CommonMark spells some things the flavour has no escape for — 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.
## Equality is editor-normal
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this
merges.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
## Unknown nodes ride the carry
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF
holds nodes, or node positions, this library does not spell.
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
a known node no section spells where it stands: a markdown serializer spells a node by type without
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`.
Where a container's own spelling cannot hold the child it has — a `bulletList` outside `listItem`,
a `codeBlock` outside text — the error result names that instead.
## Foreign HTML is refused by name
2026-08-23, the maintainer. Goals 1 and 6. Valid until the HTML dialect's element set lands
(`todo.md`, 6).
An unmappable foreign HTML element is an error result naming the element — never a silent drop.
## Names stay text
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O.
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids is the consumer's job.
## Directives under `!adf:`
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 3 and 4. Valid while prose does not
write `!adf:`.
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
`!adf:panel info` … `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the
one escape. Not CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and
its fence-length discipline ties a container's opener to its own body, where closing from the
opener nests by itself and leaf versus container falls out of the node's content model.
## CommonMark is a subset
2026-08-23, the maintainer. Goal 3. Valid while prose rarely writes the shapes the carve-outs claim.
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
## Tables
2026-08-23, the maintainer. Goal 4. Valid while a pipe table holds only one header row and inline
cells.
One header row plus plain inline cells → pipe table; anything richer → directive form.
## Links
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 4. Valid while CommonMark's link
syntax is what readers edit.
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot hold, an `href` or
`title` no canonical escape spells, a paragraph opening whose CommonMark spelling would read as a
link reference definition — and a directive link CommonMark could spell is refused. No link wraps a
link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
where its reference implementation nests one `<a>` in another.
## Ids stay site-local
2026-08-23, the maintainer. Goal 1. Valid while ADF ids are minted per site.
Identity-bearing nodes carry their ids in attributes; a document is only portable within its site —
accepted.
## The HTML dialect
2026-08-23, the maintainer. Goals 4 and 7. Valid while HTML output is read by consumers styling it
themselves.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
for what HTML cannot express, text always escaped. No stylesheet ships.
## No runtime dependencies
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each
job a dependency would.
`dependencies` is empty. A runtime dependency enters only through an entry here stating 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.
## Standards ship as data
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
3 and 7. Valid while each table is fixed data a dependency would only wrap.
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
character references ship packed in their own module, so entity decoding is complete without one.
The CommonMark spec suite is the same shape of 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.
## fast-check
2026-09-14, the maintainer. Goal 1. Valid while a failing generated document needs shrinking by
hand otherwise.
`fast-check` earns its place as a devDependency shrinking a failing generated document to the
nodes that break it.
## Any ES2022 engine
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share.
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
`tsconfig.build.json` is that gate, typechecking and emitting the shipped files alone, so
`node:fs`, `process` and an ES2024 method are compile errors here rather than a consumer's crash
there. The standard is the line, never an engine list: one implementing it in part — Hermes is the
live doubt, on the Unicode property escapes emphasis matching leans on and on lookbehind — is out
of scope rather than a bug. Node's test runner, the corpus reads and the build are the repo's own,
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
never the higher one those repo-only tools want.
## ESM only
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules.
No CommonJS build, no dual-package hazard.
## One built entrypoint
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`.
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
an npm consumer.
## Public on npm
2026-08-23, the maintainer. The Audience. Valid while the audience installs from public
npm.
Published to public npmjs as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
LICENSE in place, before the first publish.
+34 -32
View File
@@ -154,15 +154,15 @@ outside code spans and code blocks, `\!adf:` in input yields the literal text.
naming no open container or a node other than the innermost open one, a leaf given a body, an
`!adf:` completing no directive, an inline `[content]` or `{attrs}` left unclosed at end of line,
unparseable or duplicate-keyed attrs, invalid JSON in an opaque carry. Never a silent literal-text
fallback — a typo that reparses as prose is the silent loss §2 refuses.
fallback — a typo that reparses as prose is the silent loss the round-trip refuses.
## The opaque carry (AGENTS.md §3)
## The opaque carry
A node no section spells where it stands — an unknown type, or a known one whose spelling belongs
to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold
a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it
canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting
where it sits:
A node no section spells where it stands (`docs/decisions.md` §Unknown nodes ride the carry) — an
unknown type, or a known one whose spelling belongs to the other position — rides as its raw JSON
and restores to a deep-equal node. A carry may hold a node the emitter spells natively: it restores
unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
product). Block and inline positions canonicalize differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `carry`, body = the node's JSON —
two-space indent, object keys sorted.
@@ -177,9 +177,10 @@ In block-directive position `!adf:carry` is a named error — the carry's block
## Raw HTML in input
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign
HTML element mapping (AGENTS.md §3; specified with the HTML dialect, todo.md milestone 6) — ADF
has no raw-HTML node, so a construct without a mapping, comments and processing instructions
included, is an error result naming it. The flavour never emits raw HTML.
HTML element mapping (`docs/decisions.md` §Foreign HTML is refused by name; specified with the HTML
dialect, todo.md milestone 6) — ADF has no raw-HTML node, so a construct without a mapping, comments
and processing instructions included, is an error result naming it. The flavour never emits raw
HTML.
## Block nodes
@@ -192,10 +193,11 @@ have written as CommonMark is a named error.
Each section lists attributes as `name (type)`. A parenthesized value set documents what real
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys
sorted), quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty;
editor-normal ADF reads an empty attrs object, marks array or content array as the absent key
(AGENTS.md §2) — the grammar's empty-`{attrs}` omission already collapses the two spellings.
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two
spellings.
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
`json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
@@ -300,10 +302,10 @@ other text, or one carrying a title, is a named error: `mediaInline` carries a m
### Tables
One header row plus plain inline cells is a pipe table; anything richer is the directive form
(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less,
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the
(`docs/decisions.md` §Tables). Precisely: a table emits as a pipe table exactly when the `table`,
every row and every cell carry no attrs and no marks, the first row is all `tableHeader` and the
rest all `tableCell`, every row has the header's cell count, and every cell holds exactly one
attr-less, mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the
inline layer spells as syntax: a code span, an autolink, a link destination or title. A `|` there
takes the directive form instead. A pipe table parses back to exactly that shape.
@@ -449,14 +451,14 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000
```
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where
CommonMark strips or refuses one — a block's inline content edges, either side of a line break,
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled
`!adf:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
is a named error.
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text;
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
carries plainly — is a named error.
```
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
@@ -488,11 +490,11 @@ the directive form, open to no literal reading, is a named error.
A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
reverse.
`adfToMarkdown` nests in the order the array holds rather than sorting it — §2's equality
restores the array, not a set — and opens each spelling once over the longest run of adjacent
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every
node the emitter carries, so no emitted carry sits inside a mark spelling.
reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
`docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs
@@ -502,7 +504,7 @@ close where the run sits (`un**-real**istic`), or one CommonMark's matching pair
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
mark spelling is a named error in input: the carry restores its node exactly, marks included
(AGENTS.md §3).
(`docs/decisions.md` §Unknown nodes ride the carry).
```
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
+20 -11
View File
@@ -7,11 +7,19 @@
one no goal serves is proposed as a goal and asked. Sources: `AGENTS.md`'s body, the settled text
in this file's items, and `todo-history.md`, deleted with the bare `(28)` citations into it once
nothing cites it. Split by `AGENTS.md` section where one chunk is too big.
- **36b — Move §8, §9 and §14's decisions.** The code list's rules, release automation and the
non-goals.
- **36c — Move §10 and §11's decisions.** The engine legs, floors, size ratchet, bounds, spellings
and layout; the style rules stay working rules.
- **36d — Move the settled text in `todo.md`'s items and §15's dated rules, and point §15's
"the rule that closes it, landing here" at `docs/decisions.md`.** `AGENTS.md`'s sections are
renumbered once only working rules remain, their citations with them.
- **36e — Move `todo-history.md`'s decisions, re-point its citations and delete it.**
- **35 — Read and write plain markdown as a flavour of the markdown grammar.** Per Goal 2 and
AGENTS.md §1, `plainMarkdownToAdf` is `markdownToAdf`'s parser and `adfToPlainMarkdown`
`adfToMarkdown`'s writer, each with the plain flavour set; 10's rows are read and written there,
and the lift goes (the maintainer, 2026-09-27). The exports, their refusals and 10's rows stay as
they are.
`docs/decisions.md` §Plain markdown is a flavour of the grammar, `plainMarkdownToAdf` is
`markdownToAdf`'s parser and `adfToPlainMarkdown` `adfToMarkdown`'s writer, each with the plain
flavour set; 10's rows are read and written there, and the lift goes (the maintainer, 2026-09-27).
The exports, their refusals and 10's rows stay as they are.
- **35a — Read the plain flavour in the parser and delete the lift.** 10's rows are read while
parsing, and `plain-lift.ts` is deleted, its tests reading through `plainMarkdownToAdf`. `>
[!faq]- Why?` with the body on the next `>` line reads to an expand titled `Why?` whose body
@@ -49,13 +57,14 @@
the document ever saw, so nothing is lost; unwrapping them would put `alert(1)` on the page as
prose. A `style` attribute is a separate question — `textColor` and `backgroundColor` are the
marks it could reach — and is not read at `0.2.0`, the work outweighing what it buys.
So the set sorts every element three ways, and that is what AGENTS.md §3 gains in place of "error
result naming the element": a container around document content unwraps, content ADF cannot hold
is an error result naming it, and what is not document content at all drops whole. A comment sorts
into the second rather than the third because a person wrote those words on purpose. 10's
"Rejected in the survey" line names raw HTML and comments and does not contradict this: it rejects
them as spellings the lossy pair writes and reads back, where `plainMarkdownToAdf` reads through
`markdownToAdf`'s parser and so inherits whatever this set accepts.
So the set sorts every element three ways, and that is what `docs/decisions.md` §Foreign HTML is
refused by name becomes in place of "error result naming the element": a container around document
content unwraps, content ADF cannot hold is an error result naming it, and what is not document
content at all drops whole. A comment sorts into the second rather than the third because a person
wrote those words on purpose. 10's "Rejected in the survey" line names raw HTML and comments and
does not contradict this: it rejects them as spellings the lossy pair writes and reads back, where
`plainMarkdownToAdf` reads through `markdownToAdf`'s parser and so inherits whatever this set
accepts.
- **7 — Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed
through ADF.** CommonMark spec suite runs against `markdownToHtml` from here (§10). The README's
tagline and `package.json`'s `description` regain HTML (5g).