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:
+6
-5
@@ -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,
|
||||
` `, 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`
|
||||
|
||||
@@ -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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@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
@@ -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
@@ -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="{"name":"Release plan","url":"https://example.com/plan"}">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="{"attrs":{"panelType":"info"},…,"type":"panel"}"><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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@Mikael</span></p>
|
||||
<p>Ask <span class="adf-carry" data-adf="{"attrs":{"id":"5b10a2"},"type":"mention"}">@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,
|
||||
|
||||
@@ -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="{"attrs":{"panelType":"info"},"content":[{"content":[{"text":"Check the collation.","type":"text"}],"type":"paragraph"}],"type":"panel"}"><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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@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="{"attrs":{"id":"5b10a2"},"type":"mention"}">@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"><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&b=2" data-local-id="c1">https://example.com/spec?a=1&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="{"name":"Release plan","url":"https://example.com/plan"}" 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="{"url":"https://example.com/plan"}">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', () => {
|
||||
|
||||
@@ -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}>`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "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 = '{"name":"Plan","url":"https://example.com/plan"}'
|
||||
|
||||
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="{ "name":"Plan"}">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 & and " as ", 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="{ "type":"x"}"></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 "{ "type":""…'],
|
||||
[`${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 &, < as < and > as >, and holds no character HTML cannot hold: found "&b</span></p>\\n"'],
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user