3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Answer product-owner round 1: the README drops the roadmap, spells every lossless form, alternates list markers truly, checks results in its examples, and lists each call's refusals once; item 105 pins the portable writer's codes

This commit is contained in:
2026-10-11 18:49:15 +02:00
committed by Lillem4n
parent 3b5fed9a2c
commit ab01c5f7ae
2 changed files with 74 additions and 74 deletions
+66 -73
View File
@@ -12,7 +12,8 @@ products store as ADF.
document they started from — panels, mentions, statuses and node types this version does not know
included.
- **Markdown in:** `portableMarkdownToAdf` reads a bot's or a model's markdown, including `~~` and
pipe tables whose every row opens with a pipe. `commonMarkToAdf` reads strict CommonMark as its
pipe tables whose every row opens with a pipe and whose delimiter row holds no alignment colon.
`commonMarkToAdf` reads strict CommonMark as its
spec says: nothing becomes a table, task or panel.
- **Portable markdown out, lossy:** `adfToPortableMarkdown` writes the markdown that GitHub, GitLab
and Obsidian render — GitHub Flavored Markdown's alerts, task lists and pipe tables, Obsidian
@@ -28,13 +29,15 @@ products store as ADF.
npm install @larvit/adf-codec
```
ESM only, Node 18 or later ([The package](#the-package)).
ESM only, Node 18 or later ([The package](#the-package)). Upgrading from `0.1.0`:
[MIGRATION.md](https://github.com/larvit/adf-codec/blob/main/MIGRATION.md).
## Quick start
### ADF to markdown
Full reference: [The calls](#the-calls) and [The lossless flavour](#the-lossless-flavour).
Full reference: [The calls](#the-calls), [The lossless flavour](#the-lossless-flavour) and
[Portable markdown](#portable-markdown).
```ts
import { adfToLosslessMarkdown, adfToPortableMarkdown, type AdfDocument } from '@larvit/adf-codec'
@@ -52,13 +55,13 @@ const doc: AdfDocument = {
}
const lossless = adfToLosslessMarkdown(doc)
// lossless.value:
if (lossless.ok) console.log(lossless.value)
// !adf:panel warning
// Check the collation before importing.
// !adf:/panel
const portable = adfToPortableMarkdown(doc)
// portable.value, a GitHub alert:
if (portable.ok) console.log(portable.value)
// > [!WARNING]
// >
// > Check the collation before importing.
@@ -106,15 +109,18 @@ Full reference: [The guarantees](#the-guarantees).
import { adfToLosslessMarkdown, losslessMarkdownToAdf } from '@larvit/adf-codec'
const markdown = adfToLosslessMarkdown(doc)
if (markdown.ok) {
if (!markdown.ok) {
showReadOnly(doc, markdown.error) // saving markdown you could not produce would lose content
} else {
const edited = await editInYourApp(markdown.value) // a human, a model, a template
const saved = losslessMarkdownToAdf(edited)
if (saved.ok) post(saved.value)
else showError(saved.error) // the edit broke the markdown: the error names line and node
}
```
`losslessMarkdownToAdf(adfToLosslessMarkdown(doc).value)` deep-equals `doc`, node types this version
does not know included. Markdown the pair wrote reads back and writes again byte for byte.
Unedited, the markdown reads back to a document that deep-equals `doc`, node types this version
does not know included.
### Handle a result's error
@@ -146,7 +152,6 @@ Nothing throws. `code` is from a closed list, so a `switch` with no `default` co
- [The errors](#the-errors)
- [The guarantees](#the-guarantees)
- [The package](#the-package)
- [Roadmap](#roadmap)
- [Everything exported](#everything-exported)
- [Links](#links)
@@ -201,22 +206,21 @@ adfToPortableMarkdown(doc: AdfDocument): Result<string>
portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }`. Each call's name says
which markdown it reads or writes:
Each call's name says which markdown it reads or writes:
| Call | Markdown | Refuses with |
| --- | --- | --- |
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark, plus `!adf:` directives for everything CommonMark cannot spell | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth` |
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `not-a-string`, `unmappable-html`, `unsupported-nesting-depth` |
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every parse code in [The errors](#the-errors) |
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` |
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth` |
`isAdfDocument` is the type guard for the boundary you receive JSON at. It checks the shape the
converters require: `type: 'doc'`, a finite `version`, nodes holding only `attrs`, `content`,
`marks`, `text` and `type`, attribute values that are JSON values, and no object holding itself. It
returns `true` for a document nesting past 500 levels; the converters refuse that one with
`unsupported-nesting-depth`.
converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding
only `attrs`, `content`, `marks`, `text` and `type`, attribute values that are JSON values, and no
object holding itself. It returns `true` for a document nesting past 500 levels; the converters
refuse that one with `unsupported-nesting-depth`.
The types:
@@ -236,33 +240,36 @@ type SourcePosition = { line: number; offset: number }
## The lossless flavour
Serves Goals 1, 5 and 6. The lossless flavour is CommonMark, extended under one prefix, `!adf:`,
for everything CommonMark has no spelling for. The complete grammar, with every node's attributes:
Serves [Goals](#goals) 1, 5 and 6. The lossless flavour is CommonMark plus GFM's pipe tables and
`~~`, with directives under one prefix, `!adf:`, and `adf:` code fences for everything else. The
complete grammar, with every node's attributes:
[`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md).
### What CommonMark spells
### What CommonMark and GFM spell
Where CommonMark holds what a node carries, the node keeps CommonMark's spelling.
Where CommonMark or GFM holds what a node carries, the node keeps that spelling.
`adfToLosslessMarkdown` writes exactly one spelling of each; every CommonMark variant of the same
document normalizes to it through the round-trip.
| ADF | Markdown |
| --- | --- |
| `blockquote` | `> ` |
| `bulletList`, `listItem` | `- ` items; a list right after a list of its kind takes `* ` |
| `bulletList`, `listItem` | `- ` items; adjacent lists of a kind alternate `- ` and `* ` |
| `codeBlock` | a fenced code block, the language as info string, the fence longer than any backtick run inside; several text nodes are `!adf:codeBlock` holding one fence per node |
| `hardBreak` | a backslash at the end of the line |
| `hardBreak` | a backslash at the end of the line; `!adf:hardBreak{}` where no backslash can stand: a block's end, inside a heading, or a break carrying an attribute |
| `heading` | `#` to `######` |
| `link` | `[text](url "title")`; `<url>` where the text is the destination and the destination is an absolute URI |
| `mediaSingle` holding an external `media`, centered, uncaptioned | `![alt](url)`; `[![alt](url)](href)` where a link marks the image |
| `orderedList` | `1.` `2.` `3.`, the first number the list's `order`; a list right after a list of its kind takes `1)` |
| `orderedList` | `1.` `2.` `3.`, the first number the list's `order`; adjacent lists of a kind alternate `1.` and `1)` |
| `paragraph` | one line, no soft wraps |
| `rule` | `---` |
| `table` of one header row and plain inline cells | a pipe table, every row opening and closing with a pipe |
| `text` marked `code`, `em`, `strike`, `strong` | `` `code` ``, `_em_`, `~~strike~~`, `**strong**` |
| `text` marked `code`, `em`, `strike`, `strong` | `` `code` ``, `_em_` (`*em*` inside a word), `~~strike~~`, `**strong**` |
| `text` holding a carriage return | `&#13;` |
| `text` holding a newline, a null character, or a space or tab CommonMark strips (a line's edges, a pipe cell's edges, an emphasis spelling's inner edges) | that run as `!adf:text{text="…"}`, the rest plain: `!adf:text{text=" "}Two leading spaces` |
Input normalizes: setext headings become ATX, indented code becomes a fence, `*em*` becomes
`_em_`, entity references decode, a soft line break becomes a space.
`_em_` outside a word, entity references decode, a soft line break becomes a space.
### What directives spell
@@ -275,6 +282,8 @@ Three forms, one grammar. A directive's name is the ADF node or mark type:
!adf:/name
```
A node's content model picks the block form: a node taking content is a container.
```
!adf:panel warning
Check the collation before importing.
@@ -315,16 +324,14 @@ Ping !adf:mention[@Mikael]{id=5b10a2} — !adf:status[In review]{color=yellow}
or a longer escape than needed is an error naming the spelling to write.
- To keep a literal `!adf:` as text, escape it: `\!adf:`, block and inline alike. A code span and a
code block hold `!adf:` as content.
- A malformed directive is an error result, never silent text: an unclosed container, a closer
naming no open container, a leaf given a body, an unknown name.
## Portable markdown
Serves Goal 7. Portable markdown is a second flavour of the same grammar. `adfToPortableMarkdown`
writes markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
document sees or follows: its text, images and link targets. Its refusals are `not-an-adf-document`,
`unsupported-document-version` and `unsupported-nesting-depth`, and it writes no directive.
Serves [Goals](#goals) 7. Portable markdown is a second flavour of the same grammar.
`adfToPortableMarkdown` writes markdown other tools render — GitHub, GitLab, Obsidian and the like —
keeping the content and dropping the rest: attributes, colours, layout, identity. Content is what a
reader of the rendered document sees or follows: its text, images and link targets. It writes no
directive.
`portableMarkdownToAdf` reads CommonMark, pipe tables and `~~` as `losslessMarkdownToAdf` reads
them, and the conventions below as nodes, taking other tools' spellings too; a directive and an
@@ -361,7 +368,7 @@ saving what this pair read replaces mentions, attachments and macros with text.
link or code span is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops.
- A text node's `content` drops: Atlassian's schema forbids it, so no rendered document shows it.
- A list right after a list of its kind takes the other marker, `* ` or `1)`, since CommonMark
- Adjacent lists of a kind alternate markers, `- ` and `* `, `1.` and `1)`, since CommonMark
starts a new list where the marker changes; a task list counts as a bullet list, `* [x] Done`.
- An ordered list numbered past `999999999` is a bullet list keeping its numbers as text.
- A task list holding a block other than a task is a bullet list keeping its states as text:
@@ -376,9 +383,9 @@ saving what this pair read replaces mentions, attachments and macros with text.
## The errors
Serves Goal 1. An ADF node type this version does not know is not an error: the lossless pair
carries it opaquely and restores it unchanged
([Unknown nodes ride the carry](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
Serves [Goals](#goals) 1. An ADF node type this version does not know is not an error: the lossless
pair carries it opaquely and restores it unchanged ([Unknown nodes ride the
carry](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
### The error shape
@@ -406,24 +413,22 @@ the identical error: fix the input, or set the document aside.
### Parsing
Every reader also raises `unsupported-nesting-depth` from the "Either direction" table, and:
- `commonMarkToAdf`: `not-a-string` and `unmappable-html`.
- `portableMarkdownToAdf`: those and `malformed-pipe-table`.
- `losslessMarkdownToAdf`: every row below, and `unsupported-node-shape`.
Which reader raises which code: [The calls](#the-calls). Codes every direction raises:
[Either direction](#either-direction).
| Code | Fires when | What you can do |
| --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
| `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, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `not-a-string` | the value handed in is not a string, such as `null`, a number or a `String` object | pass the input as a string, `String(value)` for a `String` object; the message names the value's type |
| `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 prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds raw HTML, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown | remove the construct, or write what it holds as markdown |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md), or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds raw HTML, a comment and a processing instruction among them | remove the construct, or write what it holds as markdown |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — `&#10;`, `&#13;`, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines |
### Emitting
`adfToLosslessMarkdown` and `adfToPortableMarkdown`:
`adfToLosslessMarkdown` and `adfToPortableMarkdown` raise these, and the codes under
[Either direction](#either-direction):
| Code | Fires when | What you can do |
| --- | --- | --- |
@@ -432,17 +437,16 @@ Every reader also raises `unsupported-nesting-depth` from the "Either direction"
### 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:
The emitter raises these, and so does a parse, which asks the emitter how to spell each node:
| Code | Fires when | What you can do |
| --- | --- | --- |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON 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` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body |
## The guarantees
Serves Goals 1, 4 and 5.
Serves [Goals](#goals) 1, 4 and 5.
- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as JSON, for a document of
plain objects as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text
@@ -457,7 +461,7 @@ Serves Goals 1, 4 and 5.
four: literal text matching directive, pipe-table or strikethrough syntax, and a code fence whose
info string opens `adf:`. `portableMarkdownToAdf` claims what [Portable
markdown](#portable-markdown) lists. A claimed shape that does not complete, such as a pipe row
without a delimiter row, is an error result; each can be kept literal (`spec/flavour.md`).
without a delimiter row, is an error result; each can be kept literal ([`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md)).
- A document any reader built, written back with `adfToLosslessMarkdown`, takes the library's
canonical spelling, which round-trips byte-identically.
- These CommonMark spellings parse without an error but build a document the reference
@@ -496,7 +500,7 @@ Serves Goals 1, 4 and 5.
## The package
Serves Goal 8.
Serves [Goals](#goals) 8.
| Aspect | Contract |
| --- | --- |
@@ -510,32 +514,21 @@ Serves Goal 8.
Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#any-es2022-engine),
§Any ES2022 engine to §Public on npm.
## Roadmap
Planned for `0.3.0`, not in this release: `adfToHtml(doc): Result<string>` and
`htmlToAdf(html: string): Result<AdfDocument, ParseError>`, and markdown to and from HTML through
ADF, each call named by its markdown flavour. The markdown–HTML calls will keep only what ADF holds
and refuse what their markdown reader or `htmlToAdf` refuses. `htmlToAdf(adfToHtml(doc))` will
deep-equal `doc`, with fidelity HTML cannot express riding `data-*` attributes. Well-formed HTML will
mean what the HTML standard says, read or written, with no tag-soup recovery. Foreign HTML will map
a documented element set, which markdown's raw HTML will read through as well, and a construct
outside it will be an error. Until then every raw HTML construct in markdown is `unmappable-html`.
## Everything exported
| Export | Kind | What it is |
| Export | Kind | What it is ([The calls](#the-calls)) |
| --- | --- | --- |
| `AdfAttributes` | type | `{ [key: string]: JsonValue }` |
| `AdfDocument` | type | `{ content?: AdfNode[]; type: 'doc'; version: number }` |
| `AdfMark` | type | `{ attrs?: AdfAttributes; type: string }` |
| `AdfNode` | type | `{ attrs?: AdfAttributes; content?: AdfNode[]; marks?: AdfMark[]; text?: string; type: string }` |
| `ConvertError` | type | `{ code: ConvertErrorCode; message: string; path: ConvertErrorPath; position?: SourcePosition }` |
| `AdfAttributes` | type | a node's or mark's attributes |
| `AdfDocument` | type | an ADF document |
| `AdfMark` | type | an ADF mark |
| `AdfNode` | type | an ADF node |
| `ConvertError` | type | the error a failed call returns |
| `ConvertErrorCode` | type | the closed list of codes in [The errors](#the-errors) |
| `ConvertErrorPath` | type | `readonly (number \| string)[]` |
| `JsonValue` | type | `JsonValue[] \| boolean \| null \| number \| string \| { [key: string]: JsonValue }` |
| `ParseError` | type | `ConvertError & { position: SourcePosition }` |
| `Result` | type | `Result<T, E extends ConvertError = ConvertError>`: `{ error: E; ok: false } \| { ok: true; value: T }` |
| `SourcePosition` | type | `{ line: number; offset: number }` |
| `ConvertErrorPath` | type | an error's place in the document |
| `JsonValue` | type | an attribute value |
| `ParseError` | type | the error a markdown reader returns, its `position` always set |
| `Result` | type | every call's return value |
| `SourcePosition` | type | an error's place in the markdown |
| `adfToLosslessMarkdown` | function | ADF to the lossless flavour |
| `adfToPortableMarkdown` | function | ADF to portable markdown, lossy |
| `commonMarkToAdf` | function | strict CommonMark to ADF |
+8 -1
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 105`
`Next ID = 106`
| Goal | W |
|---|---|
@@ -30,6 +30,7 @@
| 84 | 0.2.0 | | **Read GFM's pipe table without leading pipes, and with alignment colons, in `portableMarkdownToAdf`, dropping the alignment.** | 3 | 4 | 7 | 7 | 4, 7 | 19.4 |
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 6 | 11.5 |
| 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 6 | 10.0 |
| 105 | 0.2.1 | | **Prove with a test that `adfToPortableMarkdown` never returns `unsupported-node-shape`, or list the code in the README's table of calls.** | 1 | 2 | 3 | 5 | 1 | 14.5 |
| 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 |
| 99 | 0.2.1 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
| 100 | 0.2.1 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 4 | 5 | 4, 6 | 10.9 |
@@ -111,6 +112,12 @@ Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the mar
escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table
gains its row.
### 105. Prove with a test that `adfToPortableMarkdown` never returns `unsupported-node-shape`, or list the code in the README's table of calls.
`spellableLine` (`src/markdown/portable/inline-reduction.ts`) can return `unsupported-node-shape`, and
no test reaches it; the README lists three codes for the call, and an app switching on them would
meet a fourth.
### 89. Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.
`*!adf:mention[@a]{id=…}*` and `[!adf:status[Done]{…}](/u)` build a node holding `em` or `link`.