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

Answer architecture round 3: 0 🔴, 1 🟡 (0 misread); the data-* and carry contract names its elements, an item's first paragraph holding an attribute on the item's line (panel 6 of 7)

This commit is contained in:
2026-10-23 19:39:19 +02:00
committed by Lillem4n
parent 7a38db2563
commit 7bcbf49963
5 changed files with 45 additions and 24 deletions
+9 -8
View File
@@ -466,15 +466,15 @@ 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, an attribute HTML has none for as `data-*`, 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:
for, 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).
| ADF | HTML |
| --- | --- |
| `blockquote` | `<blockquote>` |
| `bulletList`, `orderedList`, `listItem` | `<ul>`, `<ol>`, `<ol start="3">` for an `order` of 3, and `<li>`; an item's first paragraph stands bare, `<li>Bolt</li>` |
| `bulletList`, `orderedList`, `listItem` | `<ul>`, `<ol>`, `<ol start="3">` for an `order` of 3, and `<li>`; an item's first paragraph stands bare, `<li>Bolt</li>`, or as `<li><p data-local-id="p1">Bolt</p></li>` where it holds an attribute |
| `codeBlock` | `<pre><code class="language-js">`, no `class` without a language |
| `hardBreak` | `<br>` |
| `heading` | `<h1>` to `<h6>` |
@@ -483,14 +483,15 @@ included, so a reader of the page loses no content. No stylesheet ships. The com
| `paragraph` | `<p>` |
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<code>`, `<em>`, `<strong>`, the first mark outermost |
| any other attribute the node's type holds | `data-*` named as HTML's `dataset` names it: `<p data-local-id="a1f3">` |
| 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">` |
| 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 |
- 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.
- The carry element shows each block's content as a `<p>`, 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 image as
a link to it, and an expand by its title. What it cannot show leaves a note, `<em>(image)</em>`.
- The carry element shows each block's content as a `<p>` in a `<div>`, parted by `<br>` in a
`<span>`. 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. What it cannot show leaves a note, `<em>(image)</em>`.
- A link's or image's URL is written as the ADF holds it, `javascript:` included: sanitize the
HTML before showing a document from an author you do not trust.
+6
View File
@@ -412,6 +412,12 @@ block and a block carried for it would show no element at all; inside a carry el
line feed, and media it 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
`<li data-local-id="i1"><p data-local-id="p1">Bolt</p></li>`, 6 of 7, over the same elements with
the `<p>` on a line of its own, 1 of 7, and over
`<li data-local-id="i1" data-paragraph-local-id="p1">Bolt</li>`, 0 of 7.
## No runtime dependencies
2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
+8 -5
View File
@@ -16,7 +16,7 @@ document whose `content` is an empty array is the empty string.
| `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` | `<li>…</li>`: a first paragraph holding content and no attribute bare, its inline content straight after `<li>`, then each other block after a line feed and a line feed before `</li>`; with no bare paragraph, `<li>`, a line feed, its blocks, a line feed, `</li>` |
| `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 whitespace; no `class` where it holds no `language` |
| `rule` | `<hr>` |
| `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 |
@@ -24,9 +24,10 @@ document whose `content` is an empty array is the empty string.
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>`.
Every other attribute its type holds (`spec/flavour.md` §Block nodes) is a `data-*` attribute on the
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:
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>`.
@@ -91,10 +92,12 @@ later release writes it as its own element.
- an unmarked text node straight after another, 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 whitespace;
- a rule or hard break holding `content`;
- 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;
- a `listItem` outside a list, an inline node in block position and a block node inline.
+9 -5
View File
@@ -92,10 +92,13 @@ test('writes lists tight, an item holding more than one paragraph or a list in e
assert.equal(html(adfToHtml(document({ content: [item(paragraph())], type: 'bulletList' }))), '<ul>\n<li>\n<p></p>\n</li>\n</ul>\n')
})
test('writes an item\'s first paragraph as its element where it holds an attribute, and as its carry where it holds a mark', () => {
const first = (node: AdfNode): string => html(adfToHtml(document({ content: [item(node)], type: 'bulletList' })))
assert.equal(first({ attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' }), `<ul>\n<li>\n<p data-local-id="${ids.paragraph}">Bolt</p>\n</li>\n</ul>\n`)
assert.equal(first({ content: [text('Bolt')], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }).startsWith('<ul>\n<li>\n<div class="adf-carry"'), true)
test('writes an item\'s first paragraph as its element on the item\'s line where it holds an attribute, and as its carry where it holds a mark', () => {
const listed = (...blocks: AdfNode[]): string => html(adfToHtml(document({ content: [item(...blocks)], type: 'bulletList' })))
const held: AdfNode = { attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' }
assert.equal(listed(held), `<ul>\n<li><p data-local-id="${ids.paragraph}">Bolt</p></li>\n</ul>\n`)
assert.equal(listed(held, { content: [item(paragraph(text('M8')))], type: 'bulletList' }), `<ul>\n<li><p data-local-id="${ids.paragraph}">Bolt</p>\n<ul>\n<li>M8</li>\n</ul>\n</li>\n</ul>\n`)
assert.equal(listed({ attrs: { localId: ids.paragraph }, type: 'paragraph' }), `<ul>\n<li><p data-local-id="${ids.paragraph}"></p></li>\n</ul>\n`)
assert.equal(listed({ content: [text('Bolt')], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }).startsWith('<ul>\n<li>\n<div class="adf-carry"'), true)
})
test('writes the attributes an element cannot express as data-* attributes on it, sorted, a value that is no string as JSON', () => {
@@ -104,7 +107,7 @@ test('writes the attributes an element cannot express as data-* attributes on it
assert.equal(html(adfToHtml(document({ attrs: { level: 2, ...localId }, content: [text('x')], type: 'heading' }))), `<h2 data-local-id="${ids.paragraph}">x</h2>\n`)
assert.equal(html(adfToHtml(document({ attrs: { localId: ids.quote }, content: [paragraph(text('x'))], type: 'blockquote' }))), `<blockquote data-local-id="${ids.quote}">\n<p>x</p>\n</blockquote>\n`)
const listed: AdfNode = { attrs: { localId: ids.list, order: 3 }, content: [{ attrs: { localId: ids.item }, content: [{ attrs: localId, content: [text('Bolt')], type: 'paragraph' }], type: 'listItem' }], type: 'orderedList' }
assert.equal(html(adfToHtml(document(listed))), `<ol start="3" data-local-id="${ids.list}">\n<li data-local-id="${ids.item}">\n<p data-local-id="${ids.paragraph}">Bolt</p>\n</li>\n</ol>\n`)
assert.equal(html(adfToHtml(document(listed))), `<ol start="3" data-local-id="${ids.list}">\n<li data-local-id="${ids.item}"><p data-local-id="${ids.paragraph}">Bolt</p></li>\n</ol>\n`)
const code: AdfNode = { attrs: { wrap: true, language: 'js', hideLineNumbers: false, ...localId }, content: [text('x')], type: 'codeBlock' }
assert.equal(html(adfToHtml(document(code))), `<pre data-hide-line-numbers="false" data-local-id="${ids.paragraph}" data-wrap="true"><code class="language-js">x</code></pre>\n`)
assert.equal(html(adfToHtml(document({ attrs: { weight: -0, color: '#ccc', style: 'a"b' }, type: 'rule' }))), '<hr data-color="#ccc" data-style="a&quot;b" data-weight="-0">\n')
@@ -259,6 +262,7 @@ test('refuses a carried block whose JSON nests past the levels its depth leaves,
assert.equal(refusal(adfToHtml(document(paragraph(deep)))), 'unsupported-nesting-depth at /content/0/content/0')
assert.equal(refusal(adfToHtml(document({ attrs: { level: 1 }, content: [deep], type: 'heading' }))), 'unsupported-nesting-depth at /content/0/content/0')
assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/0/content/0')
assert.equal(refusal(adfToHtml(document({ content: [item({ attrs: { localId: ids.paragraph }, content: [deep], type: 'paragraph' })], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/0/content/0')
assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(text('a')), paragraph(deep)), item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/1/content/0')
})
+13 -6
View File
@@ -112,16 +112,23 @@ 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 bare where it holds content and no attribute, panel pick, docs/decisions.md §The HTML dialect.
// 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.
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)
const [first] = node.content
if (first === undefined || first.type !== 'paragraph' || dataAttributes(first, []) !== '' || nodeContent(first).length === 0) return success([`<li${attributes}>\n`, ...blocks, '\n</li>'])
const bare = inlineHtml(nodeContent(first), [...path, 'content', 0])
if (!bare.ok) return bare
// The bare paragraph's inline content stands in for its block.
return success(blocks.length === 1 ? [`<li${attributes}>${bare.value}</li>`] : [`<li${attributes}>${bare.value}`, ...blocks.slice(1), '\n</li>'])
const line = first?.type === 'paragraph' ? tryFirstParagraph(first, [...path, 'content', 0]) : undefined
if (line === undefined) return success([`<li${attributes}>\n`, ...blocks, '\n</li>'])
if (!line.ok) return line
// The first paragraph, written on the item's line, stands in for its block.
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.
function tryFirstParagraph(paragraph: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
const attributes = dataAttributes(paragraph, [])
if (attributes !== '') return inlineElement('p', attributes, nodeContent(paragraph), path)
return nodeContent(paragraph).length === 0 ? undefined : inlineHtml(nodeContent(paragraph), path)
}
function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {