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 # Working in this repo
Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or The rules for every collaborator, human or agent, and an index of the decisions a reader would
agent. Using the library: `README.md`. What is still to build: `todo.md`; a bare `(28)` cites otherwise relitigate. Using the library: `README.md`. What is still to build: `todo.md`; a bare
that item's entry in `todo-history.md`. `(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 In `docs/decisions.md`:
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).
## 2. The round-trip is the product - Plain markdown is a flavour of the grammar
- The round-trip is the product
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything - Markdown in is a canonical fixpoint
less silently destroys content an editor could not represent, in a document it did not author. - Equality is editor-normal
When losslessness and readability conflict, losslessness wins. - Unknown nodes ride the carry
- Foreign HTML is refused by name
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way - Names stay text
back yields the library's canonical spelling, and that spelling round-trips byte-identically — - Directives under `!adf:`
where there is a way back. CommonMark spells some things the flavour has no escape for — a - CommonMark is a subset
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding - Tables
does not imply a spellable document; - Links
`corpus/commonmark-spec/exceptions.json` names those. - Ids stay site-local
- The HTML dialect
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks - No runtime dependencies
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content - Standards ship as data
array the absent key — the only domain markdown can restore. - fast-check
- Any ES2022 engine
Round-trip equality is a property tested over a corpus, not a claim made in prose. - ESM only
- One built entrypoint
## 3. Unknown input policy - Public on npm
- 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`.
## 7. Nothing about any consumer ## 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 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 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. 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. - 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 - 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 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. `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 round-trip, normalization and error fixtures and the real payloads — over HTTP and converts the round-trip, normalization and error fixtures and the real payloads — the
the `commonmark-spec` sort is the Node suite's to check — which is §6's browser half and the only `commonmark-spec` sort is the Node suite's to check — which is the browser half of
SpiderMonkey there is — the gate's other three engines are two V8s and a JavaScriptCore that is `docs/decisions.md` §Any ES2022 engine and the only SpiderMonkey there is — the gate's other three
not Safari's. engines are two V8s and a JavaScriptCore that is not Safari's. A WebDriver session is what carries a
A WebDriver session is what carries a verdict back out, the driver and the page's server sharing verdict back out, the driver and the page's server sharing one network namespace so each is the
one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such other's `127.0.0.1`; `--headless --screenshot` has no such channel, and loading `dist/index.js` in a
channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser. globals-stripped realm buys one by not running a browser. The leg re-checks the conversions and
The leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed nothing else — each fixture's emitted markdown, its parsed document, its error code — leaving the
document, its error code — leaving the corpus's pairing, uniqueness, source positions and corpus's pairing, uniqueness, source positions and byte-level equality to the Node suite that owns
byte-level equality to the Node suite that owns them. them.
Every leg announces its name and, where a container is in play, the image, before it runs and its 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 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. 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 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 `tsconfig.build.json` builds, measured by `oxlint` since TypeScript 7 is a native compiler
downward. It covers the built files alone, since one ceiling over the tests too would have to be publishing no in-process parser, only the `unstable/` AST surface an out-of-process handshake
their worst, loosening the guard over the shipped code. It guards against drift and never drives a reaches. It is a per-function line ceiling, set at that set's worst and moving only downward. It
refactor, so no cyclomatic rule and no second lint rule join it: neither measure picked out what covers the built files alone, since one ceiling over the tests too would have to be their worst,
nine readers found hard (the comprehension panel, 2026-09-20). Three switches guard a silent green: loosening the guard over the shipped code. It guards against drift and never drives a refactor, so
`IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a config gone missing no cyclomatic rule and no second lint rule join it: neither measure picked out what nine readers
fails the leg instead of falling back to oxlint's own defaults; and `--deny-warnings`, since a rule found hard (the comprehension panel, 2026-09-20). Three switches guard a silent green: `IIFEs:
from a category this config never names arrives as a warning it exits 0 on. 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 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`. 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 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 The tables answer to Atlassian's schema too (`docs/decisions.md` §Standards ship as data): for every
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay node and mark they spell, the attribute names and kinds equal what `full.json` and `stage-0.json`
documentation, since any value round-trips. What the schema holds and the tables do not spell is hold between them. Value sets stay documentation, since any value round-trips. What the schema holds
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until and the tables do not spell is pinned by name — an attribute as a gap, a type as carried — so a
someone spells it or pins it. re-pin adding either goes red until someone spells it or pins it.
## 11. Code rules ## 11. Code rules
@@ -388,12 +318,13 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
## 14. Non-goals ## 14. Non-goals
No network or filesystem I/O, no name→id resolution (§3), no ADF schema No network or filesystem I/O, no name→id resolution (`docs/decisions.md` §Names stay text), no ADF
validation or exported validator — a refusal that keeps the round-trip is not schema validation, schema validation or exported validator — a refusal that keeps the round-trip is not schema
so the one a spelled node carrying the same mark type twice earns stays, and input nesting a validation, so the one a spelled node carrying the same mark type twice earns stays, and input
spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS (§4), no nesting a spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS
streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no (`docs/decisions.md` §The HTML dialect), no streaming APIs, no performance budget past §11's
figure is promised. A CLI is a later goal (`todo.md`), not a non-goal. 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 ## 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) ### 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 - 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 in a release, weighed against every item on that release by the personas and `docs/decisions.md`
outweighs moves later. A weighing no rule decides is asked as a gap. §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, - 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. 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: - 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 **Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
`0.2.0`.** `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: [`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). [`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). 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 ## The errors
An ADF node type this version does not know is not an error: it is carried opaquely and restores 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`, `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 stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
@@ -195,7 +196,7 @@ emit refuses:
## The guarantees ## The guarantees
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely - `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` - 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 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 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. 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, 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. 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: One directory per contract kind, each landing with its milestone:
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that - `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 document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The
by what the fixture exercises. round-trip is the product). Grouped by what the fixture exercises.
- `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document - `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 `markdownToAdf` must build from it, which must in turn emit and read back to itself. The
markdown is not canonical. 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 model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later
milestone may close). milestone may close).
JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. `spec.json` is the vendored, JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys
upstream machine-readable suite, byte-exact from 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 [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 MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
re-serialized by the corpus gate. 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 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, `!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 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 A node no section spells where it stands (`docs/decisions.md` §Unknown nodes ride the carry) — an
to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold unknown type, or a known one whose spelling belongs to the other position — rides as its raw JSON
a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it and restores to a deep-equal node. A carry may hold a node the emitter spells natively: it restores
canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
where it sits: 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 — - **Block position**: a fenced code block with info string `carry`, body = the node's JSON —
two-space indent, object keys sorted. 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 ## Raw HTML in input
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign 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 HTML element mapping (`docs/decisions.md` §Foreign HTML is refused by name; specified with the HTML
has no raw-HTML node, so a construct without a mapping, comments and processing instructions dialect, todo.md milestone 6) — ADF has no raw-HTML node, so a construct without a mapping, comments
included, is an error result naming it. The flavour never emits raw HTML. and processing instructions included, is an error result naming it. The flavour never emits raw
HTML.
## Block nodes ## 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 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 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 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 (`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
sorted), quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal
editor-normal ADF reads an empty attrs object, marks array or content array as the absent key ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
(AGENTS.md §2) — the grammar's empty-`{attrs}` omission already collapses the two spellings. §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 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\"}]"}`. `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 ### Tables
One header row plus plain inline cells is a pipe table; anything richer is the directive form 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 (`docs/decisions.md` §Tables). Precisely: a table emits as a pipe table exactly when the `table`,
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all every row and every cell carry no attrs and no marks, the first row is all `tableHeader` and the
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less, rest all `tableCell`, every row has the header's cell count, and every cell holds exactly one
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the 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 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. 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 **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, CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
`!adf:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text;
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no `markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and (`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
is a named error. 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. !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, 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 outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
reverse. reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
`adfToMarkdown` nests in the order the array holds rather than sorting it — §2's equality `docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each
restores the array, not a set — and opens each spelling once over the longest run of adjacent spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
node the emitter carries, so no emitted carry sits inside a mark spelling. inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its 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 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 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 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 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]. !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 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 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. 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 - **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` `docs/decisions.md` §Plain markdown is a flavour of the grammar, `plainMarkdownToAdf` is
`adfToMarkdown`'s writer, each with the plain flavour set; 10's rows are read and written there, `markdownToAdf`'s parser and `adfToPlainMarkdown` `adfToMarkdown`'s writer, each with the plain
and the lift goes (the maintainer, 2026-09-27). The exports, their refusals and 10's rows stay as flavour set; 10's rows are read and written there, and the lift goes (the maintainer, 2026-09-27).
they are. 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 - **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`. `> 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 [!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 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 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. 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 So the set sorts every element three ways, and that is what `docs/decisions.md` §Foreign HTML is
result naming the element": a container around document content unwraps, content ADF cannot hold refused by name becomes in place of "error result naming the element": a container around document
is an error result naming it, and what is not document content at all drops whole. A comment sorts content unwraps, content ADF cannot hold is an error result naming it, and what is not document
into the second rather than the third because a person wrote those words on purpose. 10's content at all drops whole. A comment sorts into the second rather than the third because a person
"Rejected in the survey" line names raw HTML and comments and does not contradict this: it rejects wrote those words on purpose. 10's "Rejected in the survey" line names raw HTML and comments and
them as spellings the lossy pair writes and reads back, where `plainMarkdownToAdf` reads through does not contradict this: it rejects them as spellings the lossy pair writes and reads back, where
`markdownToAdf`'s parser and so inherits whatever this set accepts. `plainMarkdownToAdf` reads through `markdownToAdf`'s parser and so inherits whatever this set
accepts.
- **7 — Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed - **7 — Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed
through ADF.** CommonMark spec suite runs against `markdownToHtml` from here (§10). The README's through ADF.** CommonMark spec suite runs against `markdownToHtml` from here (§10). The README's
tagline and `package.json`'s `description` regain HTML (5g). tagline and `package.json`'s `description` regain HTML (5g).