diff --git a/AGENTS.md b/AGENTS.md
index 2872d3f..3763fb4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -225,7 +225,8 @@ The comprehension panel's fill-ins:
- Language: TypeScript.
- Kind: a pure-function document converter with hand-written parsers and emitters.
-- Domain: Atlassian Document Format and CommonMark parsing.
-- Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`.
+- Domain: Atlassian Document Format, CommonMark and HTML.
+- Domain docs: the CommonMark spec, the WHATWG HTML standard, ADF's JSON schema, `spec/flavour.md` and
+ `spec/html.md`.
- 3am question: a viewer/editor app reports that a document it saved comes back with two text
nodes merged and a mark gone after `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f6b186..e5ff994 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,8 +5,9 @@
- `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, code blocks, rules, hard
breaks, a centered external image holding no attribute but its URL and alt, and `em`, `strong`,
`code` and links as their elements. A paragraph, heading, quote, list, list item, code block or
- rule writes an attribute such as `localId` as `data-local-id`. Every other node is an element of class `adf-carry` holding the node's JSON in
- `data-adf` and showing its content, links included. Every URL is written as the ADF holds it,
+ rule writes its other attributes as `data-*` attributes, `localId` as `data-local-id`. Every other
+ node is an element of class `adf-carry` holding the node's JSON in `data-adf` and showing its
+ content, links included. Every URL is written as the ADF holds it,
`javascript:` included: sanitize HTML from an author you do not trust before showing it.
- **Breaking:** `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status`
or `mediaInline` only the marks Atlassian's schema lets it hold: `**!adf:mention[@A]{id=…}**` now
diff --git a/README.md b/README.md
index ffb8317..eae7e36 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,9 @@ products store as ADF.
and Obsidian render — GitHub Flavored Markdown's alerts, task lists and pipe tables, Obsidian
Flavored Markdown's callouts and highlights. It keeps the text, images by URL and link targets and
drops the rest.
-- **HTML out:** `adfToHtml` writes paragraphs, headings, lists, quotes, code blocks and links as
- their HTML elements, and every other node, an image Atlassian's editor wrote among them, as an
- element holding its JSON and showing its content.
+- **HTML out:** `adfToHtml` writes paragraphs, headings, lists, quotes, code blocks, rules, line
+ breaks, emphasis, inline code and links as their HTML elements. Every other node, an image
+ Atlassian's editor wrote among them, becomes an element holding its JSON and showing its content.
- **No throws:** every call returns a result; a refusal carries a code from a closed list.
- **Runs anywhere:** pure functions with no runtime dependencies, in Node, Deno, Bun and browsers.
TypeScript types ship beside the JavaScript.
@@ -70,37 +70,6 @@ if (portable.ok) console.log(portable.value)
// > Check the collation before importing.
```
-### ADF to HTML
-
-Full reference: [HTML](#html).
-
-```ts
-import { adfToHtml, type AdfDocument } from '@larvit/adf-codec'
-
-const page: AdfDocument = {
- content: [
- { attrs: { level: 2, localId: 'a1f3' }, content: [{ text: 'Release notes', type: 'text' }], type: 'heading' },
- {
- content: [
- { text: 'Read the ', type: 'text' },
- { marks: [{ attrs: { href: 'https://spec.commonmark.org/' }, type: 'link' }], text: 'spec', type: 'text' },
- { text: ', then ask ', type: 'text' },
- { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' },
- ],
- type: 'paragraph',
- },
- ],
- type: 'doc',
- version: 1,
-}
-
-// URLs are written as the ADF holds them, javascript: included: sanitize HTML from an author you do not trust before showing it.
-const html = adfToHtml(page)
-if (html.ok) console.log(html.value)
-//
+```
+
### Handle a result's error
Full reference: [The errors](#the-errors).
@@ -465,8 +465,8 @@ saving what this pair read replaces mentions, attachments and macros with text.
## HTML
-Serves [Goals](#goals) 1, 6 and 7. `adfToHtml` writes the HTML element for each node HTML has one
-for, and keeps every other node whole: an element of class `adf-carry` holds the node's JSON in
+Serves [Goals](#goals) 1, 6 and 7. `adfToHtml` writes the nodes and marks the table lists as their
+HTML elements, and keeps every other node whole: an element of class `adf-carry` holds the node's JSON in
`data-adf` and shows the node's content, its links included, so a reader of the page loses no
content. No stylesheet ships. The complete dialect:
[`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md).
@@ -483,21 +483,21 @@ content. No stylesheet ships. The complete dialect:
| `paragraph` | `
` |
| `rule` | `
` |
| `text` marked `code`, `em`, `strong` | ``, ``, ``, the first mark outermost |
-| any other attribute the node's type holds, on a paragraph, heading, quote, list, list item, code block or rule | `data-*` named as HTML's `dataset` names it: `
` |
+| an attribute its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block or rule | `data-*` named as HTML's `dataset` names it: `
` |
| any other node, or a node holding what its element cannot | `
its content
`, a `` inline |
+- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, and a
+ card's or a carried link's in the carry element. Sanitize the HTML before showing a document from
+ an author you do not trust.
- Text and attribute values are escaped: `&`, `<`, `>` and `"`. A node holding what HTML cannot
hold, such as a carriage return or a control character, rides the carry element.
- In a `
`, the carry element shows each block's content as a `
`, and a code block's as
`
` keeping its indentation; in a ``, it parts blocks with ` `. It shows text a
link marks as a link, a mention, status or emoji by its text, a date as `2026-09-13` in UTC, a
- card as a link to its URL, an external image as a link to it, a stored one by its `alt`, and an
- expand by its title. An image a link to another target marks shows that link, then `(image)`
- linking to the image. What it cannot show leaves a note, `(image)`, inside the node's
- link where it has one.
-- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, and a
- card's or a carried link's in the carry element. Sanitize the HTML before showing a document from
- an author you do not trust.
+ card as a link to its URL, or to the target of a link marking it, an external image as a link to
+ it, a stored one by its `alt`, and an expand by its title. An image marked by a link to another
+ target shows that link, then `(image)` linking to the image. A node it cannot show leaves a note
+ instead, such as `(image)`; a link marking the node goes around the note.
## The errors
diff --git a/ci.sh b/ci.sh
index 7a16935..c6ff1dd 100755
--- a/ci.sh
+++ b/ci.sh
@@ -7,7 +7,7 @@ leg "install ($node_image)" in_image "$node_image" npm ci
leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck
leg "lint ($node_image)" in_image "$node_image" npm run lint
-# The boundaries pass by finding nothing, so probe that each still fires: oxlint bumps automerge on green. Each line is a probe and the import a boundary refuses it.
+# The boundaries pass by finding nothing, so probe that each still fires: oxlint bumps automerge on green. Each line names a probe file and an import its boundary refuses.
boundary_probes="src/adf/boundary-probe.ts ../markdown/flavour.ts
src/adf/boundary-probe-html.ts ../html/emit/adf-to-html.ts
src/html/boundary-probe.ts ../markdown/flavour.ts
diff --git a/docs/decisions.md b/docs/decisions.md
index 33bd4b1..b3de160 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -398,19 +398,25 @@ themselves.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
for what HTML cannot express, text always escaped. No stylesheet ships.
-The spellings, `spec/html.md`, are a writer panel's (2026-10-23): a product manager, an engineer and
-a support agent picked, 3 of 3, an image standing alone as `
` over `` and a
-bare ``, a list item's first paragraph bare, `
Bolt
`, over `
Bolt
`,
-`class="language-js"` over `lang-js`, and ` ` over ` `. For the carry element, seven readers
-picked `data-adf` 6 of 7 over `data-adf-node`. Four of them wanted the class `adf-carry` and three
-no class; this entry's stable `adf-*` classes settle it.
+The spellings, `spec/html.md`, are four writer panels' (2026-10-23 and -24), each of a product
+manager, an engineer and a support agent. The first picked each of these 3 of 3:
-A second panel of the same three roles picked, 3 of 3: an attribute the element cannot express as
-`data-local-id` over `data-adf-local-id` and `id`, since Atlassian's editor puts a `localId` on every
-block and a block carried for it would show no element at all; inside a carry element a link as
-`` over `text (url)` and over its text alone, a carried node's blocks as `
` where a link marks it |
+| `orderedList` | as `bulletList` with ``; `` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` |
+| `paragraph` | `
…
` |
+| `rule` | `` |
A container holding no `content` key is its element holding nothing: ``,
`
`, ``; so is a paragraph or heading, ``.
-For a paragraph, heading, quote, list, list item, code block or rule, every other attribute its type
-holds (`spec/flavour.md` §Block nodes) is a `data-*` attribute on its element, named as HTML's
-`dataset` names it, `localId` as `data-local-id`, after the element's own attributes and sorted by
-name. A string is its value; any other value is its JSON, compact:
-``, `
`. A code block's
-`data-*` attributes stand on its `
`.
+On a paragraph, heading, quote, list, list item, code block or rule, each attribute its type holds
+(`spec/flavour.md` §Block nodes) that the element does not write as `level`, `order` or `language`
+is a `data-*` attribute, named as HTML's `dataset` names it, `localId` as `data-local-id`, after the
+element's own attributes and sorted by name. A string is its value; any other value is its JSON,
+compact: ``, `
`. A code
+block's `data-*` attributes stand on its `
') + 2).replace(/<\/(?:div|span)>(?:<\/p>)?\n$/, '')
}
-test('writes paragraphs, headings and rules as their elements, one block a line', () => {
+test('writes paragraphs, headings and rules as their elements, separated by line feeds', () => {
const heading = (level: number): AdfNode => ({ attrs: { level }, content: [text(`Level ${level}`)], type: 'heading' })
assert.equal(html(adfToHtml(document(heading(1), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6)))), '
Level 1
\n
Read the manual.
\n\n
Level 6
\n')
assert.equal(html(adfToHtml(document(paragraph(), { attrs: { level: 2 }, type: 'heading' }))), '\n\n')
@@ -75,7 +75,7 @@ test('writes the marks CommonMark spells, the first mark outermost', () => {
assert.equal(html(adfToHtml(document(paragraph(text('Line one'), { type: 'hardBreak' }, text('Line two'))))), '
Line one Line two
\n')
})
-test('escapes text and attribute values, and keeps tabs, line feeds and form feeds', () => {
+test('escapes text, and keeps tabs, line feeds and form feeds', () => {
assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\tf\ng\fh'))))), '
a < b && c > "d" \'e\'\tf\ng\fh
\n')
})
@@ -88,7 +88,7 @@ test('carries text holding a character HTML text cannot hold, escaping it in the
assert.equal(html(adfToHtml(document(paragraph(text('a\u{1fffe}b', { type: 'underline' }))))).includes('"text":"a\\ud83f\\udffeb"'), true)
})
-test('writes lists tight, an item holding more than one paragraph or a list in elements after the first', () => {
+test('writes lists tight, and an item\'s blocks after its first paragraph as elements on lines of their own', () => {
assert.equal(html(adfToHtml(document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))), '
\n\n')
@@ -195,7 +195,7 @@ test('shows a code block in a span carry as text, its line feeds line breaks', (
assert.equal(shown(html(adfToHtml(document(paragraph({ attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }))))), 'if ready: deploy()')
})
-test('shows the content a carried node holds in an attribute, a link to what it links to', () => {
+test('shows the content a carried node holds in an attribute, and links a card or an external image to its URL', () => {
const inline = (node: AdfNode): string => shown(html(adfToHtml(document(paragraph(node)))))
assert.equal(inline({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14')
assert.equal(inline({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄')
@@ -210,7 +210,7 @@ test('shows the content a carried node holds in an attribute, a link to what it
assert.equal(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { title: 'No target' }, type: 'link' }], type: 'media' }), '
')
})
-test('shows a carried image a link to another target marks as that link, then a link to the image reading image', () => {
+test('shows a carried image marked by a link to another target as that link, then a link to the image reading image', () => {
const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' }
const image = (attrs: AdfAttributes): string => shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs, marks: [link], type: 'media' }], type: 'mediaSingle' }))))
assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }), '
')
@@ -269,7 +269,7 @@ test('carries a node holding what its element cannot', () => {
assert.equal(carriedBlock({ attrs: { layout: 'center' }, content: [{ attrs: { alt: '', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
})
-test('carries inline what its element cannot hold, and a text node an unmarked one before it would join', () => {
+test('carries inline what its element cannot hold, and a text node that would join the unmarked one before it', () => {
assert.equal(carriedSpans(text('a'), text('b')), 1)
assert.equal(carriedSpans(text('a'), text('b'), text('c')), 1)
assert.equal(carriedSpans(text('a'), text('b', { type: 'em' }), text('c')), 0)
diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts
index e949c04..ee9e00a 100644
--- a/src/html/emit/adf-to-html.ts
+++ b/src/html/emit/adf-to-html.ts
@@ -11,7 +11,7 @@ import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.t
import { plainImage } from '../../adf/plain-image.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
-// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; an item's attributes are its element's, checked by its list.
+// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; `attributes`: the `
`'s `data-*` attributes, built when its list checked the item.
type Block = { depth: number; kind: 'block'; node: AdfNode; path: ConvertErrorPath }
type Item = { attributes: string; depth: number; kind: 'item'; node: AdfNode; path: ConvertErrorPath }
type Pending = Block | Item | string
@@ -99,7 +99,7 @@ function containerPieces(tag: string, attributes: string, children: Pending[] |
return [`<${tag}${attributes}>\n`, ...children, `\n${tag}>`]
}
-// An item's blocks sit at its own level, one below its list; its first paragraph stands on the item's line, panel picks, docs/decisions.md §The HTML dialect.
+// An item's first paragraph stands on the item's line, as the writer panels picked (docs/decisions.md §The HTML dialect).
function writeItem({ attributes, depth, node, path }: Item): Result {
if (node.content === undefined) return success([``])
const blocks = blockPieces(node.content, path, depth)
@@ -111,7 +111,7 @@ function writeItem({ attributes, depth, node, path }: Item): Result {
return success(blocks.length === 1 ? [`
${line.value}
`] : [`
${line.value}`, ...blocks.slice(1), '\n
'])
}
-// `undefined` where the paragraph takes a line of its own: where it rides the carry, or holds neither content nor an attribute, which bare would read back as no paragraph.
+// `undefined` where the paragraph takes a line of its own: where it rides the carry, or holds neither content nor an attribute: written bare, it would read back as no paragraph.
function tryFirstParagraph(paragraph: AdfNode, path: ConvertErrorPath): Result | undefined {
const attributes = dataAttributes(paragraph, blockNodes.paragraph.attributes, [])
if (attributes !== '') return inlineElement('p', attributes, nodeContent(paragraph), path)
@@ -126,7 +126,7 @@ function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result | u
return html === undefined ? undefined : success(html)
}
-// `undefined` where the attributes are, so the node rides the carry.
+// `undefined` where `attributes` is undefined, so the node rides the carry.
function inlineElement(tag: string, attributes: string | undefined, nodes: readonly AdfNode[], path: ConvertErrorPath): Result | undefined {
if (attributes === undefined) return undefined
const inline = inlineHtml(nodes, path)
diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts
index 4e0bfd5..e06ffb3 100644
--- a/src/html/emit/carry-element.ts
+++ b/src/html/emit/carry-element.ts
@@ -30,7 +30,7 @@ export function carryElement(tag: string, json: string, content: string): string
function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] {
const shown: ShownBlock[] = []
let current: ShownBlock = { code: undefined, html: '' }
- // Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, after which the content stands in `code` again.
+ // Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, and what follows stands in that entry's `code`, the code block around the block that ended.
const pending: { code: AdfNode | undefined; node: AdfNode | null }[] = [{ code: undefined, node: root }]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const { node } = next
@@ -48,7 +48,7 @@ function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] {
return shown
}
-// The node's own part of what its carry element shows: its text, what it shows in its place, or a note naming what it references, linked to its link mark's target, else to the URL a card or an image links to; an image a link to another target marks is followed by a link to it.
+// The node's own part of what its carry element shows.
function shownPiece(node: AdfNode, lineFeed: string): string {
if (node.type === 'hardBreak') return ' '
const mark = nodeMarks(node).find((held) => held.type === 'link')
@@ -62,7 +62,7 @@ function shownPiece(node: AdfNode, lineFeed: string): string {
return name === undefined ? '' : linked(`(${shownValue(name, lineFeed)})`, link)
}
-// The image's alt, else the link's target, else its URL, linked to the target, then a link to the image, as portable markdown writes it inline.
+// As portable markdown writes it inline (docs/decisions.md §The HTML dialect).
function linkedImage(media: AdfNode, target: LinkAttrs, url: string, lineFeed: string): string {
const text = visibleText(nodeAttrs(media)['alt']) ?? visibleText(target.href) ?? url
return `${linked(shownValue(text, lineFeed), target)} (${linked('image', { href: url })})`
diff --git a/src/html/emit/escaping.ts b/src/html/emit/escaping.ts
index 101ac51..e0e706e 100644
--- a/src/html/emit/escaping.ts
+++ b/src/html/emit/escaping.ts
@@ -1,7 +1,7 @@
import type { JsonValue } from '../../json-value.ts'
import type { LinkAttrs } from '../../adf/mark-attributes.ts'
-// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): a control other than ASCII whitespace or a carriage return, which a parser reads as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode.
+// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): any control but a tab, line feed or form feed, a carriage return among them, since a parser reads it as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode.
const unwritable = /(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u
const unwritables = new RegExp(unwritable.source, 'gu')
@@ -29,7 +29,6 @@ export function optionalAttribute(name: string, value: string | undefined): stri
return escaped === undefined ? undefined : ` ${name}="${escaped}"`
}
-// The text a carry element shows, dropping what HTML cannot hold, each line feed spelled `lineFeed`.
export function shownValue(value: string, lineFeed: string): string {
return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', lineFeed)
}
diff --git a/todo.md b/todo.md
index fce1247..e043637 100644
--- a/todo.md
+++ b/todo.md
@@ -242,14 +242,14 @@ the union. Found by the technical-principles audit of item 78, 2026-10-05.
### 141. Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.
-GFM spells `strike`, and no other HTML item writes it: item 130 wrote the marks CommonMark spells.
-A writer panel picks `` or ``.
+GFM spells `strike`, and no other HTML item writes it: `adfToHtml` writes only the marks CommonMark
+spells. A writer panel picks `` or ``.
### 135. Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.
-A writer panel picks the spellings; an external image already has one from item 130. Atlassian's
-editor puts `localId`, `width` and `widthType` on every `mediaSingle`, so every editor-written
-image rides the carry until this item.
+A writer panel picks the spellings; a plain external image already has one, `
`.
+Atlassian's editor puts `localId`, `width` and `widthType` on every `mediaSingle`, so every
+editor-written image rides the carry until this item.
### 136. Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.