5b4: the README's consumer surface
CI / gate (push) Successful in 8s

This commit is contained in:
2026-09-03 19:50:47 +02:00
parent c385106b2e
commit 0cf3bfaa39
4 changed files with 106 additions and 45 deletions
+73 -20
View File
@@ -25,38 +25,91 @@ Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compos
```ts ```ts
adfToMarkdown(doc: AdfDocument): Result<string> adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): Result<AdfDocument, ParseError> markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string>
htmlToAdf(html: string): Result<AdfDocument, ParseError>
markdownToHtml(markdown: string): Result<string> // via ADF
htmlToMarkdown(html: string): Result<string> // via ADF
isAdfDocument(v: unknown): v is AdfDocument isAdfDocument(v: unknown): v is AdfDocument
adfToHtml(doc: AdfDocument): Result<string> // 0.3.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.3.0
markdownToHtml(markdown: string): Result<string> // 0.3.0, via ADF
htmlToMarkdown(html: string): Result<string> // 0.3.0, via ADF
``` ```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
`ConvertError` is `{ code, message, path, position? }`: a code from a closed set, the path of the
node it names from the document root, and — parsing — a `{ line, offset }` into the string passed
in, at the start of the line the refused block begins on, `line` counted from 1.
Parsing a source always names where in it the refusal sits, so `markdownToAdf` and `htmlToAdf` ## The errors
return the narrowed `ParseError`, whose `position` is there to read without a guard. Every other
direction emits, from a document with no source behind it, and carries `path` alone — including An ADF node type this version does not know is not an error: it rides both formats opaquely and
`markdownToHtml` and `htmlToMarkdown`, where half the refusals come from the emit half. restores unchanged (AGENTS.md §3).
`ConvertError` is `{ code, message, path, position? }`. `code` is stable across minors and safe to
`switch` on exhaustively with no `default`; `message` is free text and may change in any release.
A parse always names a position, so `markdownToAdf` returns `ParseError` and its `position` reads
without a guard; an emit reads no source and carries `path` alone; one handler typed on
`ConvertError` takes both, which is what the composed `markdownToHtml` and `htmlToMarkdown` hand
back. `path` is the node's place from the document root, alternating `'content'` and an index, so
`path.map((step) => '/' + step).join('')` is a JSON Pointer at the node — the empty path being the
document itself.
`position` is `{ line, offset }` into the string passed in: `line` counted from 1, `offset` a
UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte offset. It points at
or before the refusal — currently the start of the line the enclosing block begins on; a later
minor may narrow that, never widen it.
Parsing — `markdownToAdf`, and `htmlToAdf` at `0.3.0`:
| Code | Fires when | What you can do |
| --- | --- | --- |
| `malformed-directive` | a `:::` block or `:name[…]` inline directive the grammar cannot read — an unclosed fence or `[content]`, `{attrs}` out of order or duplicated, invalid JSON in an `adf` carry | write the spelling the message names, or escape the line — `\:::` for a block, `\:` for an inline one — to keep it literal text |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; a backslash before a pipe keeps it literal text |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the colon; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.3.0` |
| `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title, or write the `mediaSingle` directive form |
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.3.0`:
| Code | Fires when | What you can do |
| --- | --- | --- |
| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused |
| `unsupported-document-version` | the document's `version` is not 1 | convert a version-1 document — no markdown spelling carries another |
Either direction — a parse reaches the emitter's own refusals too, asking it which CommonMark
spelling a node takes:
| Code | Fires when | What you can do |
| --- | --- | --- |
| `unspellable-character` | text or a code block holds a carriage return or a null character, which CommonMark rewrites wherever it sits | strip or replace the character; no escape carries it through the round-trip |
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-link` | a link `href` or `title` holds what no canonical escape spells — a backslash, a newline, a control character, an entity reference, an angle bracket beside a space | percent-encode the destination (`%5C` for the backslash, `%26` for the `&` that opens the entity), or drop the title |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks or a carried node's JSON nest past 500 levels | flatten the document; the limit is fixed, and it is what stands between a deep document and a stack overflow |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
## 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). (AGENTS.md §3).
- `htmlToAdf(adfToHtml(doc))` equals `doc` — fidelity HTML cannot express rides `data-*` - Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML below, with three
attributes. carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed
- Plain CommonMark is valid input to `markdownToAdf`, with three carve-outs — literal text (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own
matching directive, pipe-table or strikethrough syntax is claimed (escapable — title-less paragraph; mid-text and titled images are error results. Converting back yields the
`spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph; library's canonical spelling, which round-trips byte-identically.
mid-text and titled images are error results. Converting back yields the library's canonical - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
spelling, which round-trips byte-identically. processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`.
- Foreign HTML maps a documented element set; an unmappable element is an error, never a silent - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
drop. Well-formed HTML only — no tag-soup recovery. a carriage return, a link destination or title no canonical escape spells, a paragraph line
beginning with a code span whose backticks read back as a fence. Show the refusal and keep the
document read-only; saving markdown you could not produce is the loss the round-trip exists to
stop.
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error
result rather than the prose it reads as, and an alignment colon in the delimiter row is an
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
input.
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
is the `taskList` directive.
- A document nested deeper than 500 levels is an error result, not a stack overflow. - A document nested deeper than 500 levels is an error result, not a stack overflow.
- The emitted formats are semver surface (AGENTS.md §8). - The emitted formats are semver surface (AGENTS.md §8).
- **`0.3.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
`data-*` attributes. Foreign HTML maps a documented element set, an unmappable element is an
error, and well-formed HTML only — no tag-soup recovery.
## Who it is for ## Who it is for
+4 -3
View File
@@ -1,9 +1,10 @@
# The markdown flavour # The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset with three carve-outs: literal text that matches directive syntax below or CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that
reads as a pipe table is claimed by the flavour, and a matched `~~` pair spells `strike` (escape the matches directive syntax below or reads as a pipe table is claimed by the flavour, and a matched
`:`, `|` or `~` to keep it literal) — and one gap: a CommonMark image fits only as its own `~~` pair spells `strike` (escape the `:`, `|` or `~` to keep it literal) — and one gap: a
CommonMark image fits only as its own
title-less paragraph — mid-text and titled images are named errors. The emitted form is contract title-less paragraph — mid-text and titled images are named errors. The emitted form is contract
(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below. (AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below.
+27
View File
@@ -392,6 +392,11 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
**Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is **Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is
the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the
hub, not the formats around it. hub, not the formats around it.
- [x] **5b — The consumer's error surface (`0.1.0`).** A product-owner read of the public surface
found the error result legible to the library and opaque to the consumer holding it, and the
README documenting no part of it. The sub-items are that read's answers, and they land before
5 because §8 freezes the code list at `0.1.0` and 5b4's table is what reads the list before
the freeze closes it.
- [x] **5b1 — The error's source position.** A parse error names an ADF path into a document the - [x] **5b1 — The error's source position.** A parse error names an ADF path into a document the
caller does not hold yet — `unmappable-html` at `["content", 5]` for a `<span>` on line caller does not hold yet — `unmappable-html` at `["content", 5]` for a `<span>` on line
12 — and no coordinate into the markdown string it passed in. `ConvertError` gains an 12 — and no coordinate into the markdown string it passed in. `ConvertError` gains an
@@ -455,3 +460,25 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
so the emitter escapes that line's first character rather than refusing the document. so the emitter escapes that line's first character rather than refusing the document.
`spec/flavour.md` had two directive blocks inside a container taking no blank line; the `spec/flavour.md` had two directive blocks inside a container taking no blank line; the
rule both directions keep is that a pair holding one takes none. rule both directions keep is that a pair holding one takes none.
- [x] **5b4 — The README's consumer surface.** §8 invites an exhaustive switch on `code` and no
code name appears in the README, so it gains a table — code, when it fires, what the
consumer does — grouped by direction, over the thirteen names 5b3 settled. Four things a
reader who has not opened the code cannot know: raw
HTML is core CommonMark and every construct in input is an error until `0.3.0`, which the
guarantees' "three carve-outs and one gap" denies and which is the bot and LLM personas'
most common failure; `adfToHtml`, `htmlToAdf`, `markdownToHtml` and `htmlToMarkdown` sit
unmarked in the code block people copy from, as do the two HTML guarantee bullets, and take
a `0.3.0` mark or leave the block; `adfToMarkdown` is partial on valid ADF — a text node
holding a carriage return, a link destination no canonical escape spells — which the viewer
persona needs told along with
what to do about it; and GFM past tables and strikethrough is literal text, task lists
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
`message` is free text. The type-level surface freezes at the same moment and gets the same
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
where a direction reads a source, and `ConvertFault` staying internal — the README table
names the shapes a consumer switches on, so the two audits are one.
The direction grouping is read off the call sites rather than the code prefixes, which do
not partition by direction: the parser asks the emitter which CommonMark spelling a node
takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one.
The trailing pipe of a pipe-table row is optional in input, not required; the leading one
is what every row must carry.
+2 -22
View File
@@ -140,31 +140,11 @@ The numbering is the order the work was planned in, not the order it ships.
`0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows `0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows
that sentence or lists them. that sentence or lists them.
- [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5a — Rename to `@larvit/adf-codec`.**
- [ ] **5b — The consumer's error surface (`0.1.0`).** A product-owner read of the public surface - [x] **5b — The consumer's error surface.**
found the error result legible to the library and opaque to the consumer holding it, and the
README documenting no part of it. The sub-items are that read's answers, and they land before
5 because §8 freezes the code list at `0.1.0` and 5b4's table is what reads the list before
the freeze closes it.
- [x] **5b1 — The error's source position.** - [x] **5b1 — The error's source position.**
- [x] **5b2 — The error messages.** - [x] **5b2 — The error messages.**
- [x] **5b3 — The code list and the flavour's gaps.** - [x] **5b3 — The code list and the flavour's gaps.**
- [ ] **5b4 — The README's consumer surface.** §8 invites an exhaustive switch on `code` and no - [x] **5b4 — The README's consumer surface.**
code name appears in the README, so it gains a table — code, when it fires, what the
consumer does — grouped by direction, over the thirteen names 5b3 settled. Four things a
reader who has not opened the code cannot know: raw
HTML is core CommonMark and every construct in input is an error until `0.3.0`, which the
guarantees' "three carve-outs and one gap" denies and which is the bot and LLM personas'
most common failure; `adfToHtml`, `htmlToAdf`, `markdownToHtml` and `htmlToMarkdown` sit
unmarked in the code block people copy from, as do the two HTML guarantee bullets, and take
a `0.3.0` mark or leave the block; `adfToMarkdown` is partial on valid ADF — a text node
holding a carriage return, a link destination no canonical escape spells — which the viewer
persona needs told along with
what to do about it; and GFM past tables and strikethrough is literal text, task lists
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
`message` is free text. The type-level surface freezes at the same moment and gets the same
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
where a direction reads a source, and `ConvertFault` staying internal — the README table
names the shapes a consumer switches on, so the two audits are one.
- [ ] **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` /