Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ In `docs/decisions.md`:
- The comparison tables list every ADF converter that starts and converts
- The real-world corpus holds anonymized documents from sites whose licence it records
- The feature table is a section per corpus example, led by a summary table
- Plain text is blocks a blank line apart, with `• ` and `1.` markers and nesting indented

## 1. Nothing about any consumer in the library

Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

## Unreleased

- `readAdfDocument` and the three writers name the node `not-an-adf-document` refuses: `path` leads
- `adfToPlainText` writes a document as text with no markup, for a search index, a notification or an
email: blocks a blank line apart; `• ` and `1.` list markers, `☑ ` and `☐ ` for a done and an open
task, nesting indented; a link's address and title in parentheses after its text; a hard break as a
line feed; and a mention, emoji, date or status as the text it shows. It refuses what `adfToHtml`
refuses. A table is written as its cells, as blocks, for now.
- `readAdfDocument` and the four writers name the node `not-an-adf-document` refuses: `path` leads
to a node that is not an ADF node, and the message says what is wrong with it, such as which
attribute holds a value that is not JSON. A getter, an object holding itself, a throwing Proxy
and a bad array length are still refused at the empty path.
Expand Down
57 changes: 48 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# @larvit/adf-codec — ADF to markdown and HTML, markdown to ADF
# @larvit/adf-codec — ADF to markdown, HTML and plain text, markdown to ADF

[![npm](https://img.shields.io/npm/v/@larvit/adf-codec)](https://www.npmjs.com/package/@larvit/adf-codec)
[![CI](https://github.com/larvit/adf-codec/actions/workflows/ci.yml/badge.svg)](https://github.com/larvit/adf-codec/actions/workflows/ci.yml)

Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML.
Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML, or write it as plain text.

For application developers whose app shows, edits, exports or generates the rich text Atlassian
products store as ADF.
Expand All @@ -17,6 +17,7 @@ products store as ADF.
* `adfToPortableMarkdown` — ADF to markdown that GitHub, GitLab and Obsidian render. Keeps the content and drops colours, layout and other attributes.
* `adfToHtml` — ADF to HTML.
* `htmlToAdf` — HTML to ADF.
* `adfToPlainText` — ADF to text for an email, a notification or a search index: blocks a blank line apart, lists as `• ` and `1.`, no markup.

## Features and perks

Expand Down Expand Up @@ -164,6 +165,36 @@ const back = html.ok ? htmlToAdf(html.value) : undefined
if (back?.ok) console.log(back.value) // deep-equals page
```

### ADF to plain text

Full reference: [The calls](#the-calls).

```ts
import { adfToPlainText, type AdfDocument } from '@larvit/adf-codec'

const note: AdfDocument = {
content: [
{ content: [{ text: 'Ship on Friday.', type: 'text' }], type: 'paragraph' },
{
content: [
{ content: [{ content: [{ text: 'Fix login', type: 'text' }], type: 'paragraph' }], type: 'listItem' },
{ content: [{ content: [{ text: 'Fix export', type: 'text' }], type: 'paragraph' }], type: 'listItem' },
],
type: 'bulletList',
},
],
type: 'doc',
version: 1,
}

const text = adfToPlainText(note)
if (text.ok) console.log(text.value)
// Ship on Friday.
//
// • Fix login
// • Fix export
```

### Handle a result's error

Full reference: [The errors](#the-errors).
Expand Down Expand Up @@ -224,6 +255,10 @@ whatever it costs; the Achieve goals are weighed against each other in the order
7. **Maintain:** Lossy conversion drops form, never content. A link's or an image's title counts as content.
8. **Maintain:** Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.
9. **Maintain:** Fast, and linear in the document's size as JSON.
10. **Achieve:** Plain text reads like text written for people reading an email.
- Signal: readers shown the candidate texts pick the one the library already writes.
- Counter-signal: issues report plain text that reads as markup or runs blocks together.
- Why: a spelling that looks tidy to its author can read as markup to its readers, and only their picks and reports show it.

## Audience

Expand Down Expand Up @@ -304,9 +339,9 @@ documentation says, on 2026-10-28.

## The calls

Serves [Goals](#goals) 1, 2 and 8. Pure functions, each taking a whole document and returning a
Serves [Goals](#goals) 1, 2, 8 and 10. Pure functions, each taking a whole document and returning a
whole result; no I/O, no configuration. [What each call writes](https://github.com/larvit/adf-codec/blob/main/docs/feature-table.md)
sets example documents beside each writer's output and says whether that output reads back the same.
sets example documents beside the markdown and HTML writers' output and says whether that output reads back the same.

```ts
adfToLosslessMarkdown(doc: AdfDocument): Result<string>
Expand All @@ -315,6 +350,8 @@ readAdfDocument(v: unknown): Result<AdfDocument>

commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError>

adfToPlainText(doc: AdfDocument): Result<string>

adfToPortableMarkdown(doc: AdfDocument): Result<string>
portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>

Expand All @@ -328,6 +365,7 @@ Each call's name says which format it reads or writes:
| --- | --- | --- |
| `adfToHtml` | writes HTML: the elements [HTML](#html) lists, and an `adf-carry` element holding everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToPlainText` | writes text with no markup: blocks a blank line apart; list items under `• ` or `1.`, a task item under `☑ ` or `☐ `, nesting indented; a link's address and title in parentheses after its text; a hard break a line feed; a mention, emoji, date and status as the text they show; a table as its cells written as blocks | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `htmlToAdf` | reads the HTML `adfToHtml` writes, also as a browser's `innerHTML` or a form post spells it, a carry element from its `data-adf`; refuses any other HTML, an edit to what a carry element shows included | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
Expand All @@ -337,7 +375,7 @@ Each call's name says which format it reads or writes:

`readAdfDocument` reads the JSON you receive into an `AdfDocument`, copying it once. It copies own
enumerable keys and data properties alone, so an accessor property or a Proxy that throws is
`not-an-adf-document` and never a throw. The three writers read their document the same way. It checks the shape the converters require: `type: 'doc'`, a finite
`not-an-adf-document` and never a throw. The four writers read their document the same way. It checks the shape the converters require: `type: 'doc'`, a finite
`version` (the converters take only 1), nodes holding only `attrs`, `content`, `marks`, `text` and
`type`, attribute values that are JSON values, and no object holding itself. It reads a document
nesting past 500 levels; the converters refuse that one with `unsupported-nesting-depth`.
Expand Down Expand Up @@ -609,7 +647,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r

### Emitting

`adfToLosslessMarkdown`, `adfToPortableMarkdown` and `adfToHtml` raise these, and the codes under
`adfToHtml`, `adfToLosslessMarkdown`, `adfToPlainText` and `adfToPortableMarkdown` raise these, and the codes under
[Either direction](#either-direction). `readAdfDocument` raises `not-an-adf-document` and
`unsupported-node-shape`, and `engine-limit`.

Expand Down Expand Up @@ -657,19 +695,19 @@ Every call can raise `engine-limit`, and every conversion `unsupported-nesting-d
- **Writing, in `adfToLosslessMarkdown`**: a node not riding the carry that carries one mark type
twice. Keep the ADF and show the document read-only; where you built the ADF, fix
what the message names.
- **Writing, in `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown`**: a shape
- **Writing, in `adfToHtml`, `adfToLosslessMarkdown`, `adfToPlainText` and `adfToPortableMarkdown`**: a shape
Atlassian's schema forbids, even inside a node that rides the carry: an inline card a link marks,
a known block holding `text`, a known non-text inline node holding `text` or non-empty `content`,
or a known block that takes no content, such as `rule`, holding non-empty `content`. Keep the ADF
and pass the document over or show it read-only; where you built the ADF, remove what the error's
`path` and message name.
- **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell.
This is a bug in this library: report it.
- **Reading a document, in `readAdfDocument` and the three writers**: the document holds more than
- **Reading a document, in `readAdfDocument` and the four writers**: the document holds more than
10,000,000 JSON values, each object, array, string, number, boolean and null counting one. Keep
the ADF and pass the document over or show it read-only; where you built the ADF, split it.
- **Past the engine's longest string**:
- a writer's markdown or HTML would pass 536870888 characters, V8's longest string, on every
- a writer's markdown, HTML or text would pass 536870888 characters, V8's longest string, on every
engine;
- in `adfToPortableMarkdown`, the document's text alone would, edge whitespace included;
- in `losslessMarkdownToAdf`, a directive's plain CommonMark would;
Expand Down Expand Up @@ -778,6 +816,7 @@ Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/do
| `SourcePosition` | type | an error's place in the markdown or HTML read |
| `adfToHtml` | function | ADF to HTML |
| `adfToLosslessMarkdown` | function | ADF to the lossless flavour |
| `adfToPlainText` | function | ADF to text with no markup |
| `adfToPortableMarkdown` | function | ADF to portable markdown, lossy |
| `commonMarkToAdf` | function | strict CommonMark to ADF |
| `htmlToAdf` | function | `adfToHtml`'s HTML, read back to ADF |
Expand Down
35 changes: 35 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1296,3 +1296,38 @@ expectations as input. The Date row drew 1 of 3 not reasonable, so four more rea
reader of the markdown, export/indexing, viewer/editor) read it, and 5 of 7 found it reasonable.
`adfToLosslessMarkdown` keeps writing `!adf:date{timestamp=1756080000000}`. The two who rejected
it wanted the day shown, as `!adf:date[2025-08-25]{timestamp=…}`.

## Plain text is blocks a blank line apart, with `• ` and `1.` markers and nesting indented

2026-11-14, a reader panel of 7 (export/indexing, LLM/agent, support agent, viewer/editor, bot,
product manager, engineer), then the maintainer; a second panel of 3 (support agent, product
manager, export/indexing) for links and task state. Goal 10. Valid while the audience of the text is
people reading an email.

Each reader of the first panel wrote the text they expected, then picked among six shuffled
candidates. All seven expected a blank line between blocks, a hard break as one line feed and a
mention as its display text. Three picked blank lines with no markers and no indent, three picked
`• ` bullets with `1.` numbers kept and nesting indented, one picked `- ` and `1.` markers. Fewer
than five of seven is a missing goal, so the maintainer chose the `• ` bullets and worded the goal
for people reading an email.

The second panel's readers each wrote the text they expected, then picked among four shuffled
candidates for the links and four for task state. All three picked a link's address in parentheses
after its text, with the title after the address, and `☑` and `☐` in place of the bullet. All three
had expected no title, and goal 7 counts a title as content, so every candidate kept it.

`adfToPlainText` writes:

- Blocks a blank line apart; an item's blocks and a list's items one line feed apart.
- `• ` for a bullet list and a decision list, and `n. ` for an ordered list, counting from its
`order`.
- `☑ ` for a done task item and `☐ ` for any other, in place of the bullet.
- A nested list and an item's later lines indented to the marker's width.
- A list child that is no item, such as a task list nested in one, under the item before it.
- A hard break as a line feed, two in a row as a blank line.
- A mention, emoji, date and status as the text they show.
- A link's text, then ` (address)` or ` (address, "title")`, once after a run of text nodes sharing
the link; nothing after a link whose text is its address and that holds no title.
- Any other mark as the text it marks.
- A block holding no text as nothing, with no extra blank line.
- A table as its cells written as blocks, until item 180 gives it a spelling.
16 changes: 16 additions & 0 deletions docs/reading-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -1060,3 +1060,19 @@ Read most (opens and searches):
- `README.md` 1
- `docs/decisions.md` 1
- `src/markdown/parse/markdown-to-adf.ts` 1

## Reading cost, chunk 1125f4a3a4ad, finished 2026-11-14T10:38:41Z

124303 new input tokens before the first commit.

Read most (opens and searches):

- `todo.md` 10
- `README.md` 5
- `src/adf/shown-text.ts` 3
- `docs/decisions.md` 2
- `src/index.ts` 2
- `AGENTS.md` 1
- `docs/score-anchors.md` 1
- `src/adf/inline-nodes.ts` 1
- `src/html/emit/adf-to-html.ts` 1
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "@larvit/adf-codec",
"version": "0.3.1",
"description": "Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML.",
"description": "Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML, or write it as plain text.",
"keywords": [
"adf",
"adf-to-html",
"adf-to-markdown",
"adf-to-plain-text",
"atlassian",
"atlassian-document-format",
"atlassian-forge",
Expand Down
19 changes: 19 additions & 0 deletions src/conformance/adf-property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/docume
import { adfDocument, blockTaskItemChildren, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToHtml } from '../html/emit/adf-to-html.ts'
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainText } from '../plain-text/adf-to-plain-text.ts'
import { adfToPortableMarkdown, reduceToPortable } from '../markdown/portable/adf-to-portable-markdown.ts'
import { centeredImage, externalMedia } from '../adf/plain-image.ts'
import { htmlToAdf } from '../html/parse/html-to-adf.ts'
Expand Down Expand Up @@ -158,6 +159,24 @@ test('a generated document writes HTML holding no character HTML cannot hold, an
)
})

test('a generated document writes plain text showing every text node\'s text in document order, whatever the whitespace between', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const written = adfToPlainText(document)
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
const page = written.value.replaceAll(/\s/g, '')
let from = 0
for (const text of documentTexts(document.content ?? [])) {
const shown = text.replaceAll(/\s/g, '')
const at = page.indexOf(shown, from)
assert.ok(at >= 0, `writing ${JSON.stringify(document)}: ${JSON.stringify(shown)} is not shown after ${from}`)
from = at + shown.length
}
}),
propertyRuns(gateRuns),
)
})

test('a generated document writes HTML that reads back to it', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Resu
export type { JsonValue } from './json-value.ts'
export { adfToHtml } from './html/emit/adf-to-html.ts'
export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainText } from './plain-text/adf-to-plain-text.ts'
export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
export { htmlToAdf } from './html/parse/html-to-adf.ts'
Expand Down
Loading