mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Document the three markdown readers: README, MIGRATION, CHANGELOG, the grammar, the decisions and the plan
This commit is contained in:
@@ -26,9 +26,9 @@ In `docs/decisions.md`:
|
||||
- Tables
|
||||
- Links
|
||||
- Ids stay site-local
|
||||
- Plain task ids come from position
|
||||
- Portable task ids come from position
|
||||
- A callout title keeps its link targets
|
||||
- The plain flavour's spellings
|
||||
- The portable flavour's spellings
|
||||
- The HTML dialect
|
||||
- No runtime dependencies
|
||||
- Standards ship as data
|
||||
@@ -205,4 +205,4 @@ The comprehension panel's fill-ins:
|
||||
- Domain: Atlassian Document Format and CommonMark parsing.
|
||||
- Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`.
|
||||
- 3am question: a viewer/editor app reports that a document it saved comes back with two text
|
||||
nodes merged and a mark gone after `markdownToAdf(adfToMarkdown(doc))`.
|
||||
nodes merged and a mark gone after `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))`.
|
||||
|
||||
+25
-20
@@ -2,8 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` refuse a value that is not a string with
|
||||
the new code `not-a-string`; `MIGRATION.md` lists what each value did before.
|
||||
- **Breaking:** each call names the markdown it reads or writes. `adfToMarkdown` and `markdownToAdf`
|
||||
are now `adfToLosslessMarkdown` and `losslessMarkdownToAdf`, and the new `commonMarkToAdf` reads
|
||||
markdown a person or a model wrote as the CommonMark spec says: a directive, a pipe table, `~~`
|
||||
and an `adf:` fence read as text and code. `MIGRATION.md` says which call to take.
|
||||
- **Breaking:** the markdown readers refuse a value that is not a string with the new code
|
||||
`not-a-string`; `MIGRATION.md` lists what each value did before.
|
||||
- **Breaking:** an attribute value that is not a plain object, array or JSON primitive, such as a
|
||||
`Date`, `Map`, typed array or class instance, makes `isAdfDocument` return `false` and the
|
||||
converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own
|
||||
@@ -20,19 +24,20 @@
|
||||
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
|
||||
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
|
||||
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
|
||||
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
|
||||
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
|
||||
spells a document holding no `content` key.
|
||||
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
|
||||
nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
|
||||
`content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
|
||||
spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
|
||||
other than plain text nodes rides the block carry, where it was refused.
|
||||
- **Breaking:** the markdown readers read markdown holding no block as a document whose `content` is
|
||||
empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no
|
||||
`content` key.
|
||||
- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it:
|
||||
two adjacent text nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an
|
||||
empty `attrs`, `content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or
|
||||
`{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock` of several text nodes is a fence per
|
||||
node. A `codeBlock` holding other than plain text nodes rides the block carry, where it was
|
||||
refused.
|
||||
- **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and
|
||||
`adfToMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled
|
||||
` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span
|
||||
is spelled as that code span, its backticks opening no fence; a code block holding either
|
||||
character, a code span holding a null character, a text node holding no text or holding
|
||||
`adfToLosslessMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is
|
||||
spelled ` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a
|
||||
code span is spelled as that code span, its backticks opening no fence; a code block holding
|
||||
either character, a code span holding a null character, a text node holding no text or holding
|
||||
`content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null
|
||||
character ride the carry. See `MIGRATION.md`.
|
||||
- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it
|
||||
@@ -44,7 +49,7 @@
|
||||
`MIGRATION.md` lists each.
|
||||
- **Breaking:** a link whose text holds another link keeps the inner link and leaves the outer
|
||||
brackets literal text, where `0.1.0` split the outer link around it; see `MIGRATION.md`.
|
||||
- Add `adfToPlainMarkdown` and `plainMarkdownToAdf`, a lossy pair converting ADF to and from
|
||||
- Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from
|
||||
markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and
|
||||
pipe tables.
|
||||
- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's
|
||||
@@ -52,12 +57,12 @@
|
||||
- Fix an image inside another image's description: it flattens into the alt text, where it was
|
||||
refused.
|
||||
- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`:
|
||||
`markdownToAdf` and `plainMarkdownToAdf` read it as the key it spells, where on V8 13.6 and later
|
||||
(Node 24, Deno, Chrome) they could refuse markdown `adfToMarkdown` wrote, as
|
||||
`unsupported-node-shape`, once the process had read a key holding a backslash
|
||||
`losslessMarkdownToAdf` reads it as the key it spells, where on V8 13.6 and later (Node 24, Deno,
|
||||
Chrome) `markdownToAdf` could refuse markdown `adfToMarkdown` wrote, as `unsupported-node-shape`,
|
||||
once the process had read a key holding a backslash
|
||||
([V8 bug](https://issues.chromium.org/issues/521080746)).
|
||||
- Fix a cyclic document, in which an object holds itself: `adfToMarkdown` and `adfToPlainMarkdown`
|
||||
refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung.
|
||||
- Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and
|
||||
`adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
|
||||
+20
-12
@@ -2,12 +2,20 @@
|
||||
|
||||
## From `0.1.0` to `0.2.0`
|
||||
|
||||
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
|
||||
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
|
||||
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
|
||||
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
|
||||
tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`.
|
||||
`0.1.0` built that shape from empty markdown, meaning the empty document.
|
||||
Each call names the markdown it reads or writes:
|
||||
|
||||
| `0.1.0` | `0.2.0` |
|
||||
| --- | --- |
|
||||
| `adfToMarkdown` | `adfToLosslessMarkdown` |
|
||||
| `markdownToAdf`, for markdown `adfToMarkdown` wrote | `losslessMarkdownToAdf` |
|
||||
| `markdownToAdf`, for markdown a person or a model wrote | `commonMarkToAdf`, which reads a directive, a pipe table, `~~` and an `adf:` fence as CommonMark does, as text and code |
|
||||
|
||||
Directives moved under the `!adf:` prefix. `losslessMarkdownToAdf` reads `0.1.0`'s spelling without
|
||||
an error, turning each directive into text and each carried node into an `adf` code block. Before
|
||||
`0.2.0` reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue)
|
||||
with the recipe below, and rewrite markdown your code writes or matches (templates, prompts,
|
||||
patterns) by the tables below. Stored ADF needs one change: give a document holding no `content` key
|
||||
`content: []`. `0.1.0` built that shape from empty markdown, meaning the empty document.
|
||||
|
||||
### Convert markdown
|
||||
|
||||
@@ -18,13 +26,13 @@ npm install @larvit/adf-codec@0.2.0 adf-codec-0.1@npm:@larvit/adf-codec@0.1.0
|
||||
```
|
||||
|
||||
```ts
|
||||
import { adfToMarkdown } from '@larvit/adf-codec'
|
||||
import { adfToLosslessMarkdown } from '@larvit/adf-codec'
|
||||
import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
|
||||
|
||||
function migrateMarkdown(stored: string) {
|
||||
const parsed = markdownToAdf010(stored)
|
||||
// 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
|
||||
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
|
||||
return parsed.ok ? adfToLosslessMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
|
||||
}
|
||||
```
|
||||
|
||||
@@ -57,15 +65,15 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
|
||||
| Input | `0.1.0` | `0.2.0` |
|
||||
| --- | --- | --- |
|
||||
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
|
||||
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
|
||||
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
|
||||
| markdown holding no block (`""`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
|
||||
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | in `losslessMarkdownToAdf`, the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
|
||||
|
||||
### Error codes
|
||||
|
||||
`unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`
|
||||
and `not-a-string` joins it: a `switch` naming one of the three stops compiling, and one naming
|
||||
every code needs the new case. A document `adfToMarkdown` refused with one of the three now
|
||||
converts; markdown the parser refused with one of the three now refuses with the code the table
|
||||
every code needs the new case. A document `adfToMarkdown` refused with one of the three
|
||||
`adfToLosslessMarkdown` now converts; markdown the parser refused with one of the three now refuses with the code the table
|
||||
gives.
|
||||
|
||||
| Input | `0.1.0` | `0.2.0` |
|
||||
|
||||
@@ -8,22 +8,22 @@ Convert Atlassian Document Format (ADF) to and from markdown and HTML, with a lo
|
||||
For application developers whose app shows, edits, exports or generates the rich text Atlassian
|
||||
products store as ADF.
|
||||
|
||||
- **Lossless:** `adfToMarkdown` and `markdownToAdf` give back the document they started from —
|
||||
panels, mentions, statuses and node types this version does not know included.
|
||||
- **Plain CommonMark in:** a bot's or a model's markdown is valid input, apart from raw HTML and
|
||||
the exceptions [The guarantees](#the-guarantees) lists.
|
||||
- **Plain markdown, lossy:** `adfToPlainMarkdown` and `plainMarkdownToAdf` convert to and from
|
||||
the markdown that GitHub, GitLab and Obsidian render — GitHub Flavored Markdown's alerts, task
|
||||
lists and pipe tables, Obsidian Flavored Markdown's callouts and highlights. `adfToPlainMarkdown`
|
||||
keeps the text, images by URL and link targets and drops the rest; to save edits back, use the
|
||||
lossless pair.
|
||||
- **Lossless:** `adfToLosslessMarkdown` and `losslessMarkdownToAdf` give back the document they
|
||||
started from — panels, mentions, statuses and node types this version does not know included.
|
||||
- **CommonMark in:** `commonMarkToAdf` reads a bot's or a model's markdown as the CommonMark spec
|
||||
says, apart from raw HTML and the exceptions [The guarantees](#the-guarantees) lists.
|
||||
- **Portable markdown, lossy:** `adfToPortableMarkdown` and `portableMarkdownToAdf` convert to and
|
||||
from the markdown that GitHub, GitLab and Obsidian render — GitHub Flavored Markdown's alerts,
|
||||
task lists and pipe tables, Obsidian Flavored Markdown's callouts and highlights.
|
||||
`adfToPortableMarkdown` keeps the text, images by URL and link targets and drops the rest; to
|
||||
save edits back, use the lossless pair.
|
||||
- **No throws:** every call returns a result; a refusal carries a code from a closed list.
|
||||
- **Runs anywhere:** pure functions with no runtime dependencies, in Node, Deno, Bun and browsers.
|
||||
- **HTML at `0.3.0`:** `adfToHtml`, `htmlToAdf`, `markdownToHtml`, `htmlToMarkdown`.
|
||||
|
||||
[The shape](#the-shape) · [Goals](#goals) · [Audience](#audience) ·
|
||||
[Plain markdown](#plain-markdown) · [The errors](#the-errors) · [The guarantees](#the-guarantees) ·
|
||||
[The package](#the-package) ·
|
||||
[Portable markdown](#portable-markdown) · [The errors](#the-errors) ·
|
||||
[The guarantees](#the-guarantees) · [The package](#the-package) ·
|
||||
[Grammar](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) ·
|
||||
[Changes](https://github.com/larvit/adf-codec/blob/main/CHANGELOG.md) ·
|
||||
[Upgrading from `0.1.0`](https://github.com/larvit/adf-codec/blob/main/MIGRATION.md) ·
|
||||
@@ -37,9 +37,9 @@ npm install @larvit/adf-codec
|
||||
```
|
||||
|
||||
```ts
|
||||
import { markdownToAdf } from '@larvit/adf-codec'
|
||||
import { losslessMarkdownToAdf } from '@larvit/adf-codec'
|
||||
|
||||
const result = markdownToAdf('# Release notes\n\n!adf:panel info\nShipped on Tuesday.\n!adf:/panel\n')
|
||||
const result = losslessMarkdownToAdf('# Release notes\n\n!adf:panel info\nShipped on Tuesday.\n!adf:/panel\n')
|
||||
if (result.ok) {
|
||||
send(result.value)
|
||||
} else {
|
||||
@@ -50,15 +50,17 @@ if (result.ok) {
|
||||
|
||||
Serves [Goals](#goals) 1, 2 and 8. Pure functions, each taking a whole document and returning a
|
||||
whole result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF:
|
||||
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
|
||||
they keep only what ADF holds, and refuse what `losslessMarkdownToAdf` or `htmlToAdf` refuses.
|
||||
|
||||
```ts
|
||||
adfToMarkdown(doc: AdfDocument): Result<string>
|
||||
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
adfToLosslessMarkdown(doc: AdfDocument): Result<string>
|
||||
losslessMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
isAdfDocument(v: unknown): v is AdfDocument
|
||||
|
||||
adfToPlainMarkdown(doc: AdfDocument): Result<string>
|
||||
plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
|
||||
adfToPortableMarkdown(doc: AdfDocument): Result<string>
|
||||
portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
|
||||
adfToHtml(doc: AdfDocument): Result<string> // 0.3.0
|
||||
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.3.0
|
||||
@@ -103,22 +105,22 @@ Behind those apps, the people who read and write the markdown, and later the HTM
|
||||
managers, engineers and support agents working in Atlassian products through a plugin or another
|
||||
UI. They know markdown and not ADF, and rely on every spelling saying what it means to them.
|
||||
|
||||
## Plain markdown
|
||||
## Portable markdown
|
||||
|
||||
Serves Goal 7. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
|
||||
markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
|
||||
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. It refuses only
|
||||
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
|
||||
no directive.
|
||||
document sees or follows: its text, images and link targets. It refuses only `not-an-adf-document`,
|
||||
`unsupported-document-version` and `unsupported-nesting-depth`, and writes no directive.
|
||||
|
||||
`plainMarkdownToAdf` reads what `markdownToAdf` reads and refuses what it refuses, and reads the
|
||||
conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker as text:
|
||||
`\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPlainMarkdown` wrote reads back and writes again
|
||||
`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
|
||||
`adf:` fence read as CommonMark reads them, as text and code. A backslash keeps a marker as text:
|
||||
`\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPortableMarkdown` wrote reads back and writes again
|
||||
byte for byte; the document it came from does not come back.
|
||||
|
||||
To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`: saving what this pair
|
||||
read replaces mentions, attachments and macros with text.
|
||||
To edit a document and save it back, use `adfToLosslessMarkdown` and `losslessMarkdownToAdf`:
|
||||
saving what this pair read replaces mentions, attachments and macros with text.
|
||||
|
||||
| ADF | Written | Read back |
|
||||
| --- | --- | --- |
|
||||
@@ -165,8 +167,8 @@ carries it opaquely and restores it unchanged ([`docs/decisions.md`](https://git
|
||||
|
||||
`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` and
|
||||
`plainMarkdownToAdf` return `ParseError`, whose `position` reads without a guard; an emit reads no
|
||||
and may change in any release. A parse always names a position, so the three markdown readers
|
||||
return `ParseError`, whose `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('')`
|
||||
@@ -181,7 +183,9 @@ UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte of
|
||||
or before the refusal — currently the start of the line the enclosing block begins on; a later
|
||||
minor may narrow that, never widen it. For `not-a-string` it is `{ line: 1, offset: 0 }`.
|
||||
|
||||
Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.3.0`:
|
||||
Parsing — `losslessMarkdownToAdf` raises every row, `portableMarkdownToAdf` the rows naming no
|
||||
directive, and `commonMarkToAdf` `not-a-string`, `unmappable-html` and `unmappable-image`;
|
||||
`htmlToAdf` at `0.3.0`:
|
||||
|
||||
| Code | Fires when | What you can do |
|
||||
| --- | --- | --- |
|
||||
@@ -193,7 +197,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.3.0`
|
||||
| `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
|
||||
| `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — ` `, ` `, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines |
|
||||
|
||||
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.3.0`:
|
||||
Emitting — `adfToLosslessMarkdown` and `adfToPortableMarkdown`, and `adfToHtml` at `0.3.0`:
|
||||
|
||||
| Code | Fires when | What you can do |
|
||||
| --- | --- | --- |
|
||||
@@ -207,25 +211,28 @@ emit refuses:
|
||||
| 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: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying 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, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying 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 |
|
||||
|
||||
## The guarantees
|
||||
|
||||
Serves Goals 1, 4 and 5.
|
||||
|
||||
- `markdownToAdf(adfToMarkdown(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 nodes, an
|
||||
empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried opaquely
|
||||
- `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
|
||||
nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried
|
||||
opaquely
|
||||
([`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
|
||||
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
|
||||
`0.3.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
|
||||
name every exception.
|
||||
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
|
||||
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
|
||||
and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
|
||||
`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, save an image inside another's description, which
|
||||
flattens into the alt text. Converting back yields the library's canonical spelling, which
|
||||
- CommonMark is valid input to every reader apart from the raw HTML `unmappable-html` names, with
|
||||
one gap: a CommonMark image fits only as its own title-less paragraph; mid-text and titled images
|
||||
are error results, save an image inside another's description, which flattens into the alt text.
|
||||
`commonMarkToAdf` reads it as its spec says. `losslessMarkdownToAdf` has four carve-outs —
|
||||
literal text matching directive, pipe-table or strikethrough syntax, and a code fence whose info
|
||||
string opens `adf:`, are claimed (each can be kept literal — `spec/flavour.md`) — and
|
||||
`portableMarkdownToAdf` claims what [Portable markdown](#portable-markdown) lists. Converting a
|
||||
read document back with `adfToLosslessMarkdown` yields the library's canonical spelling, which
|
||||
round-trips byte-identically.
|
||||
- Four CommonMark spellings parse without an error and build a document the reference
|
||||
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
|
||||
@@ -235,15 +242,17 @@ Serves Goals 1, 4 and 5.
|
||||
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
|
||||
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
|
||||
holds no example of the fourth.
|
||||
- Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, and
|
||||
the node shapes the `unsupported-node-shape` row lists. 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 — `plainMarkdownToAdf` turns the marker into a `taskList`.
|
||||
- Not every document converts back: `adfToLosslessMarkdown` refuses a document nesting past 500
|
||||
levels, and the node shapes the `unsupported-node-shape` row lists. 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 `losslessMarkdownToAdf` and `portableMarkdownToAdf` read 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 `~~`, `losslessMarkdownToAdf` reads no GFM: an autolink literal and a `- [ ]`
|
||||
marker stay text, and a checklist is the `taskList` directive — `portableMarkdownToAdf` turns the
|
||||
marker into a `taskList`.
|
||||
- A document nested deeper than 500 levels is an error result, not a stack overflow, and no call
|
||||
loops forever on a string, or on a value `JSON.parse` or `structuredClone` builds.
|
||||
- The emitted formats are semver surface
|
||||
|
||||
+9
-9
@@ -2,21 +2,21 @@
|
||||
|
||||
One directory per contract kind:
|
||||
|
||||
- `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 (`docs/decisions.md` §The
|
||||
round-trip is the product). Grouped by what the fixture exercises.
|
||||
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToLosslessMarkdown` must emit for
|
||||
that document, byte for byte, and that `losslessMarkdownToAdf` must read back to it
|
||||
(`docs/decisions.md` §The round-trip is the product). Grouped by what the fixture exercises.
|
||||
- `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document
|
||||
`markdownToAdf` must build from it, which must in turn emit and read back to itself. The
|
||||
`losslessMarkdownToAdf` must build from it, which must in turn emit and read back to itself. The
|
||||
markdown is not canonical.
|
||||
- `errors/` — `<name>.md`: markdown input that must not convert. A `<name>.error` beside it
|
||||
pins which error.
|
||||
- `real-payloads/` — `<name>.json`: ADF Atlassian's editor wrote, round-tripped ADF→markdown→ADF.
|
||||
No expected markdown.
|
||||
- `commonmark-spec/` — the CommonMark suite run against `markdownToAdf` by three checks. `spec.json`
|
||||
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
|
||||
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
|
||||
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
|
||||
model) or `pending` (a parser gap).
|
||||
- `commonmark-spec/` — the CommonMark suite run against `commonMarkToAdf` by three checks.
|
||||
`spec.json` is the suite; `refusals.json` pins each refusing example to its error `code`;
|
||||
`exceptions.json` pins each known divergence by `check`, `example`, `kind` and the exact
|
||||
`divergence`, with a `reason`. `kind` is `mark-model` (the permanent count divergence from ADF's
|
||||
mark-per-text-node model) or `pending` (a parser gap).
|
||||
|
||||
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
|
||||
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
|
||||
|
||||
+37
-37
@@ -32,12 +32,12 @@ writer.
|
||||
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
|
||||
through the lossless pair.
|
||||
|
||||
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-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 checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads
|
||||
are invented content written in Atlassian's editor on the maintainer's test site, so none is
|
||||
sanitized and a mention keeps the test user's real account id.
|
||||
`losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-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 checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real
|
||||
payloads are invented content written in Atlassian's editor on the maintainer's test site, so none
|
||||
is sanitized and a mention keeps the test user's real account id.
|
||||
|
||||
## Markdown in is a canonical fixpoint
|
||||
|
||||
@@ -55,11 +55,12 @@ shape the editor would not.
|
||||
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
|
||||
as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
|
||||
adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
|
||||
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
|
||||
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
|
||||
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
|
||||
`0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which
|
||||
ADF's schema requires — so two documents the editor holds equal write the same plain markdown.
|
||||
CommonMark's spelling stays wherever a document holds none of those shapes. The portable reader
|
||||
builds what is written, as `losslessMarkdownToAdf` does. Only the portable writer is lossy: its
|
||||
reduction reads and writes editor-normal ADF — adjacent text nodes of identical marks and no
|
||||
attributes merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key, except
|
||||
the doc's `content`, which ADF's schema requires — so two documents the editor holds equal write the
|
||||
same portable markdown.
|
||||
|
||||
## `!adf:textBreak{}` parts text CommonMark would join
|
||||
|
||||
@@ -138,8 +139,8 @@ language, and none is empty beside another, since a text node holds text. The gr
|
||||
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 5. Valid while ADF holds no node
|
||||
for a bare container, a comment or a script. Lands with `todo.md` item 6.
|
||||
|
||||
Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
|
||||
drop of content:
|
||||
Every foreign element `htmlToAdf` and the markdown readers read sorts one of three ways, never a
|
||||
silent drop of content:
|
||||
|
||||
- A container around document content that ADF has no node for unwraps to its children, its own
|
||||
attributes dropped: `<div align="center">text</div>` keeps `text`, losing the alignment.
|
||||
@@ -152,7 +153,7 @@ drop of content:
|
||||
`nestedExpand` inside another; an empty one is refused, since `expand` requires content. A `style`
|
||||
attribute is not read at `0.3.0`: the `textColor` and `backgroundColor` it could reach cost more
|
||||
than they buy.
|
||||
`plainMarkdownToAdf` reads through `markdownToAdf`'s parser, so it takes the same set.
|
||||
The three markdown readers share one parser, so they take the same set.
|
||||
|
||||
## Names stay text
|
||||
|
||||
@@ -207,33 +208,31 @@ where its reference implementation nests one `<a>` in another.
|
||||
Identity-bearing nodes carry their ids in attributes; a document is only portable within its site —
|
||||
accepted.
|
||||
|
||||
## Plain task ids come from position
|
||||
## Portable task ids come from position
|
||||
|
||||
2026-09-26, spelling 2026-09-29, the maintainer. Goals 7 and 8. Valid while a site rejects a task
|
||||
node with no `localId`.
|
||||
|
||||
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
|
||||
in the editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it
|
||||
mints, skipping any id the document holds: the same markdown reads to the same ids every run,
|
||||
different markdown to different ids. The same markdown pasted twice into one document repeats its
|
||||
ids: determinism wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its
|
||||
ids are only skipped.
|
||||
`portableMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` a `localId` in the
|
||||
editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it mints:
|
||||
the same markdown reads to the same ids every run, different markdown to different ids. The same
|
||||
markdown pasted twice into one document repeats its ids: determinism wins over that case.
|
||||
|
||||
## A callout title keeps its link targets
|
||||
|
||||
2026-09-29, the maintainer. Goals 6 and 7. Valid while an expand's `title` is a string.
|
||||
|
||||
`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
|
||||
`portableMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
|
||||
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
|
||||
is its target, with or without `mailto:`, keeps its text alone: `<http://y>` titles `http://y`,
|
||||
`<a@b.c>` `a@b.c` — three persona readers agreeing, 2026-09-30.
|
||||
|
||||
## The plain flavour's spellings
|
||||
## The portable flavour's spellings
|
||||
|
||||
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 6 and 7. Valid while GitHub's
|
||||
renderer is the one the audience's markdown is read in.
|
||||
|
||||
README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
|
||||
README §Portable markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
|
||||
MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Discord, GitHub's
|
||||
renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds
|
||||
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
|
||||
@@ -400,9 +399,9 @@ handles one cause alike whichever node, attribute or direction raised it.
|
||||
knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23).
|
||||
- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document`
|
||||
emitting — no document holds one, so no round-trip crosses them (2026-09-23).
|
||||
- `markdownToAdf`, `plainMarkdownToAdf` and `htmlToAdf` refuse a value that is not a string as
|
||||
`not-a-string`, at position `{ line: 1, offset: 0 }` with an empty path: no existing code reads
|
||||
true of it, and `not-an-adf-document` names the emitters' input (2026-10-05).
|
||||
- The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at
|
||||
position `{ line: 1, offset: 0 }` with an empty path: no existing code reads true of it, and
|
||||
`not-an-adf-document` names the emitters' input (2026-10-05).
|
||||
|
||||
## `message` and `path`
|
||||
|
||||
@@ -551,15 +550,16 @@ markdown, on a fixed seed in the gate; a counterexample found becomes a round-tr
|
||||
|
||||
## The CommonMark suite checks three ways
|
||||
|
||||
2026-08-27, the maintainer. Goals 1 and 4. Valid while the suite's answers are HTML ADF cannot be
|
||||
compared against.
|
||||
2026-08-27, against `commonMarkToAdf` 2026-10-09, the maintainer. Goals 1 and 4. Valid while the
|
||||
suite's answers are HTML ADF cannot be compared against.
|
||||
|
||||
Each example is a named error or markdown that parses and emits to itself byte for byte; its
|
||||
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
|
||||
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
|
||||
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
|
||||
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses is a bug to fix, never an
|
||||
exception.
|
||||
Each example is a named error or markdown `commonMarkToAdf` reads to a document that
|
||||
`adfToLosslessMarkdown` writes and `losslessMarkdownToAdf` reads back deep-equal, since CommonMark
|
||||
has no writer of its own; its reference HTML's text, tags stripped and entities decoded, equals the
|
||||
parsed document's; and its elements count the marks and nodes they map to. The fixpoint alone
|
||||
passes a parser returning the empty document, the text alone one dropping every emphasis. An
|
||||
exception is the maintainer's to add, and valid CommonMark parsing to a document
|
||||
`adfToLosslessMarkdown` refuses is a bug to fix, never an exception.
|
||||
|
||||
## The flavour spec is read as a source
|
||||
|
||||
@@ -647,7 +647,7 @@ two cannot disagree — which is what makes the kept value a memo rather than a
|
||||
2026-09-19, the maintainer. Goal 9. Valid while the `commonMarkSpelling` ask spells a node once
|
||||
per level above it otherwise.
|
||||
|
||||
The parse and the plain reduction keep each node's readable spelling in a memo, so the
|
||||
The parse and the portable reduction keep each node's readable spelling in a memo, so the
|
||||
`commonMarkSpelling` ask stops spelling a node once per level above it. `text` and `spelling` carry
|
||||
no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry
|
||||
rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and
|
||||
@@ -720,7 +720,7 @@ them. A primitive knowing neither ADF nor a format stays at `src/` root. A const
|
||||
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
|
||||
what both read where there are two.
|
||||
|
||||
A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`;
|
||||
A flavour's own code, both directions included, sits in its own directory: `markdown/portable/`;
|
||||
`todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise each format directory
|
||||
parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it holding what both
|
||||
directions read. A construct's reader lives there beside the regex the emitter escapes against, so
|
||||
|
||||
+32
-33
@@ -1,18 +1,18 @@
|
||||
# The markdown flavour
|
||||
|
||||
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
|
||||
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
|
||||
directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
|
||||
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
|
||||
string opens `adf:` is the opaque carry (drop the info string and wrap the fence in
|
||||
`!adf:codeBlock {language="adf:…"}` to keep it code) — 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 (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the
|
||||
sections below.
|
||||
The grammar of the lossless flavour, the extended markdown `adfToLosslessMarkdown` emits and
|
||||
`losslessMarkdownToAdf` parses. CommonMark is a subset apart from raw HTML (below), with four
|
||||
carve-outs: literal text that matches directive syntax below or reads as a pipe table is claimed by
|
||||
the flavour, a matched `~~` pair spells `strike` (escape the `!adf:`, `|` or `~` to keep it
|
||||
literal), and a code fence whose info string opens `adf:` is the opaque carry (drop the info string
|
||||
and wrap the fence in `!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a
|
||||
CommonMark image fits only as its own title-less paragraph — mid-text and titled images are named
|
||||
errors. `commonMarkToAdf` claims none of the four. The emitted form is contract (`docs/decisions.md`
|
||||
§The formats are API). Per-node syntaxes build on this grammar in the sections below.
|
||||
|
||||
## Canonical form
|
||||
|
||||
`adfToMarkdown` emits exactly one spelling; every CommonMark variant of the same document
|
||||
`adfToLosslessMarkdown` emits exactly one spelling; every CommonMark variant of the same document
|
||||
normalizes to it through the round-trip.
|
||||
|
||||
- Emphasis `_em_`, strong `**strong**`, strike `~~strike~~`; `*` replaces `_` only where `_`
|
||||
@@ -194,27 +194,27 @@ is the fence.
|
||||
|
||||
## Raw HTML in input
|
||||
|
||||
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign
|
||||
HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways) — ADF has no raw-HTML
|
||||
node, so a construct without a mapping, comments and processing instructions included, is an error
|
||||
result naming it. The flavour never emits raw HTML.
|
||||
CommonMark input may contain raw HTML. Every markdown reader routes each construct through the
|
||||
foreign HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways) — ADF has no
|
||||
raw-HTML node, so a construct without a mapping, comments and processing instructions included, is
|
||||
an error result naming it. The flavour never emits raw HTML.
|
||||
|
||||
## Block nodes
|
||||
|
||||
The directive name is always the ADF node type. A container's body is the node's `content`; a leaf
|
||||
has none. Every directive parses in any position — `markdownToAdf` builds exactly what is written;
|
||||
validity against ADF's content models stays the author's business (`docs/decisions.md` §No schema
|
||||
validation). It parses only in the form the emitter picks, though: a directive spelling a node the
|
||||
emitter would have written as CommonMark is a named error.
|
||||
has none. Every directive parses in any position — `losslessMarkdownToAdf` builds exactly what is
|
||||
written; validity against ADF's content models stays the author's business (`docs/decisions.md` §No
|
||||
schema validation). It parses only in the form the emitter picks, though: a directive spelling a
|
||||
node the emitter would have written as CommonMark is a named error.
|
||||
|
||||
Each section lists attributes as `name (type)`. A parenthesized value set documents what real
|
||||
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
|
||||
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
|
||||
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
|
||||
quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the
|
||||
markdown spells one, an empty one through its reserved key (Attributes), so a document reads back
|
||||
deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive
|
||||
form to hold an empty key.
|
||||
quoted, `-0` spelled `-0`. `losslessMarkdownToAdf` builds an `attrs`, `content` or `marks` key only
|
||||
where the markdown spells one, an empty one through its reserved key (Attributes), so a document
|
||||
reads back deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the
|
||||
directive form to hold an empty key.
|
||||
|
||||
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
|
||||
`json` value, `marks=empty` where it is empty:
|
||||
@@ -309,16 +309,15 @@ The moon, at night.
|
||||
!adf:/mediaSingle
|
||||
```
|
||||
|
||||
**The CommonMark image.** A paragraph whose entire inline content is one image `` is
|
||||
a `mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url`
|
||||
from the destination, `alt` the description's plain-text content when non-empty — a link or image
|
||||
inside it contributing its own text, a node spelling its text in the content slot contributing
|
||||
that text, and a break of either kind a space. `adfToMarkdown` emits the image form for exactly
|
||||
that shape — those attrs and no others, no marks on either node, no caption, and a `media`
|
||||
carrying nothing beyond `alt`, `type` and `url` — and only where
|
||||
CommonMark spells the pair: a destination or a description the image form cannot hold, an empty
|
||||
`alt` included, takes the directive form instead. An image amid
|
||||
other text, or one carrying a title, is a named error: `mediaInline` carries a media
|
||||
**The CommonMark image.** A paragraph whose entire inline content is one image `` is a
|
||||
`mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url` from
|
||||
the destination, `alt` the description's plain-text content when non-empty — a link or image inside
|
||||
it contributing its own text, a node spelling its text in the content slot contributing that text,
|
||||
and a break of either kind a space. `adfToLosslessMarkdown` emits the image form for exactly that
|
||||
shape — those attrs and no others, no marks on either node, no caption, and a `media` carrying
|
||||
nothing beyond `alt`, `type` and `url` — and only where CommonMark spells the pair: a destination or
|
||||
a description the image form cannot hold, an empty `alt` included, takes the directive form instead.
|
||||
An image amid other text, or one carrying a title, is a named error: `mediaInline` carries a media
|
||||
`collection` + `id`, never a URL, and no media node carries a title.
|
||||
|
||||
### Tables
|
||||
@@ -525,7 +524,7 @@ the directive form, open to no literal reading, is a named error.
|
||||
|
||||
A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
|
||||
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
|
||||
reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
|
||||
reverse. `adfToLosslessMarkdown` nests in the order the array holds rather than sorting it —
|
||||
`docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once
|
||||
over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at
|
||||
that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run
|
||||
|
||||
@@ -25,13 +25,12 @@
|
||||
|
||||
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 4, 5 | 20.3 |
|
||||
| 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 4, 5, 7 | 17.1 |
|
||||
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 |
|
||||
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 |
|
||||
| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 |
|
||||
| 60 | 0.2.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
|
||||
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
|
||||
| 62 | 0.2.0 | decision | **Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
|
||||
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
|
||||
| 62 | 0.2.0 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
|
||||
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 4, 5, 7 | 9.0 |
|
||||
| 7 | 0.3.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
|
||||
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
|
||||
@@ -55,7 +54,7 @@
|
||||
| 34 | 0.4.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 4, 5 | 11.3 |
|
||||
| 31 | 0.4.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 |
|
||||
| 42 | 0.4.0 | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 9 | 10.5 |
|
||||
| 33 | 0.4.0 | defect | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
|
||||
| 33 | 0.4.0 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
|
||||
| 56 | 0.4.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
|
||||
| 76 | 0.4.0 | principle | **State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart.** | 2 | 2 | 1 | 3 | 1 | 5.5 |
|
||||
| 82 | 0.4.0 | principle | **Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.** | 1 | 3 | 1 | 2 | 1 | 4.2 |
|
||||
@@ -65,14 +64,6 @@
|
||||
|
||||
## Details
|
||||
|
||||
### 43. Give each markdown input its own reader, strict to its own standard.
|
||||
|
||||
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
|
||||
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text, and
|
||||
a code fence whose info string opens `adf:` becomes the block carry where CommonMark reads code. A
|
||||
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
|
||||
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
|
||||
|
||||
### 65. Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.
|
||||
|
||||
Both are refused today with `unmappable-image`: 14 examples in
|
||||
@@ -80,22 +71,21 @@ Both are refused today with `unmappable-image`: 14 examples in
|
||||
takes only Atlassian media ids, never a URL. An image not alone in its paragraph reads as its alt
|
||||
text, linked to its URL. Its title becomes the link's `title`. An empty alt text reads as the URL,
|
||||
as item 50 reads an empty link. A titled image alone in its paragraph reads as the image, with its
|
||||
title as a `caption` (the maintainer, 2026-10-04). By Goal 5, the lossless flavour reads both as
|
||||
CommonMark does. An image inside a link: `[](/uri)` (spec example 517). Alone in
|
||||
its paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `media` takes. Not
|
||||
alone in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A
|
||||
writer panel chose this reading 3 of 3 (2026-10-04), by Goal 6, which outranks Goal 7.
|
||||
title as a `caption` (the maintainer, 2026-10-04). By Goal 5, every reader reads both as CommonMark
|
||||
does. An image inside a link: `[](/uri)` (spec example 517). Alone in its
|
||||
paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `media` takes. Not alone
|
||||
in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A writer
|
||||
panel chose this reading 3 of 3 (2026-10-04), by Goal 6, which outranks Goal 7.
|
||||
|
||||
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.
|
||||
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`.
|
||||
|
||||
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
||||
two (spec examples 301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells
|
||||
adjacent lists by changing the marker, so `adfToMarkdown` and `adfToPlainMarkdown` alternate `-` and
|
||||
`*` between adjacent bullet lists and `.` and `)` between adjacent ordered lists, and
|
||||
`!adf:listBreak` retires. `*` is the maintainer's pick (2026-10-04). Breaking, so it ships beside
|
||||
item 43: `MIGRATION.md`'s Readings and Spellings tables gain their rows. Examples 301 and 302 lose
|
||||
their `pending` exceptions, and the spelling leaves the README's "Four CommonMark spellings" bullet,
|
||||
which counts one fewer.
|
||||
Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads two (spec examples
|
||||
301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells adjacent lists by
|
||||
changing the marker, so `adfToLosslessMarkdown` and `adfToPortableMarkdown` alternate `-` and `*`
|
||||
between adjacent bullet lists and `.` and `)` between adjacent ordered lists, and `!adf:listBreak`
|
||||
retires. `*` is the maintainer's pick (2026-10-04). Breaking: `MIGRATION.md`'s Readings and
|
||||
Spellings tables gain their rows. Examples 301 and 302 lose their `pending` exceptions, and the
|
||||
spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||
|
||||
### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.
|
||||
|
||||
@@ -111,28 +101,28 @@ of one character from each of its characters.
|
||||
from where it happens to live. One module naming the questions keeps `docs/decisions.md` §The source
|
||||
parts by ADF and format true as they grow.
|
||||
|
||||
### 59. Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`.
|
||||
### 59. Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.
|
||||
|
||||
Eight directive files sit across three directories, and the `markdown/` root holds 14 entries. HTML
|
||||
needs `Read<T>` and `Flavour` out of the plain flavour and `markdown/`; it needs the carry and the
|
||||
mark spellings too, which item 7 moves where it learns what HTML shares.
|
||||
needs `Read<T>` and `Flavour` out of the portable flavour and `markdown/`; it needs the carry and
|
||||
the mark spellings too, which item 7 moves where it learns what HTML shares.
|
||||
|
||||
### 62. Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.
|
||||
### 62. Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.
|
||||
|
||||
Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts`, the `mintTaskIds` call and
|
||||
the `inlineLeaves` use. Writing: `spellPlainBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in
|
||||
Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts` and the `mintTaskIds` call.
|
||||
Writing: `spellPortableBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in
|
||||
`emit/adf-to-markdown.ts`. And `highlightDelimiter` and `highlightFlanking` move out of
|
||||
`plain/conventions.ts`, which `emit/` imports them from; item 59 moves `Flavour`. Every
|
||||
comprehension reader on 2026-10-03 named the plain flavour's spread across three directories.
|
||||
`portable/conventions.ts`, which `emit/` imports them from; item 59 moves `Flavour` and
|
||||
`flavourClaims`. Every comprehension reader on 2026-10-03 named the portable flavour's spread across
|
||||
three directories.
|
||||
|
||||
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
|
||||
|
||||
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
|
||||
link mark, so a link whose text is empty takes its URL as its text (the maintainer, 2026-10-04):
|
||||
`[](/url)` reads as `/url` linked to `/url`. `[]()` has no URL to show; the chunk settles it by Goals
|
||||
4 and 7. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gains its row. Its
|
||||
`pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
|
||||
which counts one fewer.
|
||||
`[](/url)` reads as `/url` linked to `/url`. `[]()` has no URL to show; the chunk settles it by
|
||||
Goals 4 and 7. Breaking: `MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go,
|
||||
and its spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||
|
||||
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
||||
|
||||
@@ -150,7 +140,7 @@ replacement.
|
||||
### 6. Specify the HTML dialect.
|
||||
|
||||
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented
|
||||
foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw
|
||||
foreign-element set `htmlToAdf` accepts — the set the markdown readers share (`spec/flavour.md` §Raw
|
||||
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
|
||||
|
||||
### 77. Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.
|
||||
@@ -174,8 +164,8 @@ technical-principles audit, 2026-10-04.
|
||||
### 83. Refuse a document whose markdown would pass the engine's string limit with a result, never a `RangeError`.
|
||||
|
||||
The block carry indents its JSON two spaces a level, so one unknown block holding 497 nested arrays
|
||||
around 540,000 numbers, about 1.1 MB as JSON, spells about 540 million characters, past V8's
|
||||
2^29 − 24. `serializeCanonicalJson`'s `join` throws, and `adfToMarkdown` throws with it. Any output
|
||||
around 540,000 numbers, about 1.1 MB as JSON, spells about 540 million characters, past V8's 2^29 −
|
||||
24. `serializeCanonicalJson`'s `join` throws, and `adfToLosslessMarkdown` throws with it. Any output
|
||||
past the limit does the same. Refuse it with a code from the closed list, and add a fixture. Found
|
||||
by the README-goals audit, 2026-10-08.
|
||||
|
||||
@@ -248,9 +238,9 @@ A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]
|
||||
### 51. Match a reference label to its definition under Unicode case folding.
|
||||
|
||||
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
|
||||
example 540); lowercasing and then uppercasing folds it. Breaking, so it ships beside item 43:
|
||||
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
|
||||
the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||
example 540); lowercasing and then uppercasing folds it. Breaking: `MIGRATION.md`'s Readings table
|
||||
gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Four CommonMark
|
||||
spellings" bullet, which counts one fewer.
|
||||
|
||||
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.
|
||||
|
||||
@@ -266,8 +256,8 @@ the place it goes; the maintainer posts them. Found while planning discoverabili
|
||||
|
||||
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
|
||||
|
||||
`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
|
||||
already escape it.
|
||||
`adfToLosslessMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute
|
||||
values already escape it.
|
||||
|
||||
### 74. Keep the release path publishing after npm retires bypass-2FA tokens.
|
||||
|
||||
@@ -302,16 +292,16 @@ measured one.
|
||||
|
||||
### 42. Trim a text leaf's trailing blanks in linear time.
|
||||
|
||||
`plain/inline-reduction.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic
|
||||
in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
|
||||
`adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
|
||||
`portable/inline-reduction.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`,
|
||||
quadratic in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
|
||||
`adfToPortableMarkdown`. Scan backward, as the expand title's trim does.
|
||||
|
||||
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.
|
||||
### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.
|
||||
|
||||
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking
|
||||
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain
|
||||
reduction's `spellableLine` drops one mark per re-emit the same way. An inline node whose
|
||||
attributes no spelling writes re-emits the line the same way before riding the carry. Make all
|
||||
`adfToLosslessMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its
|
||||
flanking cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and
|
||||
the portable reduction's `spellableLine` drops one mark per re-emit the same way. An inline node
|
||||
whose attributes no spelling writes re-emits the line the same way before riding the carry. Make all
|
||||
three linear.
|
||||
|
||||
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.
|
||||
@@ -321,10 +311,10 @@ mutate it and return `void`; `walk.leaf` alone is written in seven places. `Cont
|
||||
same file shows the shape to follow. `inline-content.ts`'s `Scan` has the same shape: about fifteen
|
||||
functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return
|
||||
`void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in
|
||||
`mintTaskIds` (`plain/task-ids.ts`), which writes `attrs` on the document `readDocument` built;
|
||||
`mintTaskIds` (`portable/task-ids.ts`), which writes `attrs` on the document `readDocument` built;
|
||||
`takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads;
|
||||
`writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the
|
||||
output; and `nestIn` (`plain/adf-to-plain-markdown.ts`), which pushes into its caller's array.
|
||||
output; and `nestIn` (`portable/adf-to-portable-markdown.ts`), which pushes into its caller's array.
|
||||
|
||||
### 76. State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart.
|
||||
|
||||
@@ -336,7 +326,7 @@ agree. Found by the technical-principles audit, 2026-10-04.
|
||||
|
||||
### 82. Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.
|
||||
|
||||
Technical principle: tests use random UUID v7 ids; `plain-markdown-to-adf.test.ts` already does.
|
||||
Technical principle: tests use random UUID v7 ids; `portable-markdown-to-adf.test.ts` already does.
|
||||
Found by the technical-principles audit of item 78, 2026-10-05.
|
||||
|
||||
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
|
||||
|
||||
Reference in New Issue
Block a user