Rank discoverability third among the goals, plan the move to GitHub and the outreach, and drop a text node's content in plain #161

Merged
lilleman merged 8 commits from goals-reorder into main 2026-10-04 22:33:40 +02:00
7 changed files with 159 additions and 140 deletions
Showing only changes of commit e844943d46 - Show all commits
+1 -1
View File
@@ -169,7 +169,7 @@ reading is the ask. Never ask "A or B?": state the gap, the earlier entries of i
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
keeps collecting instances is wrong: rewrite it. keeps collecting instances is wrong: rewrite it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than Which output the audience expects — README goal 6 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the question serves, each given only asked: three fresh-context readers, one per README persona the question serves, each given only
`## Audience` and the input, writing what they expect before picking among outputs the goals `## Audience` and the input, writing what they expect before picking among outputs the goals
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
+12 -11
View File
@@ -28,13 +28,13 @@ The most useful ADF conversion library available, judged by these goals, in prio
1. **Lossless, and every call returns a result, never a throw.** 1. **Lossless, and every call returns a result, never a throw.**
2. **ADF is the hub.** 2. **ADF is the hub.**
3. **Each format reads and writes as its standard says.** 3. **Easy to find, and clear at a glance what it does.**
4. **Our markdown is CommonMark, extended only where CommonMark has no spelling.** 4. **Each format reads and writes as its standard says.**
5. **No surprises: output reads and edits the way its audience expects.** 5. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
6. **Lossy conversion drops form, never content.** 6. **No surprises: output reads and edits the way its audience expects.**
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.** 7. **Lossy conversion drops form, never content.**
8. **Fast, and linear in the document's size as JSON.** 8. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
9. **Easy to find, and clear at a glance what it does.** 9. **Fast, and linear in the document's size as JSON.**
## Audience ## Audience
@@ -74,7 +74,7 @@ if (result.ok) {
} }
``` ```
Serves Goals 1, 2 and 7. Pure functions, each taking a whole document and returning a whole Serves 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: 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 `markdownToAdf` or `htmlToAdf` refuses.
@@ -96,7 +96,7 @@ htmlToMarkdown(html: string): Result<string> // 0.2.0
## Plain markdown ## Plain markdown
Serves Goal 6. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes 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 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 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 document sees or follows: its text, images and link targets. It refuses only
@@ -136,6 +136,7 @@ read replaces mentions, attachments and macros with text.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a - A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
link or code span is trimmed, carriage returns and null characters are removed, and an empty link or code span is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops. paragraph drops.
- A text node's `content`, which Atlassian's schema forbids, drops.
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not - An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
continue, is one bullet list keeping its numbers as text. continue, is one bullet list keeping its numbers as text.
- A task list beside a bullet or decision list, or holding a block other than a task, joins one - A task list beside a bullet or decision list, or holding a block other than a task, joins one
@@ -200,7 +201,7 @@ emit refuses:
## The guarantees ## The guarantees
Serves Goals 1, 3 and 4. Serves Goals 1, 4 and 5.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects - `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 as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an
@@ -244,7 +245,7 @@ Serves Goals 1, 3 and 4.
## The package ## The package
Serves Goal 7. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts` Serves Goal 8. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint. under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
+34 -34
View File
@@ -24,7 +24,7 @@ sanitized and a mention keeps the test user's real account id.
## Markdown in is a canonical fixpoint ## Markdown in is a canonical fixpoint
2026-08-23, the maintainer; every parsed document has converted back since 2026-10-04. Goals 1 and 2026-08-23, the maintainer; every parsed document has converted back since 2026-10-04. Goals 1 and
4. Valid while markdown input may be written by hand. 5. Valid while markdown input may be written by hand.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically. back yields the library's canonical spelling, and that spelling round-trips byte-identically.
@@ -54,7 +54,7 @@ around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text
## An empty key spells `empty` ## An empty key spells `empty`
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while no attribute value 2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while no attribute value
spells an empty object or array. spells an empty object or array.
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
@@ -71,7 +71,7 @@ reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The Co
## Empty markdown is a document of no blocks ## Empty markdown is a document of no blocks
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires 2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while ADF's schema requires
`content` on `doc`. `content` on `doc`.
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
@@ -94,7 +94,7 @@ checking its position, and refusing loses a document ADF itself keeps in an `uns
## The carry fence names the node type ## The carry fence names the node type
2026-10-03, the maintainer. Goals 1 and 5. Valid while a code fence's info string reads back 2026-10-03, the maintainer. Goals 1 and 6. Valid while a code fence's info string reads back
verbatim. verbatim.
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
@@ -118,7 +118,7 @@ language, and none is empty beside another, since a text node holds text. The gr
## Foreign HTML sorts three ways ## Foreign HTML sorts three ways
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 4. Valid while ADF holds no node 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. 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 Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
@@ -139,14 +139,14 @@ than they buy.
## Names stay text ## Names stay text
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O. 2026-08-23, the maintainer. Goal 8. Valid while resolving a name to an id needs I/O.
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids is the consumer's job. mention/emoji/media nodes; resolving names to ids is the consumer's job.
## Directives under `!adf:` ## Directives under `!adf:`
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 4 and 5. Valid while prose does not 2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 5 and 6. Valid while prose does not
write `!adf:`. write `!adf:`.
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`: Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
@@ -157,7 +157,7 @@ opener nests by itself and leaf versus container falls out of the node's content
## CommonMark is a subset ## CommonMark is a subset
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim. 2026-08-23, the maintainer. Goal 5. Valid while prose rarely writes the shapes the carve-outs claim.
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
@@ -166,14 +166,14 @@ claims, giving CommonMark its own reader, and `todo.md` item 65 ends the image g
## Tables ## Tables
2026-08-23, the maintainer. Goal 5. Valid while a pipe table holds only one header row and inline 2026-08-23, the maintainer. Goal 6. Valid while a pipe table holds only one header row and inline
cells. cells.
One header row plus plain inline cells → pipe table; anything richer → directive form. One header row plus plain inline cells → pipe table; anything richer → directive form.
## Links ## Links
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 5. Valid while CommonMark's link 2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 6. Valid while CommonMark's link
syntax is what readers edit. syntax is what readers edit.
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
@@ -192,7 +192,7 @@ accepted.
## Plain task ids come from position ## Plain task ids come from position
2026-09-26, spelling 2026-09-29, the maintainer. Goals 6 and 7. Valid while a site rejects a task 2026-09-26, spelling 2026-09-29, the maintainer. Goals 7 and 8. Valid while a site rejects a task
node with no `localId`. node with no `localId`.
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId` `plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
@@ -204,7 +204,7 @@ ids are only skipped.
## A callout title keeps its link targets ## A callout title keeps its link targets
2026-09-29, the maintainer. Goals 5 and 6. Valid while an expand's `title` is a string. 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 `plainMarkdownToAdf` 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 parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
@@ -213,7 +213,7 @@ is its target, with or without `mailto:`, keeps its text alone: `<http://y>` tit
## The plain flavour's spellings ## The plain flavour's spellings
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 5 and 6. Valid while GitHub's 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. 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 §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
@@ -232,7 +232,7 @@ past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates int
## The HTML dialect ## The HTML dialect
2026-08-23, the maintainer. Goals 5 and 7. Valid while HTML output is read by consumers styling it 2026-08-23, the maintainer. Goals 6 and 8. Valid while HTML output is read by consumers styling it
themselves. themselves.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*` The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
@@ -240,7 +240,7 @@ for what HTML cannot express, text always escaped. No stylesheet ships.
## No runtime dependencies ## No runtime dependencies
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each 2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
job a dependency would. job a dependency would.
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20 `dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
@@ -250,7 +250,7 @@ and HTML parsers are written in this repo.
## Standards ship as data ## Standards ship as data
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1, 2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
4 and 7. Valid while each table is fixed data a dependency would only wrap. 5 and 8. Valid while each table is fixed data a dependency would only wrap.
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
character references ship packed in their own module, so entity decoding is complete without one. character references ship packed in their own module, so entity decoding is complete without one.
@@ -273,7 +273,7 @@ nodes that break it.
## Any ES2022 engine ## Any ES2022 engine
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share. 2026-09-01, the maintainer. Goal 8. Valid while ES2022 is the floor browsers and servers share.
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM. shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
@@ -287,13 +287,13 @@ never the higher one those repo-only tools want.
## ESM only ## ESM only
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules. 2026-08-23, the maintainer. Goal 8. Valid while the audience's toolchains all import ES modules.
No CommonJS build, no dual-package hazard. No CommonJS build, no dual-package hazard.
## One built entrypoint ## One built entrypoint
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`. 2026-08-23, the maintainer. Goal 8. Valid while Node refuses to type-strip under `node_modules`.
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
@@ -301,7 +301,7 @@ an npm consumer.
## Public on npm ## Public on npm
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source 2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 8. Valid while the package's source
stays public beside it. stays public beside it.
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public, Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
@@ -375,7 +375,7 @@ handles one cause alike whichever node, attribute or direction raised it.
## `message` and `path` ## `message` and `path`
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 4. Valid while a person fixing the 2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 5. Valid while a person fixing the
input reads `message`. input reads `message`.
- A message names the violation, not the rule alone — a rule by itself states a truth the reader - A message names the violation, not the rule alone — a rule by itself states a truth the reader
@@ -389,7 +389,7 @@ input reads `message`.
## Publish on a version bump ## Publish on a version bump
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm 2026-08-23, converging 2026-09-03, the maintainer. Goal 8. Valid while CI on `main` holds the npm
token. token.
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version `package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
@@ -405,7 +405,7 @@ depend on a store that the gate would then have to keep.
## Docs describe the release being built ## Docs describe the release being built
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes. 2026-09-16, the maintainer. Goal 8. Valid while a bump on `main` publishes.
Docs on `main` describe the release being built rather than the version npm holds, so they match it Docs on `main` describe the release being built rather than the version npm holds, so they match it
the moment the bump publishes; add no interim note marking the gap. the moment the bump publishes; add no interim note marking the gap.
@@ -421,7 +421,7 @@ nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
## The gate runs on Deno and Bun ## The gate runs on Deno and Bun
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 3 and 7. Valid while the library claims 2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 4 and 8. Valid while the library claims
any ES2022 engine. any ES2022 engine.
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
@@ -433,7 +433,7 @@ over the corpus rather than over a smoke import.
## The gate installs the tarball ## The gate installs the tarball
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package. 2026-09-03, the maintainer. Goal 8. Valid while consumers install the packed package.
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports` The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports`
and `types` are proved on the artifact that ships rather than on the source tree a self-reference and `types` are proved on the artifact that ships rather than on the source tree a self-reference
@@ -445,7 +445,7 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
## Firefox reads the build ## Firefox reads the build
2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs 2026-09-04, the maintainer. Goal 8. Valid while the library claims a browser and no other leg runs
SpiderMonkey. SpiderMonkey.
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
@@ -517,7 +517,7 @@ markdown, on a fixed seed in the gate; a counterexample found becomes a round-tr
## The CommonMark suite checks three ways ## The CommonMark suite checks three ways
2026-08-27, the maintainer. Goals 1 and 3. Valid while the suite's answers are HTML ADF cannot be 2026-08-27, the maintainer. Goals 1 and 4. Valid while the suite's answers are HTML ADF cannot be
compared against. compared against.
Each example is a named error or markdown that parses and emits to itself byte for byte; its Each example is a named error or markdown that parses and emits to itself byte for byte; its
@@ -564,12 +564,12 @@ once doubled the parser's frames per level.
## No value `JSON.parse` or `structuredClone` builds loops a call ## No value `JSON.parse` or `structuredClone` builds loops a call
2026-10-04, the maintainer. Goals 1 and 8. Valid while every persona hands in plain data, as 2026-10-04, the maintainer. Goals 1 and 9. Valid while every persona hands in plain data, as
`JSON.parse` or `structuredClone` builds it. `JSON.parse` or `structuredClone` builds it.
The guards walk a value by `Object.values` and refuse a cycle in it. An inherited or non-enumerable The guards walk a value by `Object.values` and refuse a cycle in it. An inherited or non-enumerable
key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one. key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one.
Goal 8's size counts ADF as JSON, so a shared object costs once per place it is held. Goal 9's size counts ADF as JSON, so a shared object costs once per place it is held.
## Nothing spreads an unbounded array ## Nothing spreads an unbounded array
@@ -589,7 +589,7 @@ termination is the loop's own check.
## Readers scan by index ## Readers scan by index
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 8. Valid while the pipeline persona 2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 9. Valid while the pipeline persona
feeds documents nobody typed. feeds documents nobody typed.
A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line
@@ -601,7 +601,7 @@ two cannot disagree — which is what makes the kept value a memo rather than a
## The spelling memo ## The spelling memo
2026-09-19, the maintainer. Goal 8. Valid while the `commonMarkSpelling` ask spells a node once 2026-09-19, the maintainer. Goal 9. Valid while the `commonMarkSpelling` ask spells a node once
per level above it otherwise. 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 plain reduction keep each node's readable spelling in a memo, so the
@@ -613,7 +613,7 @@ one. Only what succeeded is kept, so no path minted at another position is ever
## Cost fixes are measured, never timed ## Cost fixes are measured, never timed
2026-09-18, the maintainer and the stability-reviewer. Goal 8. Valid while Goal 8 promises growth 2026-09-18, the maintainer and the stability-reviewer. Goal 9. Valid while Goal 9 promises growth
rather than a figure. rather than a figure.
A cost fix that changes no behaviour lands on the suite staying green with no fixture output A cost fix that changes no behaviour lands on the suite staying green with no fixture output
@@ -632,7 +632,7 @@ refuses it — which is how the whitespace carry finds a line edge.
## Emphasis follows CommonMark's matching ## Emphasis follows CommonMark's matching
2026-08-27, the maintainer. Goals 1 and 3. Valid while CommonMark's emphasis rules are the 2026-08-27, the maintainer. Goals 1 and 4. Valid while CommonMark's emphasis rules are the
reader's. reader's.
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
@@ -641,7 +641,7 @@ only ones in play, and a pair that matching hands to another delimiter rides the
## Readable spellings take the `try` prefix ## Readable spellings take the `try` prefix
2026-09-21, the maintainer. Goals 1 and 5. Valid while a readable spelling's refusal would cost a 2026-09-21, the maintainer. Goals 1 and 6. Valid while a readable spelling's refusal would cost a
document the general form spells. document the general form spells.
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
@@ -314,6 +314,7 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n') assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n') assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
assert.equal(plain(paragraph(text('x', link('/u', 't\r\u0000')))), '[x](/u "t")\n') assert.equal(plain(paragraph(text('x', link('/u', 't\r\u0000')))), '[x](/u "t")\n')
assert.equal(plain(paragraph({ content: [text('dropped')], text: 'a', type: 'text' }, text('b'))), 'ab\n')
}) })
test('keeps a code span opening a line, whose backticks open no fence', () => { test('keeps a code span opening a line, whose backticks open no fence', () => {
+5 -5
View File
@@ -77,9 +77,9 @@ test('normalizes blocks and mark attributes nesting far past the levels a recurs
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab']) assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
}) })
test('joins a text node holding content to no neighbour, so its content is kept', () => { test('drops the content a text node holds, and joins the node as any other', () => {
const holding: AdfNode = { content: [{ text: 'kept', type: 'text' }], text: 'a', type: 'text' } const holding: AdfNode = { content: [{ text: 'dropped', type: 'text' }], text: 'a', type: 'text' }
for (const content of [[holding, { text: 'b', type: 'text' }], [{ text: 'b', type: 'text' }, holding]]) { const normal = (content: AdfNode[]): AdfNode[] | undefined => toEditorNormal({ content: [{ content, type: 'paragraph' }], type: 'doc', version: 1 }).content
assert.deepEqual(toEditorNormal({ content: [{ content, type: 'paragraph' }], type: 'doc', version: 1 }).content, [{ content, type: 'paragraph' }]) assert.deepEqual(normal([holding, { text: 'b', type: 'text' }]), [{ content: [{ text: 'ab', type: 'text' }], type: 'paragraph' }])
} assert.deepEqual(normal([{ text: 'b', type: 'text' }, holding]), [{ content: [{ text: 'ba', type: 'text' }], type: 'paragraph' }])
}) })
+4 -3
View File
@@ -7,9 +7,9 @@ type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] } type NodeHolder = { content?: AdfNode[] }
// The editor's rule is the reader's over the pair's editor-normal forms; a text node holding content never joins, so its content is kept. // The editor's rule is the reader's over the pair's editor-normal forms.
export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean { export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
if (previous.type !== 'text' || node.type !== 'text' || nodeContent(previous).length > 0 || nodeContent(node).length > 0) return false if (previous.type !== 'text' || node.type !== 'text') return false
return joinsWhenRead(normalNode(previous), normalNode(node)) return joinsWhenRead(normalNode(previous), normalNode(node))
} }
@@ -25,7 +25,8 @@ export function toEditorNormal(document: AdfDocument): AdfDocument {
if (content.length === 0) continue if (content.length === 0) continue
entry.holder.content = content.map((source) => { entry.holder.content = content.map((source) => {
const holder = normalNode(source) const holder = normalNode(source)
pending.push({ holder, source }) // Atlassian's schema allows a text node no content, so the editor keeps none.
if (source.type !== 'text') pending.push({ holder, source })
return holder return holder
}) })
} }
+102 -86
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 70` `Next ID = 74`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -24,38 +24,40 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score | | ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 | | 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
| 67 | 0.2.0 | defect | **Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.** | 1 | 1 | 4 | 9 | 1 | 24.8 | | 67 | 0.2.0 | defect | **Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.** | 1 | 1 | 4 | 9 | 1 | 24.8 |
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 | | 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 |
| 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 7 | 23.0 | | 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 8 | 23.0 |
| 66 | 0.2.0 | defect | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 | | 66 | 0.2.0 | defect | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 |
| 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 | | 71 | 0.2.0 | | **Compare the package with the other ADF converters in the README, each claim checked against that package's own docs or behaviour.** | 1 | 3 | 6 | 7 | 3 | 21.2 |
| 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 | 3, 4, 6 | 18.7 | | 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 |
| 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 | | 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 |
| 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 | 3, 4 | 17.2 | | 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 |
| 70 | 0.2.0 | | **Give `package.json` the `keywords` someone searching npm for an ADF converter types.** | 1 | 1 | 4 | 6 | 3 | 16.1 |
| 72 | 0.2.0 | | **Make GitHub the canonical repository, with `gitea.larvit.se/larvit/adf-codec` a read-only pull mirror of it.** | 5 | 6 | 5 | 8 | 3 | 16.0 |
| 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 |
| 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 | | 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 |
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 | | 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 6 | 11.5 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 | | 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 4, 5 | 10.9 |
| 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 |
| 60 | 0.2.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 | | 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 | | 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 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 | | 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 |
| 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 | | 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 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 4, 5, 7 | 9.0 |
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 3 | 26.0 |
| 73 | 0.3.0 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 6 | 8 | 3 | 23.0 |
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 3 | 21.0 |
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 | | 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 | | 48 | 0.3.0 | | **Keep the release path publishing past npm's bypass-2FA token retirement.** | 4 | 4 | 8 | 3 | 3 | 15.7 |
| 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 3, 4 | 12.6 | | 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 3, 8 | 14.8 |
| 42 | 0.3.0 | | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 8 | 12.5 | | 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 4, 5 | 11.3 |
| 48 | 0.3.0 | | **Keep the release path publishing past npm's bypass-2FA token retirement.** | 4 | 4 | 8 | 3 | 9 | 11.7 |
| 31 | 0.3.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 | | 31 | 0.3.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 |
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 | | 42 | 0.3.0 | | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 9 | 10.5 |
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
| 69 | 0.3.0 | question | **Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README.** | 2 | 2 | 3 | 5 | 6 | 7.9 |
| 56 | 0.3.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 | | 56 | 0.3.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 |
| 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 7 | 1.2 | | 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 8 | 0.6 |
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 7 | -2.2 | | 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 8 | -2.6 |
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 | | 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 3 | 18.6 |
## Details ## Details
@@ -99,6 +101,14 @@ typed array in `attrs`, as `structuredClone` keeps it, passes the guard and is w
key per element: `ok: true` with content lost. Accept an object whose prototype is key per element: `ok: true` with content lost. Accept an object whose prototype is
`Object.prototype` or `null`, and an array. Found by the stability review of item 63, 2026-10-04. `Object.prototype` or `null`, and an array. Found by the stability review of item 63, 2026-10-04.
### 71. Compare the package with the other ADF converters in the README, each claim checked against that package's own docs or behaviour.
Lands after item 7, whose HTML the comparison names. A table: which directions each converts,
whether the round-trip holds, what an unknown node does, the formats, runtime dependencies and
size. Candidates are the packages an npm search for ADF and markdown returns, marklassian among
them. Every cell cites that package's README or a run against its published version, since a
reader relies on it. Found while planning discoverability, 2026-10-04.
### 43. Give each markdown input its own reader, strict to its own standard. ### 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 Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
@@ -107,19 +117,6 @@ a code fence whose info string opens `adf:` becomes the block carry where Common
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour, 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. 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
`corpus/commonmark-spec/refusals.json`, a bot's `See ![diagram](url) here` among them. `mediaInline`
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 4, the lossless flavour reads both as
CommonMark does. An image inside a link: `[![moon](moon.jpg)](/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 5, which outranks Goal 6.
### 68. Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`. ### 68. Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.
`refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`) `refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`)
@@ -128,10 +125,41 @@ round-trips them; `docs/decisions.md` §The code list gives a cause the carry an
them from the emitting half of the README's `unsupported-node-shape` row. Found by the README-goals them from the emitting half of the README's `unsupported-node-shape` row. Found by the README-goals
audit, 2026-10-04. audit, 2026-10-04.
### 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
`corpus/commonmark-spec/refusals.json`, a bot's `See ![diagram](url) here` among them. `mediaInline`
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: `[![moon](moon.jpg)](/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.
### 70. Give `package.json` the `keywords` someone searching npm for an ADF converter types.
`package.json` has none, and npm's search ranks on them. Take the terms the other ADF converters on
npm list and the names the README uses — ADF, Atlassian Document Format, markdown, CommonMark, HTML
— so a search for any of them can reach the package. Found while planning discoverability,
2026-10-04.
### 72. Make GitHub the canonical repository, with `gitea.larvit.se/larvit/adf-codec` a read-only pull mirror of it.
The maintainer's call, 2026-10-04: GitHub is where an ADF converter is searched for, and the Gitea
copy keeps the move back open. The workflows move to `.github/workflows/` with `ci.sh` and
`publish.sh` unchanged; `package.json`'s `repository`, `bugs` and `homepage`, the README's,
CHANGELOG's and MIGRATION's links, and AGENTS.md's forge commands and merge style follow.
`docs/decisions.md` gains the entry "GitHub is canonical, and Gitea a pull mirror of it" (Goal 3),
and every entry naming Gitea Actions is revised. Creating the GitHub repository, its `NPM_TOKEN`
secret, and re-creating the Gitea copy as a mirror — Gitea converts a repository to a mirror only
that way — are the maintainer's.
### 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 both readers, and retire `!adf:listBreak`.
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads 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 4 settles it for the lossless flavour too: CommonMark spells 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 adjacent lists by changing the marker, so `adfToMarkdown` and `adfToPlainMarkdown` alternate `-` and
`*` between adjacent bullet lists and `.` and `)` between adjacent ordered lists, 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 `!adf:listBreak` retires. `*` is the maintainer's pick (2026-10-04). Breaking, so it ships beside
@@ -159,12 +187,6 @@ example 540); lowercasing and then uppercasing folds it. Breaking, so it ships b
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves `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. 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.
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table
gains its row.
### 60. Collect the questions `parse/` asks `emit/` into one named module. ### 60. Collect the questions `parse/` asks `emit/` into one named module.
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported `parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
@@ -177,14 +199,11 @@ Eight directive files sit across three directories, and the `markdown/` root hol
needs `Read<T>` and `Flavour` out of the plain flavour and `markdown/`; it needs the carry and the 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. mark spellings too, which item 7 moves where it learns what HTML shares.
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link. ### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
link mark, so a link whose text is empty takes its URL as its text (the maintainer, 2026-10-04): escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table
`[](/url)` reads as `/url` linked to `/url`. `[]()` has no URL to show; the chunk settles it by Goals gains its row.
3 and 6. 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.
### 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 plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.
@@ -194,10 +213,14 @@ the `inlineLeaves` use. Writing: `spellPlainBlock`, `quotedUnder`, `tryTaskList`
`plain/conventions.ts`, which `emit/` imports them from; item 59 moves `Flavour`. Every `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. comprehension reader on 2026-10-03 named the plain flavour's spread across three directories.
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode. ### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
already escape it. 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.
### 47. Open the README with what the package is, what it does and for whom. ### 47. Open the README with what the package is, what it does and for whom.
@@ -208,17 +231,16 @@ pair, `adfToPlainMarkdown` and `plainMarkdownToAdf`, and the flavours it writes
Flavored Markdown's alerts and task lists, Obsidian Flavored Markdown's callouts — so a search for Flavored Markdown's alerts and task lists, Obsidian Flavored Markdown's callouts — so a search for
any of these names finds the package. any of these names finds the package.
### 34. Read emphasis flanking by the whole character beside an astral symbol. ### 73. Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.
Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an Lands after `0.2.0` is published and items 71 and 72 land, so each post links the GitHub
astral symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as repository and the comparison. We draft each post for the place it goes; the maintainer posts
punctuation — and, where they do, read the code point, with a fixture per direction. them. Found while planning discoverability, 2026-10-04.
### 42. Trim a text leaf's trailing blanks in linear time. ### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
`plain/inline-reduction.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic `adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in already escape it.
`adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
### 48. Keep the release path publishing past npm's bypass-2FA token retirement. ### 48. Keep the release path publishing past npm's bypass-2FA token retirement.
@@ -234,6 +256,22 @@ added Gitea or self-hosted OIDC, and otherwise whether the release moves to the
which fits badly with publish-on-merge, and is the maintainer's trade to weigh. The Goals and G which fits badly with publish-on-merge, and is the maintainer's trade to weigh. The Goals and G
cells are provisional: no README goal covers the release path. cells are provisional: no README goal covers the release path.
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
kB.
### 34. Read emphasis flanking by the whole character beside an astral symbol.
Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an
astral symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as
punctuation — and, where they do, read the code point, with a fixture per direction.
### 31. Make the branch-coverage figure repeat across runs of an unchanged tree. ### 31. Make the branch-coverage figure repeat across runs of an unchanged tree.
Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and
@@ -245,33 +283,11 @@ moves upward, so the first raise to the measured figure reddens a run that chang
measurement repeatable, or state the number the floor may be raised to and why it is not the measurement repeatable, or state the number the floor may be raised to and why it is not the
measured one. measured one.
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`. ### 42. Trim a text leaf's trailing blanks in linear time.
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking `plain/inline-reduction.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
reduction's `spellableLine` drops one mark per re-emit the same way. An inline node whose `adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
attributes no spelling writes re-emits the line the same way before riding the carry. Make all
three linear.
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
kB.
### 69. Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README.
`nodeLeaves` (`plain/inline-reduction.ts`) keeps only a text node's `text`, so
`{ text: 'a', content: [{ text: 'kept', type: 'text' }] }` writes `a`, though `toEditorNormal`
keeps that content (`plain/editor-normal.ts`) and an unknown node's children keep their text.
Atlassian's schema allows no `content` on a text node, so no Atlassian reader saw it; an
export/indexing tool fed a looser producer loses searchable text. Goal 6 reads two ways here: keep
the children's text as an unknown node's, or name the drop in the README's plain section. Found by
the product-owner review, 2026-10-04.
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes. ### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.