Extend prose-minimum rule to all markdown files and apply it to the docs

This commit is contained in:
2026-08-23 23:20:46 +02:00
parent 359c9a0083
commit 61d6fca105
3 changed files with 122 additions and 173 deletions
+76 -109
View File
@@ -1,158 +1,125 @@
# Working in this repo
Decisions a reader would otherwise relitigate, and the rules every collaborator human or agent —
works under. Everything about *using* the library is in `README.md`; what is still to build is in
`todo.md`.
Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or
agent. Using the library: `README.md`. What is still to build: `todo.md`.
## 1. Three formats, ADF is the hub
ADF, one markdown flavour, one HTML dialect. Six directions are exposed, but markdown↔HTML compose
through ADF, so four conversions exist to keep correct — never write a fifth. A fourth format
(wiki markup, anything else) is refused: each one doubles the directions again.
ADF, one markdown flavour, one HTML dialect. Six directions exposed, but markdown↔HTML compose
through ADF: four conversions exist to keep correct — never write a fifth. No fourth format, ever;
each one doubles the directions.
## 2. The round-trip is the product
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc`. Anything less
and a consumer that lets someone edit a document destroys what it could not represent — a panel, a
mention, an attachment — in a document it did not author.
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must 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.
When losslessness and readability conflict, losslessness wins. A rare node may look ugly; nothing
is ever dropped for looks.
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.
The other direction is a **canonical fixpoint**, not byte-identity: human markdown normalizes
(`*em*` and `_em_` become one ADF doc), converting back yields the library's canonical spelling,
and that spelling round-trips byte-identically. Byte-identity both ways would mean storing the
author's formatting choices in ADF, which has no place for them.
Round-trip equality is a property to test over a corpus, not a claim to make in prose.
Round-trip equality is a property tested over a corpus, not a claim made in prose.
## 3. Unknown input policy
- **Unknown ADF node** (Atlassian ships undocumented types and adds more): carried opaquely — the
raw JSON rides a dedicated syntax in markdown and HTML, and converts back byte-for-byte. The
round-trip holds even for documents newer than the library.
- **Unmappable foreign HTML element**: error result naming the element. Arbitrary HTML can express
what ADF cannot hold, so import maps a documented element set and refuses the rest — never a
silent drop.
- **Bare `@name` / `:smile:` typed as plain text**: stays a text node. Only the directive syntax
produces mention/emoji/media nodes — resolving names to ids needs I/O, which is the consumer's
job (e.g. an autocomplete that inserts the directive).
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats, restored
byte-for-byte. The round-trip holds for documents newer than the library.
- Unmappable foreign HTML element: error result naming the element — never a silent drop.
- Bare `@name` / `:smile:` in typed text: stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
## 4. The flavour
- **Directives**, one grammar for everything markdown lacks: `:::panel info``:::` for blocks,
`:mention[@Mikael]{id=5b10a2}` style inline. Prior art: CommonMark's generic-directives proposal.
- **Plain CommonMark is a subset.** Anything a human types converts per the spec; the flavour only
adds syntax, never changes CommonMark meaning.
- **Tables**: a simple table (one header row, plain inline cells — no spans, widths, colours, block
content) emits a pipe table; anything richer emits the directive form.
- **Identity-bearing nodes** (`mention`, `media`, `emoji`) carry their ids in attributes. A
converted document is therefore only portable within the site it came from — accepted.
The HTML dialect mirrors this: semantic elements, stable `adf-*` class names, `data-*` attributes
for what HTML cannot express, text content always escaped. No stylesheet ships — styling is the
consumer's.
- Directives, one grammar for everything markdown lacks: `:::panel info``:::` blocks,
`:mention[@Mikael]{id=5b10a2}` inline. Prior art: CommonMark's generic-directives proposal.
- Plain CommonMark is a subset: the flavour adds syntax, never changes CommonMark meaning.
- Tables: one header row plus plain inline cells → pipe table; anything richer → directive form.
- Identity-bearing nodes carry their ids in attributes; a document is only portable within its
site — accepted.
- The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML
cannot express, text always escaped. No stylesheet ships.
## 5. Dependencies
`dependencies` is empty. A runtime dependency may enter only through a decision entry here stating
why ~20 lines of our own code cannot do the job, who maintains it, and what auditing it costs;
until that entry exists the answer is no. So the CommonMark and HTML parsers are written in this
repo — a general parser would have to be extended into the flavour anyway.
`devDependencies`: few, each earning its keep. Tooling that genuinely helps (a property-test
generator) is welcome; it never reaches a consumer.
`dependencies` is empty. A runtime dependency enters only through a decision entry here stating
why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
CommonMark and HTML parsers are written in this repo. `devDependencies`: few, each earning its
keep; they never reach a consumer.
## 6. The package contract
- **ESM only.** No CommonJS build, no dual-package hazard.
- **Two entrypoints.** Built JavaScript for ordinary consumers; TypeScript source for consumers
running TypeScript directly through Node's type stripping.
- **Types for both.** The JavaScript entrypoint ships `.d.ts` beside it; the TypeScript entrypoint
is its own types.
- **Published to public npmjs as `@larvit/atlassian-adf-converter`**, matching `@larvit/log`.
Public means the source is public: the Gitea repo starts private, and going public — LICENSE in
place — is a step before the first publish, not after it.
- **Exact versions.** `save-exact=true` in `.npmrc`, as in every other repo here.
- ESM only — no CommonJS build, no dual-package hazard.
- Two entrypoints: built JavaScript, and TypeScript source for Node's type stripping. Types for
both (`.d.ts` beside the JavaScript).
- Published to public npmjs as `@larvit/atlassian-adf-converter`. Public source: the Gitea repo
goes public, LICENSE in place, before the first publish.
- Exact versions: `save-exact=true` in `.npmrc`.
## 7. Nothing about any consumer
No Jira client, no HTTP, no REST response shapes, no issue keys — and no actual consumer named
anywhere in this repo. Design against the README's personas, not against a product. A consumer's
concern that leaks in here is a seam nobody declared.
No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design
against the README's personas.
## 8. Semver: the formats are API
The emitted markdown and HTML are contracts, not just the TypeScript surface. After 1.0: a change
that makes previously-emitted output parse differently, or stop parsing, is MAJOR; teaching the
parsers new syntax while old output still round-trips is MINOR. Documents in flight — stored,
cached, mid-edit — survive upgrades. Pre-1.0, normal 0.x rules apply.
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
Pre-1.0, normal 0.x rules.
## 9. Release automation
- `package.json` version on `main` is the source of truth. CI on every `main` push: tests green
and version differs from npm's latest → publish and tag `vX.Y.Z`. A merge without a bump (docs,
CI, dep bumps) deploys nothing; the bump in each shipping PR is a deliberate semver judgment.
- **Renovate** watches npm devDependencies, Docker image pins and action tags, and automerges
every update — majors included — when CI is green.
- Docker images pin the **full patch version** (`node:24.19.0-alpine3.24`, never `node:24`);
actions pin semver tags. Renovate raises the bumps as reviewable PRs.
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's
deliberate semver judgment.
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
- Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`); actions
pin semver tags.
## 10. Tests first, in Docker
Write the test for the behaviour wanted, then implement until it passes. `node --test`, beside the
code. Node, tsc and npm never run on the host — a compose service or `docker run` against a pinned
image (§9). Tests are independent: none passes because another ran first. Coverage does not
decline. Tear down test containers after a run.
Test for the behaviour wanted first, then implement until green. `node --test`, beside the code.
Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent,
coverage does not decline, containers are torn down after a run.
The corpus, all checked in: hand-built fixtures per node and combination; real (sanitized) ADF
captured from live Atlassian APIs, including undocumented nodes; property-based generated ADF trees
(generator is a devDependency); the official CommonMark spec suite, run against `markdownToAdf`
(mapped) and `markdownToHtml`.
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against
`markdownToAdf` and `markdownToHtml`.
## 11. Code rules
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order for object keys,
lists and file lists wherever order carries no meaning.
- **Failures are values.** Every conversion returns
`Result<T>``{ ok: true; value } | { ok: false; error: ConvertError }` and nothing throws.
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
carries no meaning.
- Failures are values: everything returns
`Result<T>``{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
the spot; never for control flow.
- **No casts.** `as`, `as unknown as` and non-null `!` are banned — they silence the compiler
exactly where it is needed. A boundary owes a type guard validating the fields it claims (that
is what `isAdfDocument` is); past it everything is typed. Make invalid states unrepresentable:
encode "one of these, never both" in the types, not in checks.
- Explicit over implicit. Descriptive names (`panelDirectiveParser`, not `helper`); no catch-all
files or folders (`utils`, `helpers`, `misc`, `common`, `lib`).
- **Reuse before adding.** Extend an existing test or function before writing a new one; the
smallest sufficient diff is the benchmark. No speculative generality: an interface with one
implementation, an option with one used value, or a wrapper that only forwards waits for its
second consumer — or goes.
the spot.
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable.
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`).
- Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
a second consumer, or it goes.
## 12. Prose to a minimum
Stricter here than most repos. Applies to comments, docs and PR text alike.
Applies everywhere: comments, every markdown file in this repo (this one included), PR text.
- **Default is no comment.** One earns its single line only by naming an invariant, footgun or
external constraint the code cannot show. Never a restatement, never history ("we used to…"),
never why something is absent, never how a file is arranged. A second line means it belongs in
the commit message or a decision entry here.
- Every prose comment in a diff is a review question, and the reviewer's default answer is delete.
- A doc paragraph clears the same bar: it says what the repo cannot say for itself, or it goes —
and the fix for a redundant one is deletion, not trimming. A false claim in any doc is a bug,
fixed in the change that finds it.
- Published text — the npm README, exported error messages, API docs — never references internal
systems, tickets or repos.
- Default is no comment. One earns its single line only by naming an invariant, footgun or
external constraint the code cannot show — never restatement, history, absence or arrangement.
A second line belongs in the commit message or a decision entry here.
- Every prose comment in a diff is a review question; the default answer is delete.
- A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant
one is deletion, not trimming. A false claim in any doc is a bug, fixed where found.
- Published text — npm README, error messages, API docs — never references internal systems,
tickets or repos.
## 13. Commits and PRs
One-line commit messages and PR titles; short PR summaries. No AI-attribution markers
`Co-Authored-By` bots, "Generated with …" footers — in commits, PRs or comments, ever.
One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever.
## 14. Non-goals
Stated so nobody builds them in: no wiki markup (§1), no network or filesystem I/O, no name→id
resolution (§3), no ADF schema validation or exported validator — whether Atlassian accepts a tree
is Atlassian's call — no shipped CSS (§4), no streaming APIs and no performance budget: conversions
are O(n) with no pathological blowups, and real documents are kilobytes. A CLI is a later goal
(`todo.md`), not a non-goal.
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema
validation or exported validator, no shipped CSS (§4), no streaming APIs, no performance budget —
conversions are O(n), real documents are kilobytes. A CLI is a later goal (`todo.md`), not a
non-goal.
+25 -38
View File
@@ -3,25 +3,21 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect.
**Status: specification only. No code is implemented yet.** `todo.md` holds the plan; `AGENTS.md`
holds the decisions already made.
**Status: specification only, no code yet.** Plan: `todo.md`. Decisions: `AGENTS.md`.
## What it is for
Atlassian Cloud REST APIs (Jira v3, Confluence) hand out rich text — issue descriptions, comments,
pages — as ADF, a JSON node tree, ProseMirror-shaped, and take it back the same way. There is no
Atlassian endpoint that converts it: `pf-editor-service/convert` was decommissioned and
[JRACLOUD-77436](https://jira.atlassian.com/browse/JRACLOUD-77436) is still an open request. The npm
ecosystem covers one direction each, drops what markdown cannot express, and none of it round-trips.
A consumer that shows a document and lets someone edit it needs both directions, and needs them
lossless — otherwise saving an edit silently destroys the panels, mentions and attachments that were
in someone else's document. That is what this library is.
Atlassian Cloud REST APIs hand out rich text — issue descriptions, comments, pages — as ADF, a
ProseMirror-shaped JSON tree, and take it back the same way. No Atlassian endpoint converts it
(`pf-editor-service/convert` is decommissioned,
[JRACLOUD-77436](https://jira.atlassian.com/browse/JRACLOUD-77436) open), and the npm ecosystem is
one-directional and lossy. A consumer that shows a document and lets someone edit it needs both
directions lossless — otherwise saving destroys the panels, mentions and attachments it could not
represent.
## The shape
Pure functions and their types. No I/O, no network, no configuration. ADF is the hub: the
markdown↔HTML directions compose through it.
Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compose through it.
```ts
adfToMarkdown(doc: AdfDocument): Result<string>
@@ -37,36 +33,27 @@ isAdfDocument(v: unknown): v is AdfDocument
## The guarantees
- **`markdownToAdf(adfToMarkdown(doc))` equals `doc`**including node types the library has never
seen, which are carried opaquely (AGENTS.md §3).
- **`htmlToAdf(adfToHtml(doc))` equals `doc`** — fidelity HTML cannot express rides `data-*`
- `markdownToAdf(adfToMarkdown(doc))` equals `doc`unknown node types included, carried opaquely
(AGENTS.md §3).
- `htmlToAdf(adfToHtml(doc))` equals `doc` — fidelity HTML cannot express rides `data-*`
attributes.
- **Plain CommonMark is valid input** to `markdownToAdf`: any ordinary markdown a human types
converts per the CommonMark spec. Converting back yields the library's canonical spelling, which
then round-trips byte-identically.
- **Foreign HTML** (not emitted by this library) maps a documented element set; an unmappable
element is an error result, never a silent drop. The parser takes well-formed HTML, not
WHATWG tag-soup recovery.
- **The emitted formats are semver surface** (AGENTS.md §8): after 1.0, output an old version
emitted always parses under a newer one within the same major.
- Plain CommonMark is valid input to `markdownToAdf`; converting back yields the library's
canonical spelling, which round-trips byte-identically.
- Foreign HTML maps a documented element set; an unmappable element is an error, never a silent
drop. Well-formed HTML only — no tag-soup recovery.
- The emitted formats are semver surface (AGENTS.md §8).
## Who it is for
No actual consumer is named here or anywhere in this repo (AGENTS.md §7). The personas the design
serves:
Personas, never named consumers (AGENTS.md §7):
- **A viewer/editor app** — shows a document as markdown or HTML, lets a human edit, posts the
result back. Needs losslessness above all.
- **A bot posting content** — generates ordinary markdown (templates, LLM output) and converts it
to ADF. Needs the CommonMark input promise; never reads ADF back.
- **An export/indexing tool** — bulk-converts ADF to markdown or HTML for archives, search, static
sites. Read-only; needs readable output.
- **An LLM/agent pipeline** — feeds documents to a model as markdown, converts the model's edits
back. Needs the round-trip plus markdown that stays legible to a reader that half-knows the
flavour.
- **Viewer/editor app** — shows a document, lets a human edit, posts back. Losslessness above all.
- **Bot posting content** — converts generated markdown to ADF; needs the CommonMark promise.
- **Export/indexing tool** — bulk ADF→markdown/HTML; needs readable output.
- **LLM/agent pipeline** — documents to a model as markdown, edits back; needs the round-trip and
markdown legible to a reader that half-knows the flavour.
## The package
ESM only, **no runtime dependencies**, published to public npmjs. Two entrypoints — built
JavaScript for ordinary consumers, TypeScript source for consumers running Node's type stripping —
with exported types either way. `AGENTS.md` §56 have the contract.
ESM only, no runtime dependencies, public npmjs. Two entrypoints — built JavaScript, and
TypeScript source for Node's type stripping — with types either way. Contract: `AGENTS.md` §56.
+21 -26
View File
@@ -1,43 +1,38 @@
# Todo
The plan, in order. Nothing here is built yet. The design questions that used to be open are
settled in `AGENTS.md`; what remains open below is spec detail, settled at its own milestone.
The plan, in order. Nothing is built. Design questions are settled in `AGENTS.md`; remaining spec
detail is settled at its own milestone.
## Milestones
- [ ] **0 — Scaffold.** `package.json` with the §6 contract, `tsconfig.json`, `.npmrc`
(`save-exact=true`), the Docker tooling setup, `renovate.json` (automerge-on-green, §9), and
`.gitea/workflows/ci.yml` gating branches: `runs-on: docker-host`, actions pinned to semver
tags.
- [ ] **1 — The flavour spec.** Write the markdown flavour down as this repo's specification before
implementing it: the exact directive grammar (attributes, escaping, nesting), each node's
syntax from the inventory below, the opaque-carry spelling for unknown nodes, and the
pipe-vs-directive table rule. Open detail to settle here: what CommonMark's raw-HTML
constructs become in ADF, which has no raw-HTML node — likely the §3 element mapping, error
otherwise. Start the corpus (§10) from this spec.
- [ ] **0 — Scaffold.** `package.json` per §6, `tsconfig.json`, `.npmrc` (`save-exact=true`), the
Docker tooling, `renovate.json` (§9), and `.gitea/workflows/ci.yml` gating branches:
`runs-on: docker-host`, actions pinned to semver tags.
- [ ] **1 — The flavour spec.** The markdown flavour written as this repo's specification before
any implementation: the directive grammar (attributes, escaping, nesting), each node's
syntax from the inventory below, the opaque-carry spelling, the pipe-vs-directive table
rule, and what CommonMark's raw-HTML constructs become in ADF, which has no raw-HTML node —
likely the §3 element mapping, error otherwise. Start the corpus (§10) from this spec.
- [ ] **2 — `adfToMarkdown`.**
- [ ] **3 — `markdownToAdf`.** The CommonMark parser is the largest single component.
- [ ] **4 — Round-trip property tests** over the corpus, both ways. Not a milestone that follows
2 and 3 so much as the thing that proves them.
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change on `main` (§9),
`NPM_TOKEN` secret, the repo made public first (§6). `0.1.0` is the markdown round-trip:
both markdown directions, the types, `isAdfDocument`.
- [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and 3.
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
the repo made public first (§6). `0.1.0` is the markdown round-trip: both markdown
directions, the types, `isAdfDocument`.
- [ ] **6 — The HTML dialect spec.** Element-by-element mapping, the `data-*` fidelity scheme, the
opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.2.0`.** `adfToHtml`, `htmlToAdf`, and the composed `markdownToHtml` /
- [ ] **7 — HTML, ship `0.2.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10).
- [ ] **8 — CLI.** A later goal, deliberately unspecified until the library exists: shape it
around the personas then.
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
## The ADF inventory to cover
From Atlassian's [structure
reference](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/). **It is
not the whole schema** — real payloads also carry `taskList`/`taskItem`, `decisionList`/`decisionItem`,
reference](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) — not the
whole schema: real payloads also carry `taskList`/`taskItem`, `decisionList`/`decisionItem`,
`layoutSection`/`layoutColumn`, `blockCard`/`embedCard`, `extension`/`bodiedExtension`/`inlineExtension`
and `placeholder`, none of which are documented there. Treat the documented set as the floor, not the
ceiling — the floor gets designed syntax, everything else rides the opaque carry (§3) until it too
gets syntax.
and `placeholder`, none documented there. The documented set is the floor: the floor gets designed
syntax, the rest rides the opaque carry (§3) until it does too.
| | |
| --- | --- |
@@ -46,6 +41,6 @@ gets syntax.
| Inline | `date` `emoji` `hardBreak` `inlineCard` `mediaInline` `mention` `status` `text` |
| Marks | `border` `code` `em` `link` `strike` `strong` `subsup` `textColor` `underline` |
Plain markdown already covers `blockquote`, `bulletList`, `codeBlock`, `heading`, `orderedList`,
Plain markdown covers `blockquote`, `bulletList`, `codeBlock`, `heading`, `orderedList`,
`paragraph`, `rule`, `listItem`, `hardBreak`, `text`, and the `code`, `em`, `link`, `strike` and
`strong` marks. Everything else is what the flavour is for.