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

Write mentions and inline cards holding data as HTML elements, not the carry

This commit is contained in:
2026-11-04 16:22:57 +01:00
committed by Lillem4n
parent 13a2689b0d
commit 4ede66577e
11 changed files with 166 additions and 91 deletions
+6 -5
View File
@@ -9,17 +9,18 @@
libraries and Atlassian's own paths.
- `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, code blocks, rules, hard
breaks, a centered, uncaptioned external image holding no attribute but its URL and alt, and
`em`, `strong`, `code` and links as their elements. An unmarked emoji, status, date and card
holding a URL and no `data` are `<span class="adf-emoji">`, `<span class="adf-status">`,
`<time class="adf-date">` and `<a class="adf-inline-card">`. A paragraph, heading, quote, list,
list item, code block, rule, emoji, status, date or card writes its other attributes as `data-*`
`em`, `strong`, `code` and links as their elements. An unmarked emoji, mention, status, date
and card holding a URL or `data`, not both, are `<span class="adf-emoji">`,
`<span class="adf-mention">`, `<span class="adf-status">`, `<time class="adf-date">` and
`<a class="adf-inline-card">`. A paragraph, heading, quote, list, list item, code block, rule,
emoji, mention, status, date or card 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.
- `htmlToAdf` reads `adfToHtml`'s HTML back to the document it came from, a carry element from its
`data-adf`. It reads that HTML as a browser's `innerHTML` writes it too (a raw `"` in text,
`&nbsp;`, a raw `<` or `>` in an attribute value) and as a form post sends it (`\r\n` for each
line feed). An edit to an emoji's or status's text becomes its `text`; an edit to a date's or a
line feed). An edit to an emoji's, mention's or status's text becomes its `text`; an edit to a date's or a
card's text alone is refused. It refuses any other HTML, including an edit to what a carry element
shows, with the codes README §The calls lists.
- **Breaking:** `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status`
+4 -4
View File
@@ -153,7 +153,7 @@ const page: AdfDocument = {
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>
// <p>Read the <a href="https://spec.commonmark.org/">spec</a>, then ask <span class="adf-mention" data-id="5b10a2">@Mikael</span></p>
const back = html.ok ? htmlToAdf(html.value) : undefined
if (back?.ok) console.log(back.value) // deep-equals page
@@ -516,16 +516,16 @@ content. No stylesheet ships. The complete dialect:
| `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>`, 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 |
| `date`, `emoji`, `status` | `<time class="adf-date" datetime="2025-08-25" data-timestamp="…">2025-08-25</time>`, `<span class="adf-emoji" data-id="1f389" data-short-name=":tada:">🎉</span>`, `<span class="adf-status" data-color="yellow">In review</span>` |
| `date`, `emoji`, `mention`, `status` | `<time class="adf-date" datetime="2025-08-25" data-timestamp="…">2025-08-25</time>`, `<span class="adf-emoji" data-id="1f389" data-short-name=":tada:">🎉</span>`, `<span class="adf-mention" data-id="5b10a2">@Mikael</span>`, `<span class="adf-status" data-color="yellow">In review</span>` |
| `hardBreak` | `<br>` |
| `heading` | `<h1>` to `<h6>` |
| `inlineCard` holding a `url` | `<a class="adf-inline-card" href="…">…</a>` |
| `inlineCard` holding a `url`, or `data` and no `url` | `<a class="adf-inline-card" href="…">…</a>`; for `data`, `href` is the URL it holds, the text its name, and `data-data` the rest |
| `link` | `<a href="…" title="…">` |
| `mediaSingle` holding an external `media`, centered, uncaptioned, holding no other attribute such as `width` or `localId` | `<p><img src="…" alt="…"></p>`, the image inside `<a>` where a link marks it; an image holding such an attribute, as editor-written ones do, rides the carry element |
| `paragraph` | `<p>` |
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<code>`, `<em>`, `<strong>`, the first mark outermost |
| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block, rule, emoji, status, date or card | `data-*` named as HTML's `dataset` names it: `<p data-local-id="a1f3">` |
| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block, rule, emoji, mention, status, date or card | `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, a
+10 -7
View File
@@ -442,7 +442,7 @@ it joins the second panel's `<a>` to that portable spelling.
Inside a carry element, a card a link marks links to the link's target, and shows its own URL as its
text where it has one, so both stay. A card showing only its name keeps its URL in `data-adf` alone.
Atlassian's schema gives no card a link mark, so only a hand-built document meets this;
`todo.md` item 133 ends both, in 0.3.0, by writing such cards as their own element. Recorded 2026-10-24
`todo.md` item 133 ends it, in 0.3.0, by writing such cards as their own element. Recorded 2026-10-24
without a panel of its own: it puts the second panel's `<a>` around the text a card shows.
A fifth panel (2026-11-04, three readers in the same roles, then four more for what the first three
@@ -457,12 +457,15 @@ rode the carry. The first three picked each of these 3 of 3:
Seven readers picked a date as
`<time class="adf-date" datetime="2025-08-25" data-timestamp="1756080000000">2025-08-25</time>`,
5 of 7: the day for people, the exact timestamp for scripts. Two questions stayed split, so a
mention and a card holding `data` ride the carry until the maintainer settles them, `todo.md` item
133: a mention's `data-id`, `data-access-level` and `data-user-type` drew 4 of 7, over
`data-mention-id` 2 and `data-user-id` 1; a card holding `data` and no URL drew `<a … href data-data>`
3, `<a … data-card>` 2 and `<span … data-data>` 2. A card a link marks waits on the second answer,
since its name-only form is that card, so it rides the carry too.
5 of 7: the day for people, the exact timestamp for scripts. Two questions stayed split. A mention's
`data-id`, `data-access-level` and `data-user-type` drew 4 of 7, over `data-mention-id` 2 and
`data-user-id` 1; a card holding `data` and no URL drew `<a … href data-data>` 3, `<a … data-card>` 2
and `<span … data-data>` 2. The maintainer took the leading spelling of each, 2026-11-04:
- a mention is `<span class="adf-mention" data-access-level="CONTAINER" data-id="5b10a2" data-user-type="DEFAULT">@Mikael</span>`, its `text` the content;
- a card holding `data` and no URL is `<a class="adf-inline-card" href="https://example.com/plan" data-data="{…}">Release plan</a>`, `href` the URL its `data` holds and the text its name, else that URL. A card holding both `data` and a `url` rides the carry, since read back its `href` would not say which of the two it spells.
A card a link marks waits on a spelling of its own, `todo.md` item 133, so it rides the carry.
## `htmlToAdf` also reads the dialect as a browser serializes it
+10 -7
View File
@@ -40,11 +40,13 @@ block's `data-*` attributes stand on its `<pre>`.
| `emoji` | `<span class="adf-emoji" data-id="1f389" data-short-name=":tada:">🎉</span>`, its `text` the content |
| `hardBreak` | `<br>` |
| `inlineCard`, holding a `url` | `<a class="adf-inline-card" href="https://example.com/spec">https://example.com/spec</a>` |
| `inlineCard`, holding `data` and no `url` | `<a class="adf-inline-card" href="https://example.com/plan" data-data="{&quot;name&quot;:&quot;Release plan&quot;,&quot;url&quot;:&quot;https://example.com/plan&quot;}">Release plan</a>`, `href` the URL its `data` holds and the text its name, else that URL |
| `link`, holding `href` and an optional `title` | `<a href="…" title="…">` around the text |
| `mention` | `<span class="adf-mention" data-access-level="CONTAINER" data-id="5b10a2" data-user-type="DEFAULT">@Mikael</span>`, its `text` the content |
| `status` | `<span class="adf-status" data-color="yellow">In review</span>`, its `text` the content |
| `text` | its text, escaped |
A `date`, `emoji`, `inlineCard` or `status` writes each other attribute its type holds as a
A `date`, `emoji`, `inlineCard`, `mention` or `status` writes each other attribute its type holds as a
`data-*` attribute, named and sorted as on a block, `data-local-id` among them.
A text node's marks nest in the order its `marks` array lists them, the first outermost:
@@ -73,7 +75,7 @@ later release writes it as its own element.
```html
<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},…,&quot;type&quot;:&quot;panel&quot;}"><p>Read the <a href="https://spec.commonmark.org/">CommonMark spec</a> first.</p><p>Then import.</p></div>
<p>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>
<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;},&quot;type&quot;:&quot;mention&quot;}">@5b10a2</span></p>
```
- `data-adf` holds the node's JSON compact, keys sorted, as `!adf:carry` does in markdown, its type
@@ -115,8 +117,8 @@ later release writes it as its own element.
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`;
- an emoji or status with no `text`, a date whose `timestamp` names no day from year 1 to 9999,
and a card with no `url` or holding `data`;
- an emoji, mention or status with no `text`, a date whose `timestamp` names no day from year 1
to 9999, a card holding neither a `url` nor `data` with a URL, and a card holding both;
- 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;
@@ -147,9 +149,10 @@ that node in either spelling: an edit there would be lost, so it is `unmappable-
- Any other HTML is `unmappable-html`: another element, attribute or escape, attributes in another
order, a block on the line of another, a carriage return not followed by a line feed, or
non-empty HTML not ending in a line feed.
- An emoji, status, date or card element with empty text, a date whose `datetime` or text is not the
day its `data-timestamp` names, and a card whose `href` and text are not one URL are
`unmappable-html`.
- An emoji, mention, status, date or card element with empty text, a date whose `datetime` or text
is not the day its `data-timestamp` names, a card without `data-data` whose `href` and text are
not one URL, and a card with it whose `href` and text are not the URL and name its `data` holds,
are `unmappable-html`.
- A `data-*` attribute that its element does not take, such as `data-level` on `<h1>`, or that holds
a value outside its attribute's kind or not written as compact JSON, is `unsupported-node-shape`.
- A carry element's `data-adf` holding invalid JSON is `unmappable-html`. JSON that is no ADF node,
+19 -5
View File
@@ -136,20 +136,24 @@ test('carries every other node in an adf-carry element holding its JSON', () =>
html(adfToHtml(document(panel))),
'<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},&quot;content&quot;:[{&quot;content&quot;:[{&quot;text&quot;:&quot;Check the collation.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;}],&quot;type&quot;:&quot;panel&quot;}"><p>Check the collation.</p></div>\n',
)
const mention: AdfNode = { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }
assert.equal(html(adfToHtml(document(paragraph(text('Ask '), mention)))), '<p>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>\n')
const mention: AdfNode = { attrs: { id: '5b10a2' }, type: 'mention' }
assert.equal(html(adfToHtml(document(paragraph(text('Ask '), mention)))), '<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;},&quot;type&quot;:&quot;mention&quot;}">@5b10a2</span></p>\n')
})
test('writes an emoji, status, date and card holding a URL as their elements, their attributes data-* sorted by name', () => {
test('writes an emoji, mention, status, date and card as their elements, their attributes data-* sorted by name', () => {
const inline = (node: AdfNode): string => html(adfToHtml(document(paragraph(text('See '), node))))
assert.equal(inline({ attrs: { id: '1f389', localId: 'e1', shortName: ':tada:', text: '🎉' }, type: 'emoji' }), '<p>See <span class="adf-emoji" data-id="1f389" data-local-id="e1" data-short-name=":tada:">🎉</span></p>\n')
assert.equal(inline({ attrs: { text: '<3' }, type: 'emoji' }), '<p>See <span class="adf-emoji">&lt;3</span></p>\n')
assert.equal(inline({ attrs: { accessLevel: 'CONTAINER', id: '5b10a2', localId: 'm1', text: '@Mikael', userType: 'DEFAULT' }, type: 'mention' }), '<p>See <span class="adf-mention" data-access-level="CONTAINER" data-id="5b10a2" data-local-id="m1" data-user-type="DEFAULT">@Mikael</span></p>\n')
assert.equal(inline({ attrs: { color: 'yellow', localId: 's1', style: 'bold', text: 'In review' }, type: 'status' }), '<p>See <span class="adf-status" data-color="yellow" data-local-id="s1" data-style="bold">In review</span></p>\n')
assert.equal(inline({ attrs: { localId: 'd1', timestamp: '1756080000000' }, type: 'date' }), '<p>See <time class="adf-date" datetime="2025-08-25" data-local-id="d1" data-timestamp="1756080000000">2025-08-25</time></p>\n')
assert.equal(inline({ attrs: { localId: 'c1', url: 'https://example.com/spec?a=1&b=2' }, type: 'inlineCard' }), '<p>See <a class="adf-inline-card" href="https://example.com/spec?a=1&amp;b=2" data-local-id="c1">https://example.com/spec?a=1&amp;b=2</a></p>\n')
const plan = { name: 'Release plan', url: 'https://example.com/plan' }
assert.equal(inline({ attrs: { data: plan, localId: 'c2' }, type: 'inlineCard' }), '<p>See <a class="adf-inline-card" href="https://example.com/plan" data-data="{&quot;name&quot;:&quot;Release plan&quot;,&quot;url&quot;:&quot;https://example.com/plan&quot;}" data-local-id="c2">Release plan</a></p>\n')
assert.equal(inline({ attrs: { data: { url: 'https://example.com/plan' } }, type: 'inlineCard' }), '<p>See <a class="adf-inline-card" href="https://example.com/plan" data-data="{&quot;url&quot;:&quot;https://example.com/plan&quot;}">https://example.com/plan</a></p>\n')
})
test('carries an emoji, status, date or card holding a URL the element cannot hold', () => {
test('carries a node its element cannot hold', () => {
const carried = (node: AdfNode): boolean => html(adfToHtml(document(paragraph(node)))).includes('adf-carry')
const comment: AdfMark = { attrs: { annotationType: 'inlineComment', id: 'c1' }, type: 'annotation' }
assert.equal(carried({ attrs: { text: '🎉' }, marks: [comment], type: 'emoji' }), true)
@@ -171,7 +175,17 @@ test('carries an emoji, status, date or card holding a URL the element cannot ho
assert.equal(carried({ attrs: { url: 'https://example.com/' }, marks: [{ attrs: { href: 'https://example.org/' }, type: 'link' }], type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { url: '' }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { url: 'https://example.com/\u0001' }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }), true)
assert.equal(carried({ attrs: { data: { name: 'Roadmap', url: 'https://example.com/' } }, marks: [comment], type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { data: { url: 'https://example.com/\u0001' } }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { data: { name: 'Roadmap', url: ' ' } }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { data: 'https://example.com/' }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { data: {} }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { data: { name: 'Roadmap\u0001', url: 'https://example.com/' } }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { id: '5b10a2' }, type: 'mention' }), true)
assert.equal(carried({ attrs: { id: '5b10a2', text: '' }, type: 'mention' }), true)
assert.equal(carried({ attrs: { id: '5b10a2', text: '@Mikael' }, content: [text('x')], type: 'mention' }), true)
assert.equal(carried({ attrs: { id: '5b10a2', text: '@Mikael' }, marks: [comment], type: 'mention' }), true)
assert.equal(carried({ attrs: { id: 7, text: '@Mikael' }, type: 'mention' }), true)
})
test('shows each block a carried node holds as a paragraph in a div', () => {
+10 -10
View File
@@ -1,18 +1,18 @@
import type { AdfNode } from '../../adf/document.ts'
import { dataAttributes } from '../data-attributes.ts'
import { elementOfNodeType, htmlDay } from '../node-element.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { shownText } from '../../adf/shown-text.ts'
import { writableValue } from '../escaping.ts'
import { elementOfNodeType } from '../node-element.ts'
import { optionalAttribute, writableValue } from '../escaping.ts'
// `undefined` where the node is no emoji, status, date or card holding a URL, or holds what its element cannot write: it rides the carry.
// `undefined` where the node is no emoji, mention, status, date or card, or holds what its element cannot write: it rides the carry.
export function nodeElement(node: AdfNode): string | undefined {
const model = elementOfNodeType(node.type)
if (model === undefined || node.content !== undefined) return undefined
const data = dataAttributes(node, model.vocabulary, model.textAttribute === undefined ? [] : [model.textAttribute])
const held = model.textAttribute === undefined ? htmlDay(shownText(node)) : nodeAttrs(node)[model.textAttribute]
const shown = typeof held === 'string' && held !== '' ? writableValue(held) : undefined
if (data === undefined || shown === undefined) return undefined
const leading = model.leading === undefined ? '' : ` ${model.leading}="${shown}"`
const held = model.shown(node)
const leadingValue = model.leading?.value(node)
if (held === undefined || (model.leading !== undefined && leadingValue === undefined)) return undefined
const data = dataAttributes(node, model.vocabulary, model.written)
const leading = model.leading === undefined ? '' : optionalAttribute(model.leading.name, leadingValue)
const shown = writableValue(held)
if (data === undefined || leading === undefined || shown === undefined) return undefined
return `<${model.element} class="${model.class}"${leading}${data}>${shown}</${model.element}>`
}
+62 -9
View File
@@ -1,27 +1,80 @@
import type { AdfAttributes, AdfNode } from '../adf/document.ts'
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import { inlineNodeModel } from '../adf/inline-nodes.ts'
import { nodeAttrs } from '../adf/document.ts'
import { shownText, shownUrl } from '../adf/shown-text.ts'
// `textAttribute` names the attribute holding the shown text, none for a date, which shows the day its `timestamp` falls on; `leading` names the attribute right after the class, holding that same text.
// `shown` is the text the element holds and `leading.value` the attribute right after the class, each `undefined` where the node cannot be written as this element and rides the carry. The reader builds the node from the element's `data-*` attributes and text, then holds both to what `shown` and `leading.value` answer for it, so only what the writer writes reads back. `written` names the attributes the text stands in for, which no `data-*` attribute repeats.
export type NodeElement = {
build(data: AdfAttributes, shown: string): AdfAttributes
class: string
element: 'a' | 'span' | 'time'
leading: 'datetime' | 'href' | undefined
textAttribute: string | undefined
leading: { name: 'datetime' | 'href'; value(node: AdfNode): string | undefined } | undefined
mismatch: string
shown(node: AdfNode): string | undefined
vocabulary: AttributeVocabulary
written: readonly string[]
}
const nodeElements: Readonly<Record<string, NodeElement>> = {
date: { class: 'adf-date', element: 'time', leading: 'datetime', textAttribute: undefined, vocabulary: attributesOf('date') },
emoji: { class: 'adf-emoji', element: 'span', leading: undefined, textAttribute: inlineNodeModel('emoji')?.textAttribute, vocabulary: attributesOf('emoji') },
// Leaves out `data`, whose spelling is open (todo.md item 133), so a card holding it rides the carry.
inlineCard: { class: 'adf-inline-card', element: 'a', leading: 'href', textAttribute: 'url', vocabulary: { localId: 'string', url: 'string' } },
status: { class: 'adf-status', element: 'span', leading: undefined, textAttribute: inlineNodeModel('status')?.textAttribute, vocabulary: attributesOf('status') },
date: {
build: (data) => data,
class: 'adf-date',
element: 'time',
leading: { name: 'datetime', value: dayOf },
mismatch: 'shows the day its data-timestamp names, in datetime and as its text; to change the date, change all three',
shown: dayOf,
vocabulary: attributesOf('date'),
written: [],
},
emoji: textElement('emoji', 'adf-emoji'),
inlineCard: {
build: (data, shown) => (data['data'] === undefined ? { ...data, url: shown } : data),
class: 'adf-inline-card',
element: 'a',
leading: { name: 'href', value: (node) => card(node)?.href },
mismatch: 'holds one URL, as its href and as its text, or the name and URL its data-data holds, as its text and its href; to change the card, change them together',
shown: (node) => card(node)?.text,
vocabulary: attributesOf('inlineCard'),
written: ['url'],
},
mention: textElement('mention', 'adf-mention'),
status: textElement('status', 'adf-status'),
}
function attributesOf(type: string): AttributeVocabulary {
return inlineNodeModel(type)?.attributes ?? {}
}
// A card holding `data` shows its name and links to the URL `data` holds; one holding both `data` and a `url` rides the carry, since read back its href would not say which of the two it spells.
function card(node: AdfNode): { href: string; text: string } | undefined {
const { data, url } = nodeAttrs(node)
if (data === undefined) return typeof url === 'string' && url !== '' ? { href: url, text: url } : undefined
const href = url === undefined ? shownUrl(node) : undefined
const text = shownText(node)
return href === undefined || text === undefined ? undefined : { href, text }
}
function dayOf(node: AdfNode): string | undefined {
return htmlDay(shownText(node))
}
function textElement(type: string, className: string): NodeElement {
return {
build: (data, shown) => ({ ...data, text: shown }),
class: className,
element: 'span',
leading: undefined,
mismatch: 'holds its text, which is not empty',
shown: (node) => {
const text = nodeAttrs(node)['text']
return typeof text === 'string' && text !== '' ? text : undefined
},
vocabulary: attributesOf(type),
written: ['text'],
}
}
export function classesOfElement(element: string): string[] {
return Object.values(nodeElements).filter((candidate) => candidate.element === element).map((candidate) => candidate.class)
}
@@ -35,6 +88,6 @@ export function nodeTypeOfElement(element: string, className: string): [string,
}
// HTML's valid date string holds a four-digit year above 0, so a day outside years 1 to 9999 rides the carry.
export function htmlDay(day: string | undefined): string | undefined {
function htmlDay(day: string | undefined): string | undefined {
return day !== undefined && /^\d{4}-/.test(day) && !day.startsWith('0000-') ? day : undefined
}
+3 -2
View File
@@ -15,8 +15,9 @@ const holder = "a carry element's data-adf"
export function readCarry(html: string, tag: StartTag, element: 'div' | 'span', levels: number, place: Place): ReadNode {
const json = readCarryAttributes(tag.attributes)
if (json === undefined) {
const others = classesOfElement(tag.name)
const alternative = others.length === 0 ? '' : `, or opens with class="${others.join('" or "')}"`
const others = classesOfElement(tag.name).map((name) => `"${name}"`)
const last = others.pop()
const alternative = last === undefined ? '' : `, or opens with class=${others.length === 0 ? last : `${others.join(', ')} or ${last}`}`
return refused('unmappable-html', `a <${tag.name}> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone${alternative}: ${foundAt(html, tag.start)}`, html, tag.start, place)
}
const jsonAt = tag.start + carryJsonOffset(tag.name)
+21 -4
View File
@@ -191,7 +191,7 @@ test('reads a carry element holding a node HTML writes as its own element as tha
assert.deepEqual(read(htmlToAdf(`<ul>\n<li>\n${carry('div', paragraph(text('x')), '<p>x</p>')}\n</li>\n</ul>\n`)), document({ content: [item(paragraph(text('x')))], type: 'bulletList' }))
})
test('reads an emoji, status, date and card holding a URL from their elements', () => {
test('reads an emoji, mention, status, date and card from their elements', () => {
const inline = (node: AdfNode): AdfDocument | string => read(htmlToAdf(adfToHtmlText(document(paragraph(text('See '), node)))))
const nodes: AdfNode[] = [
{ attrs: { id: '1f389', localId: 'e1', shortName: ':tada:', text: '🎉' }, type: 'emoji' },
@@ -202,6 +202,11 @@ test('reads an emoji, status, date and card holding a URL from their elements',
{ attrs: { text: ' ' }, type: 'emoji' },
{ attrs: { text: ' ' }, type: 'status' },
{ attrs: { url: ' ' }, type: 'inlineCard' },
{ attrs: { accessLevel: 'CONTAINER', id: ids.mention, localId: 'm1', text: '@Mikael', userType: 'DEFAULT' }, type: 'mention' },
{ attrs: { text: ' ' }, type: 'mention' },
{ attrs: { data: { name: 'Release plan', url: 'https://example.com/plan?a=1&b=2' }, localId: 'c2' }, type: 'inlineCard' },
{ attrs: { data: { url: 'https://example.com/plan' } }, type: 'inlineCard' },
{ attrs: { data: { name: 'https://example.com/plan', url: 'https://example.com/plan' } }, type: 'inlineCard' },
]
for (const node of nodes) assert.deepEqual(inline(node), document(paragraph(text('See '), node)))
assert.deepEqual(read(htmlToAdf('<p><span class="adf-emoji">a&nbsp;&quot;b</span></p>\n')), document(paragraph({ attrs: { text: 'a\u00a0"b' }, type: 'emoji' })))
@@ -218,6 +223,8 @@ function adfToHtmlText(doc: AdfDocument): string {
}
// Each group's cases, the HTML and its refusal.
const planData = '{&quot;name&quot;:&quot;Plan&quot;,&quot;url&quot;:&quot;https://example.com/plan&quot;}'
const refusals: Readonly<Record<string, readonly (readonly [string, string])[]>> = {
'refuses block shapes adfToHtml would not write': [
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5'],
@@ -290,7 +297,16 @@ const refusals: Readonly<Record<string, readonly (readonly [string, string])[]>>
['<p><a class="adf-inline-card" data-local-id="x">x</a></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><a class="adf-inline-card" href="">a</a></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><a class="adf-inline-card" href="https://example.com/" title="t">https://example.com/</a></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><a class="adf-inline-card" href="https://example.com/" data-data="{}">https://example.com/</a></p>\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
['<p><a class="adf-inline-card" href="https://example.com/" data-data="{}">https://example.com/</a></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
[`<p><a class="adf-inline-card" href="https://example.org/plan" data-data="${planData}">Plan</a></p>\n`, 'unmappable-html at /content/0/content/0 line 1 offset 0'],
[`<p><a class="adf-inline-card" href="https://example.com/plan" data-data="${planData}">Roadmap</a></p>\n`, 'unmappable-html at /content/0/content/0 line 1 offset 0'],
[`<p><a class="adf-inline-card" data-data="${planData}">Plan</a></p>\n`, 'unmappable-html at /content/0/content/0 line 1 offset 0'],
[`<p><a class="adf-inline-card" href="https://example.com/plan" data-data="${planData}" data-url="https://example.com/plan">Plan</a></p>\n`, 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
['<p><a class="adf-inline-card" href="https://example.com/plan" data-data="{ &quot;name&quot;:&quot;Plan&quot;}">Plan</a></p>\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
['<p><span class="adf-mention"></span></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><span class="adf-mention" data-text="x">x</span></p>\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
['<p><span class="adf-mention" data-user-type="DEFAULT" data-id="x">x</span></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><a class="adf-mention">x</a></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
],
'refuses the document\'s carry anywhere but as the whole document, a line feed ending it': [
[documentCarry, 'unmappable-html at / line 1 offset 0'],
@@ -398,7 +414,7 @@ const messages: readonly (readonly [string, string])[] = [
['<p><em></em></p>\n', 'a mark element holds text, or one other mark element around text: found "</em></p>\\n"'],
['<p><strong><a href="https://x.test/?a=1&b=2">x</a></strong></p>\n', '<a> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: found "&b=2\\">x</a></strong></p>"…'],
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, 'a carry element shows what adfToHtml writes for its node, as it or a browser spells it, then </div>; to change the node, edit its ADF and write the HTML again: found "<b>collation</b>.</p></d"…'],
['<p><span>a</span></p>\n', 'a <span> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone, or opens with class="adf-emoji" or "adf-status": found "<span>a</span></p>\\n"'],
['<p><span>a</span></p>\n', 'a <span> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone, or opens with class="adf-emoji", "adf-mention" or "adf-status": found "<span>a</span></p>\\n"'],
['<div class="adf-carry" data-adf="{ &quot;type&quot;:&quot;x&quot;}"></div>\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ &quot;type&quot;:&quot"…'],
[`${documentCarry}\n<hr>\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n<hr>\\n"'],
[`<hr>\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "<div class=\\"adf-carry\\" d"…'],
@@ -414,7 +430,8 @@ const messages: readonly (readonly [string, string])[] = [
['<ol start="2" start="3">\n<li>a</li>\n</ol>\n', '<ol> takes start once, before its data-* attributes: found start after start'],
['<pre data-wrap="yes"><code>x</code></pre>\n', 'data-wrap holds no boolean written as compact JSON'],
['<p><span class="adf-emoji"></span></p>\n', '<span class="adf-emoji"> holds its text, which is not empty: found "</span></p>\\n"'],
['<p><a class="adf-inline-card" href="https://example.com/">Spec</a></p>\n', '<a class="adf-inline-card"> holds one URL, as its href and as its text; to change the card, change both: found "Spec</a></p>\\n"'],
['<p><a class="adf-inline-card" href="https://example.com/">Spec</a></p>\n', '<a class="adf-inline-card"> holds one URL, as its href and as its text, or the name and URL its data-data holds, as its text and its href; to change the card, change them together: found "Spec</a></p>\\n"'],
[`<p><a class="adf-inline-card" href="https://example.org/plan" data-data="${planData}">Plan</a></p>\n`, '<a class="adf-inline-card"> holds one URL, as its href and as its text, or the name and URL its data-data holds, as its text and its href; to change the card, change them together: found "Plan</a></p>\\n"'],
['<p><time class="adf-date" datetime="2025-08-26" data-timestamp="1756080000000">x</time></p>\n', '<time class="adf-date"> shows the day its data-timestamp names, in datetime and as its text; to change the date, change all three: found "x</time></p>\\n"'],
['<p><span class="adf-emoji" data-size="big">a</span></p>\n', '<span class="adf-emoji"> takes no data-size attribute'],
['<p><span class="adf-status">a&b</span></p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&b</span></p>\\n"'],
+10 -22
View File
@@ -1,41 +1,29 @@
import type { AdfAttributes, AdfNode } from '../../adf/document.ts'
import type { AdfNode } from '../../adf/document.ts'
import { foundAt, misspelledText, refused, type Place, type ReadNode, type StartTag } from './source.ts'
import { htmlDay, nodeTypeOfElement, type NodeElement } from '../node-element.ts'
import { nodeTypeOfElement } from '../node-element.ts'
import { readDataAttributes } from '../data-attributes.ts'
import { shownText } from '../../adf/shown-text.ts'
import { success } from '../../result.ts'
import { misspelledAt, unescapedValue } from '../escaping.ts'
// `undefined` where the tag opens no emoji, status, date or card element; one that does reads as the node `nodeElement` wrote it from, or is refused.
// `undefined` where the tag opens no emoji, mention, status, date or card element; one that does reads as the node `nodeElement` wrote it from, or is refused.
export function readNodeElement(html: string, tag: StartTag, place: Place): ReadNode | undefined {
const [kind, ...rest] = tag.attributes
const found = kind?.[0] === 'class' ? nodeTypeOfElement(tag.name, kind[1]) : undefined
if (found === undefined) return undefined
const [type, model] = found
if (model.leading !== undefined && rest[0]?.[0] !== model.leading) return refused('unmappable-html', `<${tag.name} class="${model.class}"> holds ${model.leading} first, before its data-* attributes: ${foundAt(html, tag.start)}`, html, tag.start, place)
const data = readDataAttributes(rest, `${tag.name} class="${model.class}"`, model.vocabulary, model.textAttribute === undefined ? [] : [model.textAttribute], model.leading)
const name = `<${tag.name} class="${model.class}">`
if (model.leading !== undefined && rest[0]?.[0] !== model.leading.name) return refused('unmappable-html', `${name} holds ${model.leading.name} first, before its data-* attributes: ${foundAt(html, tag.start)}`, html, tag.start, place)
const data = readDataAttributes(rest, `${tag.name} class="${model.class}"`, model.vocabulary, model.written, model.leading?.name)
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
const closer = `</${tag.name}>`
const stop = html.indexOf('<', tag.end)
const end = stop < 0 ? html.length : stop
const shown = unescapedValue(html.slice(tag.end, end), 'text')
if (shown === undefined) return misspelledText(html, tag.end + misspelledAt(html.slice(tag.end, end), 'text'), place)
if (!html.startsWith(closer, end)) return refused('unmappable-html', `<${tag.name} class="${model.class}"> holds its text, then ${closer}: ${foundAt(html, end)}`, html, end, place)
const node = nodeOf(type, model, data.value, shown)
const expected = model.textAttribute === undefined ? htmlDay(shownText(node)) : shown
if (expected === undefined || expected === '' || shown !== expected || (model.leading !== undefined && rest[0]?.[1] !== expected)) {
return refused('unmappable-html', `${mismatch(tag.name, model)}: ${foundAt(html, tag.end)}`, html, tag.end, place)
if (!html.startsWith(closer, end)) return refused('unmappable-html', `${name} holds its text, then ${closer}: ${foundAt(html, end)}`, html, end, place)
const node: AdfNode = { attrs: model.build(data.value, shown), type }
if (model.shown(node) !== shown || (model.leading !== undefined && model.leading.value(node) !== rest[0]?.[1])) {
return refused('unmappable-html', `${name} ${model.mismatch}: ${foundAt(html, tag.end)}`, html, tag.end, place)
}
return success({ end: end + closer.length, node })
}
function nodeOf(type: string, model: NodeElement, data: AdfAttributes, shown: string): AdfNode {
return { attrs: model.textAttribute === undefined ? data : { ...data, [model.textAttribute]: shown }, type }
}
function mismatch(name: string, model: NodeElement): string {
const element = `<${name} class="${model.class}">`
if (model.leading === 'datetime') return `${element} shows the day its data-timestamp names, in datetime and as its text; to change the date, change all three`
if (model.leading === 'href') return `${element} holds one URL, as its href and as its text; to change the card, change both`
return `${element} holds its text, which is not empty`
}
+11 -16
View File
@@ -12,7 +12,7 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 133 | 0.3.0 | | question | **Write mentions, and inline cards holding `data` or a link mark, as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
| 133 | 0.3.0 | | question | **Write an inline card a link marks as semantic HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
| 163 | 0.3.0 | | | **Gather a corpus of real-world ADF documents from public Jira and Confluence sites, each recording where it came from and under what licence.** | 3 | 2 | 4 | 7 | 4 | 6 by 3 | 3.0 |
| 164 | 0.3.0 | 163 | | **Publish a feature table setting chosen corpus examples beside what each call writes for them, readable at a glance by people and parseable by LLMs, and kept current by the gate.** | 3 | 2 | 1 | 4 | 5 | 3 by 6, 6 by 4 | 6.2 |
| 165 | 0.3.0 | 164 | | **Have a reader panel seated from `## Audience` judge each feature-table row reasonable or not, and file an item for each row it rejects.** | 2 | 2 | 1 | 3 | 3 | 6 by 5 | 7.5 |
@@ -69,23 +69,18 @@
## Details
### 133. Write mentions, and inline cards holding `data` or a link mark, as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.
### 133. Write an inline card a link marks as semantic HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.
The fifth writer panel (`docs/decisions.md` §The HTML dialect) did not reach 5 of 7 on two
spellings, so mentions and cards holding `data` ride the carry until the maintainer settles them.
Class: the HTML dialect's spellings. Earlier entries: `data-*` named as HTML's `dataset` names it,
`data-local-id` over `data-adf-local-id`. Candidates, each following those entries:
Mentions and cards holding `data` are elements since the maintainer took the fifth writer panel's
leading spellings (`docs/decisions.md` §The HTML dialect). A card a link marks still rides the carry,
which shows the link's target as the link and the card's URL as its text, so both stay in sight.
Atlassian's schema gives no card a link mark, so only a hand-built document meets this.
- a mention as `<span class="adf-mention" data-access-level="CONTAINER" data-id="5b10a2" data-user-type="DEFAULT">@Mikael</span>` (4 of 7; `data-mention-id` drew 2);
- a card holding `data` and no URL as `<a class="adf-inline-card" href="https://example.com/plan" data-data="{…}">Release plan</a>`, `href` the URL its `data` holds (3 of 7; `data-card` drew 2, a `<span>` without `href` 2).
Where a link marks a card, both the link's target and the card's URL stay in sight, a card showing
only its name included.
Candidate entry for §The HTML dialect, if the leading spellings stand: "A mention is
`<span class="adf-mention" data-access-level data-id data-user-type>` showing its text. A card
holding `data` and no URL is `<a class="adf-inline-card" href data-data>`, `href` the URL its `data`
holds, showing its name." The instance it yields for the example above: `<a class="adf-inline-card" href="https://example.com/plan" data-data="{…}">Release plan</a>`.
Class: the HTML dialect's spellings. Earlier entries: a card is `<a class="adf-inline-card" href …>`,
and HTML nests no `<a>` in an `<a>`, so the card's element has no room for the link beside it. The
nearest candidate keeps the carry: "A card a link marks rides the carry, which keeps both targets in
sight; no document Atlassian's editor writes holds one." Its instance is today's output, so the
choice is the maintainer's: this item ends with that entry, or with a spelling the maintainer names.
### 163. Gather a corpus of real-world ADF documents from public Jira and Confluence sites, each recording where it came from and under what licence.