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