3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer prose round 1: 0 🔴, 11 🟡 (0 misread); claims true to the code, one pick per line in the dialect's panels, plainer comments and test names

This commit is contained in:
2026-10-24 01:40:49 +02:00
committed by Lillem4n
parent 94efd30c59
commit fb75c33975
13 changed files with 141 additions and 127 deletions
+3 -2
View File
@@ -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))`.
+3 -2
View File
@@ -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
+44 -44
View File
@@ -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)
// <h2 data-local-id="a1f3">Release notes</h2>
// <p>Read the <a href="https://spec.commonmark.org/">spec</a>, then ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>
```
### Markdown to ADF
Full reference: [The calls](#the-calls) and [Portable markdown](#portable-markdown).
@@ -156,6 +125,37 @@ if (!markdown.ok) {
Unedited, the markdown reads back to a document that deep-equals `doc`, node types this version
does not know included.
### 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)
// <h2 data-local-id="a1f3">Release notes</h2>
// <p>Read the <a href="https://spec.commonmark.org/">spec</a>, then ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>
```
### 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` | `<p>` |
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<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: `<p data-local-id="a1f3">` |
| 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: `<p data-local-id="a1f3">` |
| any other node, or a node holding what its element cannot | `<div class="adf-carry" data-adf="{…}"><p>its content</p></div>`, a `<span>` 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 `<div>`, the carry element shows each block's content as a `<p>`, and a code block's as
`<pre><code>` keeping its indentation; in a `<span>`, it parts blocks with `<br>`. 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, `<em>(image)</em>`, 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 `<em>(image)</em>`; a link marking the node goes around the note.
## The errors
+1 -1
View File
@@ -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
+31 -24
View File
@@ -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 `<p><img …></p>` over `<figure>` and a
bare `<img>`, a list item's first paragraph bare, `<li>Bolt</li>`, over `<li><p>Bolt</p></li>`,
`class="language-js"` over `lang-js`, and `<br>` over `<br />`. 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
`<a>` over `text (url)` and over its text alone, a carried node's blocks as `<p>` over `<br>` and a
line feed, and media it cannot show as `<em>(image)</em>` over `<em>(image not shown)</em>` and
nothing.
- an image standing alone as `<p><img …></p>`, over `<figure>` and a bare `<img>`;
- a list item's first paragraph bare, `<li>Bolt</li>`, over `<li><p>Bolt</p></li>`;
- `class="language-js"`, over `lang-js`;
- `<br>`, over `<br />`.
Seven readers picked `data-adf` for the carry element, 6 of 7, over `data-adf-node`. Four wanted the
class `adf-carry` and three no class; this entry's stable `adf-*` classes settle it.
Atlassian's editor puts a `localId` on every block, so carrying each block holding one would leave
no element at all. The second panel picked each of these 3 of 3:
- an attribute the element cannot express as `data-local-id`, over `data-adf-local-id` and `id`;
- a link inside a carry element as `<a>`, over `text (url)` and its text alone;
- a carried node's blocks as `<p>`, over `<br>` and a line feed;
- media a carry element cannot show as `<em>(image)</em>`, over `<em>(image not shown)</em>` and
nothing.
A third panel, seven readers in the same three roles, was asked how to write a list item whose first
paragraph holds an attribute, the shape of every item Atlassian's editor writes. It picked
@@ -420,13 +426,14 @@ the `<p>` on a line of its own, 1 of 7, and over
A fourth panel of the same three roles picked, 3 of 3, a code block inside a carry element as
`<pre><code>…</code></pre>` over `<pre>…</pre>` and over a `<p>` whose lines part at `<br>`, which
loses the indentation. All three wrote `<code class="language-python">`, which no option held.
loses the indentation. All three wrote `<code class="language-python">`, which no option held, so
the shown code keeps the class its code block's own element takes.
Inside a carry element (2026-10-24, no panel of its own: the second panel's `<a>` joined to a
portable spelling), an image a link to another target marks takes the spelling portable markdown
gives it inline (§A linked image standing inline in portable markdown, its link to another
target, is written `[alt](href) ([image](url))`), keeping both targets, which Goal 7 counts as
content. A link marking a node that shows a note goes around the note.
Inside a carry element, an image marked by a link to another target shows as portable markdown
writes it inline (§A linked image standing inline in portable markdown, its link to another target,
is written `[alt](href) ([image](url))`), so both targets stay; Goal 7 counts them as content. A link
marking a node that shows a note goes around the note. Recorded 2026-10-24 without a panel of its
own: it joins the second panel's `<a>` to that portable spelling.
## No runtime dependencies
@@ -573,8 +580,8 @@ handles one cause alike whichever node, attribute or direction raised it.
emitter carries such a node (2026-10-14).
- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document`
emitting — no document holds one, so no round-trip crosses them (2026-09-23).
- A document whose markdown would be longer than V8's longest string is `unsupported-node-shape`
at the document's path, in either writer, and so is a directive `losslessMarkdownToAdf` reads
- A document whose output would be longer than V8's longest string is `unsupported-node-shape`
at the document's path, in every writer, and so is a directive `losslessMarkdownToAdf` reads
whose CommonMark spelling would be: the document does not convert, and no spelling in the input
causes the bound (§The code list). A carried node passes it on about 1 MB of deeply nested JSON,
since the block carry indents two spaces a level; `todo.md` item 107 respells the carry in a
@@ -831,10 +838,10 @@ that, JavaScriptCore throws `Out of memory`, which is `engine-limit` (§Which co
`adfToHtml` writes every URL exactly as the ADF holds it, escaped as every attribute value is,
`javascript:`, `vbscript:`, `file:` and `data:` included. That covers a link's `href`, an image's
`src`, and the `href` of each link the carry element shows: a carried node's link mark, a card's
`url` or `data.url`, and an external image's `url`. Carrying such a link or image in the carry
element, as cmark's default leaves these URLs out of its HTML, was proposed and not taken: item 142
weighs that against writing the URL. Carrying alone no longer keeps the URL out of the HTML, since
the carry element links what it shows, so that remedy would also drop the shown `href`.
`url` or `data.url`, and an external image's `url`. Carrying such a link or image, to leave the URL
out of the HTML as cmark does by default, was proposed and not taken; item 142 weighs it against
writing the URL. Carrying alone would not keep the URL out, since the carry element links what it
shows: the carry would also have to drop the shown `href`.
## Nothing recurses unbounded
+38 -32
View File
@@ -6,39 +6,39 @@ for every other node (`docs/decisions.md` §The HTML dialect). The written form
## Blocks
One block a line: blocks are separated by a line feed, and a document holding any ends with one. A
document whose `content` is an empty array is the empty string.
Blocks are separated by a line feed, and a document holding any ends with one. A document whose
`content` is an empty array is the empty string.
| ADF | HTML |
| --- | --- |
| `paragraph` | `<p>…</p>` |
| `heading`, `level` 1 to 6 | `<h1>…</h1>` to `<h6>…</h6>` |
| `blockquote` | `<blockquote>`, a line feed, its blocks, a line feed, `</blockquote>` |
| `bulletList` | `<ul>`, a line feed, its items a line each, a line feed, `</ul>` |
| `orderedList` | as `bulletList` with `<ol>`; `<ol start="3">` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` |
| `listItem` | its first paragraph straight after `<li>`: bare where it holds content and no attribute, `<li>Bolt</li>`, and as its `<p>` element where it holds an attribute, `<li><p data-local-id="p1">Bolt</p></li>`; where other blocks follow, each after a line feed, and a line feed before `</li>`. Otherwise `<li>`, a line feed, its blocks, a line feed, `</li>` |
| `codeBlock` | `<pre><code class="language-js">…</code></pre>`, the language one token holding no ASCII whitespace (a tab, line feed, form feed, carriage return or space); no `class` where it holds no `language` |
| `rule` | `<hr>` |
| `heading`, `level` 1 to 6 | `<h1>…</h1>` to `<h6>…</h6>` |
| `listItem` | its first paragraph straight after `<li>`: bare where it holds content and no attribute, `<li>Bolt</li>`, and as its `<p>` element where it holds an attribute, `<li><p data-local-id="p1">Bolt</p></li>`; where other blocks follow, each after a line feed, and a line feed before `</li>`. Where the first block is no paragraph, holds neither content nor an attribute, or rides the carry: `<li>`, a line feed, its blocks, a line feed, `</li>` |
| `mediaSingle`, centered, holding one external `media` | `<p><img src="…" alt="…"></p>`, no `alt` where the media holds none; `<p><a href="…"><img …></a></p>` where a link marks it |
| `orderedList` | as `bulletList` with `<ol>`; `<ol start="3">` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` |
| `paragraph` | `<p>…</p>` |
| `rule` | `<hr>` |
A container holding no `content` key is its element holding nothing: `<blockquote></blockquote>`,
`<ul></ul>`, `<li></li>`; so is a paragraph or heading, `<p></p>`.
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:
`<ol start="3" data-local-id="91177a27-b0ff">`, `<pre data-wrap="true"><code>`. A code block's
`data-*` attributes stand on its `<pre>`.
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: `<ol start="3" data-local-id="91177a27-b0ff">`, `<pre data-wrap="true"><code>`. A code
block's `data-*` attributes stand on its `<pre>`.
## Inline
| ADF | HTML |
| --- | --- |
| `text` | its text, escaped |
| `em`, `strong`, `code` | `<em>`, `<strong>`, `<code>` around the text |
| `link`, holding `href` and an optional `title` | `<a href="…" title="…">` around the text |
| `hardBreak` | `<br>` |
| `link`, holding `href` and an optional `title` | `<a href="…" title="…">` around the text |
| `text` | its text, escaped |
A text node's marks nest in the order its `marks` array lists them, the first outermost:
`<strong><em>manual</em></strong>`. Each text node writes its own elements, so two text nodes a link
@@ -51,10 +51,10 @@ Attribute values are double-quoted. Every URL is written as the ADF holds it, wh
(`docs/decisions.md` §HTML writes a URL as the ADF holds it, whatever its scheme): a link's `href`,
an image's `src`, and the `href` of each link a carry element shows.
HTML text and attribute values cannot hold a carriage return, which a parser reads as a line feed,
a control other than a tab, line feed or form feed, a noncharacter, or a lone surrogate, which
UTF-8 cannot encode (WHATWG HTML §13.1.3). A node holding one in a value its element writes rides
the carry.
HTML text and attribute values cannot hold these (WHATWG HTML §13.1.3): any control but a tab, line
feed or form feed, so a carriage return too, which a parser reads as a line feed; a noncharacter;
and a lone surrogate, which UTF-8 cannot encode. A node holding one in a value its element writes
rides the carry.
## The carry element
@@ -73,19 +73,24 @@ later release writes it as its own element.
included, each character HTML cannot hold spelled as a JSON escape, `\u0085`. A document holding
no `content` key is the block carry of `{"type":"doc","version":1}`.
- The content shown is every text node's text below the node, and what a node holding no text shows
in its place: a mention's text, else `@` and its id where it has one; an emoji's text, else its `shortName`; a
status's text; a date as `2026-09-13` in UTC; a card's URL, else its name, else the URL its data
holds; media's `alt`, else an external one's URL; an extension's text; an expand's title.
in its place: a mention's text, else `@` and its id where it has one; an emoji's text, else its
`shortName`; a status's text; a date as `2026-09-13` in UTC; a card's URL, else its name, else the
URL its data holds; media's `alt`, else an external one's URL; an extension's text; an expand's
title.
- In a `<div>`, each block's content is a `<p>`, and a code block's is
`<pre><code class="language-js">…</code></pre>`, keeping its line feeds and spaces. Its class
is the one the code block's element takes, and it has none where the language is no class
token. In a `<span>`, `<br>` parts two blocks'.
`<pre><code class="language-js">…</code></pre>`, keeping its line feeds and spaces. The `<code>`
takes the class the code block's own element takes, and none where the language is not one
token free of ASCII whitespace. In a `<span>`, a `<br>` parts each block's content from the
next.
- A hard break is `<br>`, and so is a line feed in text outside a `<div>`'s code block.
- Text a link marks is `<a href="…" title="…">` around it, and a card or an external image is a
link to its URL.
- An image a link to another target marks shows as portable markdown writes it inline, the link
around its `alt`, else the link's target, else its URL, then `image` linking to the image in
parentheses: `<a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)`.
- Text a link marks is `<a href="…" title="…">` around it, or the text alone where HTML cannot
hold the link's `href` or `title`. A card is a link to the target of a link marking it, else to
its URL.
- An external image with no link to another target is a link to its URL. An image marked by a
link to another target shows as portable markdown writes it inline. First comes the link,
around the image's `alt`, else the link's target, else the image's URL. Then comes ` (image)`,
the word `image` linking to the image:
`<a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)`.
- A node referencing what it cannot show is a note naming it, `<em>(image)</em>`: `image` for
media with no `alt` or URL, `link card` for a card with neither URL nor name, an extension's
key, else `extension`, for an extension with no text, and `synced block` for a `syncBlock`. A
@@ -96,14 +101,15 @@ later release writes it as its own element.
element cannot hold:
- text holding no character, or a character HTML cannot hold; text holding a mark twice, or a
mark with attributes besides a link's `href` and `title`;
- an unmarked text node straight after another, which would read back as one;
- an unmarked text node straight after one written as plain text, which would read back as one;
- a link whose `href` or `title` holds a character HTML cannot hold, and an image whose `url` or
`alt` holds one or whose link's `href` or `title` does;
- an image holding an attribute besides its `center` layout and its media's `type`, `url` and
non-empty `alt`;
- a heading whose `level` is not 1 to 6, and an ordered list whose `order` is not a safe integer
or is `-0`;
- a code block holding more than one text node, a marked one, or a language holding ASCII whitespace;
- a code block holding more than one text node, a marked one, or a language holding ASCII
whitespace;
- a rule or hard break holding `content`, and a hard break holding an attribute;
- a list holding a node that is no `listItem`, or an item holding a mark, text, an empty key or
an attribute its type does not hold: the whole list rides the carry;
+1 -1
View File
@@ -55,7 +55,7 @@ export function writableContent(document: AdfDocument): Result<readonly AdfNode[
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no format this library writes carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(undefined)
// Every character of text is written at least once, so this much text cannot fit, and none of it is written.
// Text alone past the bound is refused before anything is written (docs/decisions.md §Output past V8's longest string is refused on every engine).
return heldTextLength(document.content) > longestOutput ? tooLong() : success(document.content)
}
+1 -1
View File
@@ -150,7 +150,7 @@ for (const name of names(realPayloadsRoot, '.json')) {
})
}
// The element each block an editor writes with no attribute but `localId`, and `level` or `order`, opens with; every other block is carried.
// The opening tag of each block type whose only attributes are `localId`, `level` and `order`, as the editor writes them.
const blockElements: Readonly<Record<string, string>> = { blockquote: '<blockquote', bulletList: '<ul', heading: '<h', orderedList: '<ol', paragraph: '<p', rule: '<hr' }
for (const name of names(realPayloadsRoot, '.json')) {
+6 -6
View File
@@ -59,7 +59,7 @@ function shown(written: string): string {
return written.slice(written.indexOf('">') + 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)))), '<h1>Level 1</h1>\n<p>Read the manual.</p>\n<hr>\n<h6>Level 6</h6>\n')
assert.equal(html(adfToHtml(document(paragraph(), { attrs: { level: 2 }, type: 'heading' }))), '<p></p>\n<h2></h2>\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'))))), '<p>Line one<br>Line two</p>\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'))))), '<p>a &lt; b &amp;&amp; c &gt; &quot;d&quot; \'e\'\tf\ng\fh</p>\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('&quot;text&quot;:&quot;a\\ud83f\\udffeb&quot;'), 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' }))), '<ul>\n<li>Bolt</li>\n<li>Nut</li>\n</ul>\n')
const listed: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' }
assert.equal(html(adfToHtml(document(listed))), '<ol start="3">\n<li>Bolt\n<ul>\n<li>M8</li>\n</ul>\n</li>\n<li>a\n<p>b</p>\n</li>\n</ol>\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:<br> 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' }), '<p><a href="https://example.com/moon.jpg">The moon</a></p>')
})
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' }), '<p><a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)</p>')
@@ -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)
+4 -4
View File
@@ -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 `<li>`'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<Pending[]> {
if (node.content === undefined) return success([`<li${attributes}></li>`])
const blocks = blockPieces(node.content, path, depth)
@@ -111,7 +111,7 @@ function writeItem({ attributes, depth, node, path }: Item): Result<Pending[]> {
return success(blocks.length === 1 ? [`<li${attributes}>${line.value}</li>`] : [`<li${attributes}>${line.value}`, ...blocks.slice(1), '\n</li>'])
}
// `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<string> | 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<string> | 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<string> | undefined {
if (attributes === undefined) return undefined
const inline = inlineHtml(nodes, path)
+3 -3
View File
@@ -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 '<br>'
const mark = nodeMarks(node).find((held) => held.type === 'link')
@@ -62,7 +62,7 @@ function shownPiece(node: AdfNode, lineFeed: string): string {
return name === undefined ? '' : linked(`<em>(${shownValue(name, lineFeed)})</em>`, 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 })})`
+1 -2
View File
@@ -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)
}
+5 -5
View File
@@ -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 `<s>` or `<del>`.
GFM spells `strike`, and no other HTML item writes it: `adfToHtml` writes only the marks CommonMark
spells. A writer panel picks `<s>` or `<del>`.
### 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, `<p><img …></p>`.
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.