Write down what the converter must be, before any of it is built

This commit is contained in:
2026-08-23 17:32:17 +02:00
commit 07ec80efc5
5 changed files with 190 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# Working in this repo
Decisions that a reader would otherwise relitigate. Everything about *using* the library is in
`README.md`; what is still to build, and what is still undecided, is in `todo.md`.
## 1. Two formats, never three
ADF and one markdown flavour. **No HTML** — not as an output, not as an intermediate, not as a
convenience export. A consumer that wants HTML renders the markdown itself, with its own escaping
and its own stylesheet; a consumer that wants neither shows the markdown verbatim, which is what the
first one does.
Three formats would mean six directions to keep lossless instead of two.
## 2. The round-trip is the product
`markdownToAdf(adfToMarkdown(doc))` must equal `doc`. Anything less and a consumer that lets someone
edit a ticket destroys what it could not represent — a panel, a mention, an attachment — in a
document it did not author.
That is why the flavour is *extended*: markdown has no syntax for most of what ADF holds, so the
flavour invents it. Designing that syntax is the first real task, and it is open (`todo.md`).
Two consequences to settle before any node is implemented, not after:
- **What happens to a node the library does not know.** The documented ADF node set is not the whole
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.
## 3. Zero runtime dependencies
Nothing in `dependencies`, ever. TypeScript and whatever the tests need are `devDependencies`, and
they never reach a consumer. A markdown parser is exactly the dependency this rule exists to refuse:
the flavour is not CommonMark, so a general parser would have to be extended into one anyway.
## 4. The package contract
- **ESM only.** No CommonJS build, no dual-package hazard.
- **Two entrypoints.** The built JavaScript for ordinary consumers, and the TypeScript source for
consumers that run TypeScript directly through Node's type stripping — the first consumer is one,
which is why this exists.
- **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
No Jira, no HTTP, no REST response shapes, no plainpages, no issue keys. The library takes a document
tree and returns a string, or the reverse. A consumer's concern that leaks in here is a seam nobody
declared — and the reason this is a library at all rather than a file in the client that needed it.
## 6. 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 a `docker run` against a
**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
Two-space indent, alphabetically sorted object keys, strict TypeScript. Failures are values, not
exceptions: a function that both returns a result and throws for some inputs has two error channels.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Larv IT AB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
# @larvit/atlassian-adf-converter
Lossless conversion between **Atlassian Document Format** (ADF) and an extended markdown flavour
that can carry the nodes plain markdown has no syntax for.
**Status: specification only. No code is implemented yet.** `todo.md` holds the plan and the design
questions still open; `AGENTS.md` holds the decisions already made.
## What it is for
Jira Cloud's REST v3 API hands out issue descriptions and comment bodies as ADF — a JSON node tree,
ProseMirror-shaped — and takes them 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 client that shows a ticket 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 ticket. That is what this library is.
## The intended shape
Two pure functions and their types. No I/O, no network, no configuration:
```ts
adfToMarkdown(document: AdfDocument): string
markdownToAdf(markdown: string): AdfDocument
```
The published package is ESM only, has **no runtime dependencies**, and offers two entrypoints — the
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
[`plainpages-plugin-fastjira`](https://gitea.larvit.se/larvit/plainpages-plugin-fastjira) — a
server-rendered Jira client. Its read-only ticket view shows the markdown this library produces
verbatim, with no HTML rendering anywhere; its later write paths post back what this library
converts the other way. **That view is blocked on `0.1.0`,** and it needs `adfToMarkdown` first.
The library knows nothing about that consumer. No Jira, no HTTP, no REST shapes, no plainpages — a
document tree in, a string out, and the reverse.
+57
View File
@@ -0,0 +1,57 @@
# Todo
The plan, in order. Nothing here is built yet.
## 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
- [ ] **0 — Scaffold.** `package.json` with the §4 contract, `tsconfig.json`, `.npmrc`, LICENSE (MIT,
Larv IT AB), the Docker tooling setup, and `.gitea/workflows/ci.yml` gating branches. Mirror
`plainpages` for the workflow shape: `runs-on: docker-host`, actions pinned to semver tags.
- [ ] **1 — Settle the flavour.** Write the syntax down as this repo's specification before
implementing it, and make the round-trip corpus from it.
- [ ] **2 — `adfToMarkdown`.** The direction the first consumer needs. Ships `0.1.0`.
- [ ] **3 — `markdownToAdf`.**
- [ ] **4 — Round-trip property tests** over a corpus of real Jira documents, both ways. Not a
milestone that follows 2 and 3 so much as the thing that proves them.
- [ ] **5 — Release pipeline.** Tag-triggered publish to public npmjs, `NPM_TOKEN` secret, the repo
made public with the LICENSE in place first (AGENTS.md §4).
## 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`,
`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, and see the unknown-node policy above.
| | |
| --- | --- |
| Top-level block | `blockquote` `bodiedSyncBlock` `bulletList` `codeBlock` `expand` `heading` `mediaGroup` `mediaSingle` `multiBodiedExtension` `orderedList` `panel` `paragraph` `rule` `syncBlock` `table` |
| Child block | `blockTaskItem` `extensionFrame` `listItem` `media` `nestedExpand` `tableCell` `tableHeader` `tableRow` |
| 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`,
`paragraph`, `rule`, `listItem`, `hardBreak`, `text`, and the `code`, `em`, `link`, `strike` and
`strong` marks. Everything else is what the flavour is for.