From 0cf3bfaa39a217be260d653d78cd159695a0cbf5 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 19:50:47 +0200 Subject: [PATCH 1/2] 5b4: the README's consumer surface --- README.md | 93 ++++++++++++++++++++++++++++++++++++++----------- spec/flavour.md | 7 ++-- todo-history.md | 27 ++++++++++++++ todo.md | 24 ++----------- 4 files changed, 106 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index cdfb90f..5fc8692 100644 --- a/README.md +++ b/README.md @@ -25,38 +25,91 @@ Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compos ```ts adfToMarkdown(doc: AdfDocument): Result markdownToAdf(markdown: string): Result -adfToHtml(doc: AdfDocument): Result -htmlToAdf(html: string): Result -markdownToHtml(markdown: string): Result // via ADF -htmlToMarkdown(html: string): Result // via ADF isAdfDocument(v: unknown): v is AdfDocument + +adfToHtml(doc: AdfDocument): Result // 0.3.0 +htmlToAdf(html: string): Result // 0.3.0 +markdownToHtml(markdown: string): Result // 0.3.0, via ADF +htmlToMarkdown(html: string): Result // 0.3.0, via ADF ``` `Result` 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` -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 -`markdownToHtml` and `htmlToMarkdown`, where half the refusals come from the emit half. +## The errors + +An ADF node type this version does not know is not an error: it rides both formats opaquely and +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 - `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely (AGENTS.md §3). -- `htmlToAdf(adfToHtml(doc))` equals `doc` — fidelity HTML cannot express rides `data-*` - attributes. -- Plain CommonMark is valid input to `markdownToAdf`, 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 its own title-less paragraph; - mid-text and titled images are error results. Converting back yields the library's canonical - spelling, which round-trips byte-identically. -- Foreign HTML maps a documented element set; an unmappable element is an error, never a silent - drop. Well-formed HTML only — no tag-soup recovery. +- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML below, 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 its own + title-less paragraph; mid-text and titled images are error results. Converting back yields the + library's canonical spelling, which round-trips byte-identically. +- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a + processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`. +- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding + 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. - 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 diff --git a/spec/flavour.md b/spec/flavour.md index a4dd3a1..dd653f8 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -1,9 +1,10 @@ # The markdown flavour 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 -reads as a pipe table is claimed by the flavour, and a matched `~~` pair spells `strike` (escape the -`:`, `|` or `~` to keep it literal) — and one gap: a CommonMark image fits only as its own +CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that +matches directive syntax below or reads as a pipe table is claimed by the flavour, and a matched +`~~` 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 (AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below. diff --git a/todo-history.md b/todo-history.md index da8df92..0d46007 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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 the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the 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 caller does not hold yet — `unmappable-html` at `["content", 5]` for a `` on line 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. `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. + - [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. diff --git a/todo.md b/todo.md index 6c48e59..e8c532a 100644 --- a/todo.md +++ b/todo.md @@ -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 that sentence or lists them. - [x] **5a — Rename to `@larvit/adf-codec`.** -- [ ] **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] **5b — The consumer's error surface.** - [x] **5b1 — The error's source position.** - [x] **5b2 — The error messages.** - [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 - 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. + - [x] **5b4 — The README's consumer surface.** - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / -- 2.52.0 From 9af68c30ad697b3aaa58adbf7c7f5dca3f8caeb6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 20:02:55 +0200 Subject: [PATCH 2/2] 5b4: the review's consolidated fixes to the README's consumer surface --- README.md | 53 +++++++++++++++++++++++++++++++++++++---------------- todo.md | 4 +++- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5fc8692..b310ced 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,22 @@ represent. ## The shape +```sh +npm install @larvit/adf-codec +``` + +```ts +import { markdownToAdf } from '@larvit/adf-codec' + +const result = markdownToAdf('# Release notes\n\n:::panel info\nShipped on Tuesday.\n:::\n') +if (result.ok) { + send(result.value) +} else { + const { code, message, path, position } = result.error + console.error(`${code} at line ${position.line}, ${path.map((step) => '/' + step).join('')}: ${message}`) +} +``` + Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compose through it. ```ts @@ -37,17 +53,21 @@ htmlToMarkdown(html: string): Result // 0.3.0, via ADF ## The errors -An ADF node type this version does not know is not an error: it rides both formats opaquely and -restores unchanged (AGENTS.md §3). +An ADF node type this version does not know is not an error: it is carried opaquely and 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. +`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 +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. + +A call reports the first refusal in document order and stops, so a document with several surfaces +them one per call. Every refusal is deterministic — there is no I/O anywhere — so a retry returns +the identical error: fix the input, or set the document aside. `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 @@ -59,20 +79,21 @@ 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 | +| `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; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | | `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 | +| `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title | 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 | +| `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change | -Either direction — a parse reaches the emitter's own refusals too, asking it which CommonMark -spelling a node takes: +Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it +which CommonMark spelling a node takes — so the two rows above are not the measure of how often an +emit refuses: | Code | Fires when | What you can do | | --- | --- | --- | @@ -80,7 +101,7 @@ spelling a node takes: | `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-nesting-depth` | blocks, marks or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `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 diff --git a/todo.md b/todo.md index e8c532a..d6b6968 100644 --- a/todo.md +++ b/todo.md @@ -138,7 +138,9 @@ The numbering is the order the work was planned in, not the order it ships. yet, and §8's pre-1.0 rules cover what the wider proof then finds. 3k's exception list landing after the release leaves the README's canonical-fixpoint sentence claiming more than `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](http://a\b)` is one to narrow it against: it parses + cleanly and refuses on the way back, so a successful parse does not imply a spellable + document. - [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5b — The consumer's error surface.** - [x] **5b1 — The error's source position.** -- 2.52.0