Settle goals, guarantees, HTML scope and release automation in the spec docs #1

Merged
lilleman merged 3 commits from worktree-spec-decisions into main 2026-08-23 23:21:26 +02:00
3 changed files with 174 additions and 112 deletions
+104 -48
View File
@@ -1,69 +1,125 @@
# Working in this repo # Working in this repo
Decisions that a reader would otherwise relitigate. Everything about *using* the library is in Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or
`README.md`; what is still to build, and what is still undecided, is in `todo.md`. agent. Using the library: `README.md`. What is still to build: `todo.md`.
## 1. Two formats, never three ## 1. Three formats, ADF is the hub
ADF and one markdown flavour. **No HTML** — not as an output, not as an intermediate, not as a ADF, one markdown flavour, one HTML dialect. Six directions exposed, but markdown↔HTML compose
convenience export. A consumer that wants HTML renders the markdown itself, with its own escaping through ADF: four conversions exist to keep correct — never write a fifth. No fourth format, ever;
and its own stylesheet; a consumer that wants neither shows the markdown verbatim, which is what the each one doubles the directions.
first one does.
Three formats would mean six directions to keep lossless instead of two.
## 2. The round-trip is the product ## 2. The round-trip is the product
`markdownToAdf(adfToMarkdown(doc))` must equal `doc`. Anything less and a consumer that lets someone `markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
edit a ticket destroys what it could not represent — a panel, a mention, an attachment — in a less silently destroys content an editor could not represent, in a document it did not author.
document it did not author. When losslessness and readability conflict, losslessness wins.
That is why the flavour is *extended*: markdown has no syntax for most of what ADF holds, so the The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
flavour invents it. Designing that syntax is the first real task, and it is open (`todo.md`). back yields the library's canonical spelling, and that spelling round-trips byte-identically.
Two consequences to settle before any node is implemented, not after: Round-trip equality is a property tested over a corpus, not a claim made in prose.
- **What happens to a node the library does not know.** The documented ADF node set is not the whole ## 3. Unknown input policy
schema, and Atlassian adds to it. Whether an unknown node is carried opaquely, refused, or dropped
is a correctness decision for the whole library, and it decides the return shape of both functions.
- **Whether a lossless document must stay readable to a plain markdown reader.** Anything the flavour
invents is noise to a reader that does not know it. How much noise is acceptable bounds the syntax.
Round-trip equality is a property to test over a corpus, not a claim to make in prose. - 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.
## 3. Zero runtime dependencies ## 4. The flavour
Nothing in `dependencies`, ever. TypeScript and whatever the tests need are `devDependencies`, and - Directives, one grammar for everything markdown lacks: `:::panel info``:::` blocks,
they never reach a consumer. A markdown parser is exactly the dependency this rule exists to refuse: `:mention[@Mikael]{id=5b10a2}` inline. Prior art: CommonMark's generic-directives proposal.
the flavour is not CommonMark, so a general parser would have to be extended into one anyway. - 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.
## 4. The package contract ## 5. Dependencies
- **ESM only.** No CommonJS build, no dual-package hazard. `dependencies` is empty. A runtime dependency enters only through a decision entry here stating
- **Two entrypoints.** The built JavaScript for ordinary consumers, and the TypeScript source for why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
consumers that run TypeScript directly through Node's type stripping — the first consumer is one, CommonMark and HTML parsers are written in this repo. `devDependencies`: few, each earning its
which is why this exists. keep; they never reach a consumer.
- **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 — with the 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.
## 5. Nothing about any consumer ## 6. The package contract
No Jira, no HTTP, no REST response shapes, no plainpages, no issue keys. The library takes a document - ESM only — no CommonJS build, no dual-package hazard.
tree and returns a string, or the reverse. A consumer's concern that leaks in here is a seam nobody - Two entrypoints: built JavaScript, and TypeScript source for Node's type stripping. Types for
declared — and the reason this is a library at all rather than a file in the client that needed it. 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`.
## 6. Tests first, in Docker ## 7. Nothing about any consumer
Write the test for the behaviour wanted, then implement until it passes. `node --test`, beside the No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design
code. Node, tsc and npm never run on the host — a compose service or a `docker run` against a against the README's personas.
**full patch version** image tag (`node:24.19.0-alpine3.24`, never `node:24`), so the same commit
builds the same thing on a different day.
## 7. Style ## 8. Semver: the formats are API
Two-space indent, alphabetically sorted object keys, strict TypeScript. Failures are values, not The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
exceptions: a function that both returns a result and throws for some inputs has two error channels. 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 `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
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 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 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.
- 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
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 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, ever.
## 14. Non-goals
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.
+44 -27
View File
@@ -1,42 +1,59 @@
# @larvit/atlassian-adf-converter # @larvit/atlassian-adf-converter
Lossless conversion between **Atlassian Document Format** (ADF) and an extended markdown flavour Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
that can carry the nodes plain markdown has no syntax for. an HTML dialect.
**Status: specification only. No code is implemented yet.** `todo.md` holds the plan and the design **Status: specification only, no code yet.** Plan: `todo.md`. Decisions: `AGENTS.md`.
questions still open; `AGENTS.md` holds the decisions already made.
## What it is for ## What it is for
Jira Cloud's REST v3 API hands out issue descriptions and comment bodies as ADF — a JSON node tree, Atlassian Cloud REST APIs hand out rich text — issue descriptions, comments, pages — as ADF, a
ProseMirror-shaped and takes them back the same way. There is no Atlassian endpoint that converts ProseMirror-shaped JSON tree, and take it back the same way. No Atlassian endpoint converts it
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
represent.
A client that shows a ticket and lets someone edit it needs both directions, and needs them ## The shape
lossless — otherwise saving an edit silently destroys the panels, mentions and attachments that were
in someone else's ticket. That is what this library is.
## The intended shape Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compose through it.
Two pure functions and their types. No I/O, no network, no configuration:
```ts ```ts
adfToMarkdown(document: AdfDocument): string adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): AdfDocument markdownToAdf(markdown: string): Result<AdfDocument>
adfToHtml(doc: AdfDocument): Result<string>
htmlToAdf(html: string): Result<AdfDocument>
markdownToHtml(markdown: string): Result<string> // via ADF
htmlToMarkdown(html: string): Result<string> // via ADF
isAdfDocument(v: unknown): v is AdfDocument
``` ```
The published package is ESM only, has **no runtime dependencies**, and offers two entrypoints — the `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
built JavaScript for ordinary consumers, and the TypeScript source for consumers that run TypeScript
directly (Node's type stripping), with exported types either way. `AGENTS.md` §4 has the contract.
## The first consumer ## The guarantees
[`plainpages-plugin-fastjira`](https://gitea.larvit.se/larvit/plainpages-plugin-fastjira) — a - `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
server-rendered Jira client. Its read-only ticket view shows the markdown this library produces (AGENTS.md §3).
verbatim, with no HTML rendering anywhere; its later write paths post back what this library - `htmlToAdf(adfToHtml(doc))` equals `doc` — fidelity HTML cannot express rides `data-*`
converts the other way. **That view is blocked on `0.1.0`,** and it needs `adfToMarkdown` first. attributes.
- 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).
The library knows nothing about that consumer. No Jira, no HTTP, no REST shapes, no plainpages — a ## Who it is for
document tree in, a string out, and the reverse.
Personas, never named consumers (AGENTS.md §7):
- **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, public npmjs. Two entrypoints — built JavaScript, and
TypeScript source for Node's type stripping — with types either way. Contract: `AGENTS.md` §56.
+26 -37
View File
@@ -1,49 +1,38 @@
# Todo # Todo
The plan, in order. Nothing here is built yet. The plan, in order. Nothing is built. Design questions are settled in `AGENTS.md`; remaining spec
detail is settled at its own milestone.
## Open design questions — settle these first
None of them have an answer yet, and each one changes what every later milestone implements.
- [ ] **The flavour's syntax.** Markdown has no syntax for most of ADF. Every node in the inventory
below that is not plain markdown needs one, and the set has to be internally consistent rather
than invented node by node. Prior art worth reading before choosing: CommonMark's generic
directives proposal, MDX, Obsidian's and Pandoc's extensions, and what Atlassian's own
`editor-markdown-transformer` does (it is lossy — read it for the failure modes, not the design).
- [ ] **The unknown-node policy** (AGENTS.md §2). Carried opaquely, refused, or dropped — it decides
the return shape of both functions, so it cannot be retrofitted.
- [ ] **How readable a converted document must stay** to a reader that does not know the flavour.
- [ ] **Whether plain CommonMark is valid input** to `markdownToAdf`. A human typing ordinary
markdown into a comment box is the second consumer's whole write path.
- [ ] **Table fidelity.** ADF tables carry column widths, colspan, rowspan, header rows and cell
background colours; markdown tables carry none of it.
- [ ] **Identity-bearing nodes.** `mention` holds an account id, `media` an attachment id, `emoji` a
shortcode plus an id. The rendered text is not enough to reconstruct them, so the syntax has to
carry the id — and then a document is only portable within the site it came from.
## Milestones ## Milestones
- [ ] **0 — Scaffold.** `package.json` with the §4 contract, `tsconfig.json`, `.npmrc`, LICENSE (MIT, - [ ] **0 — Scaffold.** `package.json` per §6, `tsconfig.json`, `.npmrc` (`save-exact=true`), the
Larv IT AB), the Docker tooling setup, and `.gitea/workflows/ci.yml` gating branches. Mirror Docker tooling, `renovate.json` (§9), and `.gitea/workflows/ci.yml` gating branches:
`plainpages` for the workflow shape: `runs-on: docker-host`, actions pinned to semver tags. `runs-on: docker-host`, actions pinned to semver tags.
- [ ] **1 — Settle the flavour.** Write the syntax down as this repo's specification before - [ ] **1 — The flavour spec.** The markdown flavour written as this repo's specification before
implementing it, and make the round-trip corpus from it. any implementation: the directive grammar (attributes, escaping, nesting), each node's
- [ ] **2 — `adfToMarkdown`.** The direction the first consumer needs. Ships `0.1.0`. syntax from the inventory below, the opaque-carry spelling, the pipe-vs-directive table
- [ ] **3 — `markdownToAdf`.** rule, and what CommonMark's raw-HTML constructs become in ADF, which has no raw-HTML node —
- [ ] **4 — Round-trip property tests** over a corpus of real Jira documents, both ways. Not a likely the §3 element mapping, error otherwise. Start the corpus (§10) from this spec.
milestone that follows 2 and 3 so much as the thing that proves them. - [ ] **2 — `adfToMarkdown`.**
- [ ] **5Release pipeline.** Tag-triggered publish to public npmjs, `NPM_TOKEN` secret, the repo - [ ] **3`markdownToAdf`.** The CommonMark parser is the largest single component.
made public with the LICENSE in place first (AGENTS.md §4). - [ ] **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`, the composed `markdownToHtml` /
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10).
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
## 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, and see the unknown-node policy above. syntax, the rest rides the opaque carry (§3) until it does too.
| | | | | |
| --- | --- | | --- | --- |
@@ -52,6 +41,6 @@ ceiling, and see the unknown-node policy above.
| 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.