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

Answer principles round 2: 1 🔴, 2 🟡 (0 misread); mutants killed, dead branches gone, data-* in its own module, linked images keep both targets, every URL sink named

This commit is contained in:
2026-10-24 00:53:04 +02:00
committed by Lillem4n
parent 7bcbf49963
commit 73363a6756
10 changed files with 256 additions and 119 deletions
+5 -5
View File
@@ -3,11 +3,11 @@
## Unreleased
- `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, code blocks, rules, hard
breaks, external images, and `em`, `strong`, `code` and links as their elements, an attribute such
as `localId` as `data-local-id`. Every other node is an element of class `adf-carry` holding the
node's JSON in `data-adf` and showing its content, links included. A link's URL is written as the
ADF holds it, `javascript:` included: sanitize HTML from an author you do not trust before showing
it.
breaks, external images, and `em`, `strong`, `code` and links as their elements. A paragraph,
heading, quote, list, list item, code block or rule writes an attribute such as `localId` as
`data-local-id`. Every other node is an element of class `adf-carry` holding the node's JSON in
`data-adf` and showing its content, links included. Every URL is written as the ADF holds it,
`javascript:` included: sanitize HTML from an author you do not trust before showing it.
- **Breaking:** `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status`
or `mediaInline` only the marks Atlassian's schema lets it hold: `**!adf:mention[@A]{id=…}**` now
reads as a plain mention. A link or directive mark around only nodes that cannot take it is
+10 -6
View File
@@ -488,12 +488,16 @@ content. No stylesheet ships. The complete dialect:
- 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>` 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.
- In a `<div>`, the carry element shows each block's content as a `<p>`, and a code block's as
`<pre><code>` keeping its indentation; in a `<span>`, it parts blocks with `<br>`. It shows text a
link marks as a link, a mention, status or emoji by its text, a date as `2026-09-13` in UTC, a
card as a link to its URL, an external image as a link to it, a stored one by its `alt`, and an
expand by its title. An image a link to another target marks shows that link, then `(image)`
linking to the image. What it cannot show leaves a note, `<em>(image)</em>`, inside the node's
link where it has one.
- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, and a
card's or a carried link's in the carry element. Sanitize the HTML before showing a document from
an author you do not trust.
## The errors
+16 -4
View File
@@ -418,6 +418,15 @@ paragraph holds an attribute, the shape of every item Atlassian's editor writes.
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.
A fourth panel of the same three roles picked, 3 of 3, a code block inside a carry element as
`<pre><code>…</code></pre>` over `<pre>…</pre>` and over a `<p>` whose lines part at `<br>`, which
loses the indentation. All three wrote `<code class="language-python">`, which no option held.
Inside a carry element, an image a link to another target marks takes the spelling portable
markdown gives it inline (§A linked image standing inline in portable markdown, its link to another
target, is written `[alt](href) ([image](url))`), keeping both targets, which Goal 7 counts as
content. A link marking a node that shows a note goes around the note.
## No runtime dependencies
2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
@@ -818,10 +827,13 @@ that, JavaScriptCore throws `Out of memory`, which is `engine-limit` (§Which co
2026-10-23, the maintainer. Goal 1. Valid until `todo.md` item 142 weighs the alternatives.
`adfToHtml` writes a link's `href` and an image's `src` exactly as the ADF holds them, escaped as
every attribute value is, `javascript:`, `vbscript:`, `file:` and `data:` included. Carrying such a
link or image in the carry element, as cmark's default leaves these URLs out of its HTML, was
proposed and not taken: item 142 weighs that against writing the URL.
`adfToHtml` writes every URL exactly as the ADF holds it, escaped as every attribute value is,
`javascript:`, `vbscript:`, `file:` and `data:` included. That covers a link's `href`, an image's
`src`, and the `href` of each link the carry element shows: a carried node's link mark, a card's
`url` or `data.url`, and an external image's `url`. Carrying such a link or image in the carry
element, as cmark's default leaves these URLs out of its HTML, was proposed and not taken: item 142
weighs that against writing the URL. Carrying alone no longer keeps the URL out of the HTML, since
the carry element links what it shows, so that remedy would also drop the shown `href`.
## Nothing recurses unbounded
+13 -6
View File
@@ -47,9 +47,9 @@ marks are two `<a>` elements.
## Escaping
Text and attribute values escape `&`, `<`, `>` and `"` as `&amp;`, `&lt;`, `&gt;` and `&quot;`.
Attribute values are double-quoted. A link's `href` and an image's `src` are the URL as the ADF
holds it, whatever its scheme (`docs/decisions.md` §HTML writes a URL as the ADF holds it, whatever
its scheme).
Attribute values are double-quoted. Every URL is written as the ADF holds it, whatever its scheme
(`docs/decisions.md` §HTML writes a URL as the ADF holds it, whatever its scheme): a link's `href`,
an image's `src`, and the `href` of each link a carry element shows.
HTML text and attribute values cannot hold a carriage return, which a parser reads as a line feed,
a control other than a tab, line feed or form feed, a noncharacter, or a lone surrogate, which
@@ -76,13 +76,20 @@ later release writes it as its own element.
in its place: a mention's text, else `@` and its id; an emoji's text, else its `shortName`; a
status's text; a date as `2026-09-13` in UTC; a card's URL, else its name, else the URL its data
holds; media's `alt`, else an external one's URL; an extension's text; an expand's title.
- In a `<div>`, each block's content is a `<p>`; in a `<span>`, `<br>` parts two blocks'.
- A hard break and a line feed in text are `<br>`.
- In a `<div>`, each block's content is a `<p>`, and a code block's is
`<pre><code class="language-js">…</code></pre>`, keeping its line feeds and spaces. Its class
is the one the code block's element takes, and it has none where the language is no class
token. In a `<span>`, `<br>` parts two blocks'.
- A hard break is `<br>`, and so is a line feed in text outside a `<div>`'s code block.
- Text a link marks is `<a href="…" title="…">` around it, and a card or an external image is a
link to its URL.
- An image a link to another target marks shows as portable markdown writes it inline, the link
around its `alt`, else the link's target, else its URL, then `image` linking to the image in
parentheses: `<a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)`.
- A node referencing what it cannot show is a note naming it, `<em>(image)</em>`: `image` for
media with no `alt` or URL, `link card` for a card with neither URL nor name, an extension's
key, else `extension`, for an extension with no text, and `synced block` for a `syncBlock`.
key, else `extension`, for an extension with no text, and `synced block` for a `syncBlock`. A
link marking the node goes around the note: `<a href="…"><em>(image)</em></a>`.
- Characters HTML cannot hold are dropped.
- A node rides the carry where it holds a mark or a key its element does not write, an attribute
its type does not hold as that kind, an empty `attrs`, `content` or `marks`, or a value its
+97 -14
View File
@@ -7,10 +7,13 @@ import { adfToHtml } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
const ids = {
item: '0192f1a2-6b3c-7d4e-8f50-6a7b8c9d0e1f',
list: '0192f1a2-6b3c-7d4e-9a51-6a7b8c9d0e20',
paragraph: '0192f1a2-6b3c-7d4e-ab52-6a7b8c9d0e21',
quote: '0192f1a2-6b3c-7d4e-b853-6a7b8c9d0e22',
collection: '019fa884-c105-7043-ba0c-2992cfbda729',
file: '01a0be1b-33b1-7324-b77d-9c2d49dacde7',
item: '019ad5af-efd9-7f4e-a7c9-6aef16d4945f',
list: '019d8e77-7df8-7a83-91ea-43a7ca2aae3b',
paragraph: '01957ad9-e3f7-70aa-a5f3-4c013274f488',
quote: '019d2fe4-3e67-7b76-b40a-a776a8f76aea',
resource: '019695b0-e8cc-7bf7-8c1d-855237b7a090',
}
function document(...content: AdfNode[]): AdfDocument {
@@ -82,6 +85,7 @@ test('carries text holding a character HTML text cannot hold, escaping it in the
}
assert.equal(carriedSpans(text('😄 a b')), 0)
assert.equal(html(adfToHtml(document(paragraph(text('a\u0085﷐b', { type: 'underline' }))))).includes('&quot;text&quot;:&quot;a\\u0085\\ufdd0b&quot;'), true)
assert.equal(html(adfToHtml(document(paragraph(text('a\u{1fffe}b', { type: 'underline' }))))).includes('&quot;text&quot;:&quot;a\\ud83f\\udffeb&quot;'), true)
})
test('writes lists tight, an item holding more than one paragraph or a list in elements after the first', () => {
@@ -136,17 +140,57 @@ test('carries every other node in an adf-carry element holding its JSON', () =>
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')
})
test('shows a carried node\'s blocks as paragraphs, its links as anchors and its hard breaks as line breaks', () => {
test('shows each block a carried node holds as a paragraph in a div', () => {
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.')), paragraph(text('Then import.'))], type: 'panel' }
assert.equal(shown(html(adfToHtml(document(panel)))), '<p>Check the collation.</p><p>Then import.</p>')
})
test('shows a carried node\'s content after a block it holds as a paragraph of its own', () => {
assert.equal(shown(html(adfToHtml(document({ content: [paragraph(text('a')), text('b')], type: 'panel' })))), '<p>a</p><p>b</p>')
})
test('shows text a link marks in a carried node as a link', () => {
const link: AdfMark = { attrs: { href: 'https://spec.commonmark.org/', title: 'The spec' }, type: 'link' }
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Read the '), text('CommonMark spec', link), text(' first.')), paragraph(text('Then'), { type: 'hardBreak' }, text('import.'))], type: 'panel' }
assert.equal(shown(html(adfToHtml(document(panel)))), '<p>Read the <a href="https://spec.commonmark.org/" title="The spec">CommonMark spec</a> first.</p><p>Then<br>import.</p>')
assert.equal(shown(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' })))), '<p>Details</p><p>Inside</p>')
assert.equal(shown(html(adfToHtml(document({ content: [text('a\nb'), text('c')], type: 'codeBlock' })))), '<p>a<br>bc</p>')
const panel: AdfNode = { content: [paragraph(text('Read the '), text('CommonMark spec', link), text(' first.'))], type: 'panel' }
assert.equal(shown(html(adfToHtml(document(panel)))), '<p>Read the <a href="https://spec.commonmark.org/" title="The spec">CommonMark spec</a> first.</p>')
assert.equal(shown(html(adfToHtml(document(paragraph(text('x', { type: 'underline' }, link)))))), '<a href="https://spec.commonmark.org/" title="The spec">x</a>')
})
test('shows a hard break, and a line feed in carried text outside a code block, as a line break', () => {
assert.equal(shown(html(adfToHtml(document({ content: [paragraph(text('Then'), { type: 'hardBreak' }, text('import.'))], type: 'panel' })))), '<p>Then<br>import.</p>')
assert.equal(shown(html(adfToHtml(document({ content: [paragraph(text('Then\nimport.'))], type: 'panel' })))), '<p>Then<br>import.</p>')
assert.equal(shown(html(adfToHtml(document(paragraph(text('a\nb', { type: 'underline' })))))), 'a<br>b')
})
test('shows an expand\'s title as a paragraph of its own', () => {
assert.equal(shown(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' })))), '<p>Details</p><p>Inside</p>')
})
test('parts the blocks a span carry shows with line breaks', () => {
assert.equal(shown(html(adfToHtml(document(paragraph({ content: [paragraph(text('a')), paragraph(text('b'))], type: 'panel' }))))), 'a<br>b')
})
test('drops from what a carry element shows the characters HTML cannot hold, and the link of a URL holding one', () => {
assert.equal(shown(html(adfToHtml(document(paragraph(text('a\u0001b\r', { type: 'underline' }, { attrs: { href: 'https://example.com/\u0001' }, type: 'link' })))))), 'ab')
})
test('shows a code block in a div carry as pre and code, keeping its line feeds and spaces', () => {
const code: AdfNode = { attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }
assert.equal(shown(html(adfToHtml(document({ attrs: { panelType: 'info' }, content: [paragraph(text('Run this:')), code], type: 'panel' })))), '<p>Run this:</p><pre><code class="language-python">if ready:\n deploy()</code></pre>')
assert.equal(shown(html(adfToHtml(document({ content: [text('a\r\n b'), text('c')], type: 'codeBlock' })))), '<pre><code>a\n bc</code></pre>')
})
test('gives a code block in a div carry the class its element takes, and none where its language is no class token', () => {
const carried = (language: AdfAttributes[string]): string => shown(html(adfToHtml(document({ attrs: { language }, content: [text('x')], marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], type: 'codeBlock' }))))
assert.equal(carried('js'), '<pre><code class="language-js">x</code></pre>')
assert.equal(carried('c sharp'), '<pre><code>x</code></pre>')
assert.equal(carried(5), '<pre><code>x</code></pre>')
})
test('shows a code block in a span carry as text, its line feeds line breaks', () => {
assert.equal(shown(html(adfToHtml(document(paragraph({ attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }))))), 'if ready:<br> deploy()')
})
test('shows the content a carried node holds in an attribute, a link to what it links to', () => {
const inline = (node: AdfNode): string => shown(html(adfToHtml(document(paragraph(node)))))
assert.equal(inline({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14')
@@ -159,15 +203,35 @@ test('shows the content a carried node holds in an attribute, a link to what it
const image = (media: AdfNode): string => shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [media], type: 'mediaSingle' }))))
assert.equal(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '<p><a href="https://example.com/moon.jpg">The moon</a></p>')
assert.equal(image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '<p><a href="https://example.com/moon.jpg">https://example.com/moon.jpg</a></p>')
assert.equal(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { title: 'No target' }, type: 'link' }], type: 'media' }), '<p><a href="https://example.com/moon.jpg">The moon</a></p>')
})
test('shows a note naming what a carried node references where it shows no text', () => {
test('shows a carried image a link to another target marks as that link, then a link to the image reading image', () => {
const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' }
const image = (attrs: AdfAttributes): string => shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs, marks: [link], type: 'media' }], type: 'mediaSingle' }))))
assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }), '<p><a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)</p>')
assert.equal(image({ type: 'external', url: 'https://example.com/moon.jpg' }), '<p><a href="https://example.com/">https://example.com/</a> (<a href="https://example.com/moon.jpg">image</a>)</p>')
assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/' }), '<p><a href="https://example.com/">The moon</a></p>')
const blank = shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { href: ' ' }, type: 'link' }], type: 'media' }], type: 'mediaSingle' }))))
assert.equal(blank, '<p><a href=" ">https://example.com/moon.jpg</a> (<a href="https://example.com/moon.jpg">image</a>)</p>')
const inline: AdfNode = { attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [link], type: 'mediaInline' }
assert.equal(shown(html(adfToHtml(document(paragraph(inline))))), '<a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)')
})
test('links a carried card a link marks to that link\'s target alone', () => {
const card: AdfNode = { attrs: { url: 'https://example.com/roadmap' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' }
assert.equal(shown(html(adfToHtml(document(paragraph(card))))), '<a href="https://example.com/">https://example.com/roadmap</a>')
})
test('shows a note naming what a carried node references where it shows no text, linked where a link marks the node', () => {
const inline = (node: AdfNode): string => shown(html(adfToHtml(document(paragraph(node)))))
assert.equal(inline({ attrs: { collection: 'contentId-123', id: 'f1', type: 'file' }, type: 'mediaInline' }), '<em>(image)</em>')
const stored = { collection: ids.collection, id: ids.file, type: 'file' }
assert.equal(inline({ attrs: stored, type: 'mediaInline' }), '<em>(image)</em>')
assert.equal(inline({ attrs: stored, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'mediaInline' }), '<a href="https://example.com/"><em>(image)</em></a>')
assert.equal(inline({ attrs: {}, type: 'inlineCard' }), '<em>(link card)</em>')
assert.equal(inline({ attrs: { extensionKey: 'jira-issues' }, type: 'inlineExtension' }), '<em>(jira-issues)</em>')
assert.equal(inline({ attrs: { extensionType: 'com.example' }, type: 'inlineExtension' }), '<em>(extension)</em>')
assert.equal(shown(html(adfToHtml(document({ attrs: { resourceId: 'r1' }, type: 'syncBlock' })))), '<p><em>(synced block)</em></p>')
assert.equal(shown(html(adfToHtml(document({ attrs: { resourceId: ids.resource }, type: 'syncBlock' })))), '<p><em>(synced block)</em></p>')
})
test('carries a node holding what its element cannot', () => {
@@ -177,14 +241,20 @@ test('carries a node holding what its element cannot', () => {
assert.equal(carriedBlock({ attrs: {}, type: 'rule' }), true)
assert.equal(carriedBlock({ content: [text('x')], type: 'rule' }), true)
assert.equal(carriedBlock({ attrs: { color: 'a\u0001' }, type: 'rule' }), true)
assert.equal(carriedBlock({ attrs: { level: 1 }, type: 'rule' }), true)
assert.equal(carriedBlock({ type: 'unknownBlock' }), true)
for (const level of [0, 1.5, 7]) assert.equal(carriedBlock({ attrs: { level }, content: [text('x')], type: 'heading' }), true, String(level))
assert.equal(carriedBlock({ marks: [{ attrs: { level: 1 }, type: 'indentation' }], type: 'blockquote' }), true)
for (const order of [1.5, -0]) assert.equal(carriedBlock({ attrs: { order }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true, String(order))
assert.equal(carriedBlock({ attrs: { localId: 7 }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true)
assert.equal(carriedBlock({ content: [item(paragraph(text('x'))), paragraph(text('y'))], type: 'bulletList' }), true)
assert.equal(carriedBlock({ content: [{ attrs: {}, content: [paragraph(text('x'))], type: 'listItem' }], type: 'bulletList' }), true)
assert.equal(carriedBlock({ content: [{ content: [paragraph(text('x'))], text: 'y', type: 'listItem' }], type: 'bulletList' }), true)
assert.equal(carriedBlock({ content: [], type: 'bulletList' }), true)
assert.equal(carriedBlock({ attrs: { language: 'c sharp' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ attrs: { language: 5 }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ attrs: { localId: 7 }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ content: [text('x')], marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ attrs: { language: 'a\0b' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ content: [text('a'), text('b')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ content: [text('x', { type: 'code' })], type: 'codeBlock' }), true)
@@ -197,23 +267,27 @@ test('carries a node holding what its element cannot', () => {
test('carries inline what its element cannot hold, and a text node an unmarked one before it would join', () => {
assert.equal(carriedSpans(text('a'), text('b')), 1)
assert.equal(carriedSpans(text('a'), text('b'), text('c')), 1)
assert.equal(carriedSpans(text('a'), text('b', { type: 'em' }), text('c')), 0)
assert.equal(carriedSpans(text('')), 1)
assert.equal(carriedSpans({ type: 'text' }), 1)
assert.equal(carriedSpans(text('x', { type: 'em' }, { type: 'em' })), 1)
assert.equal(carriedSpans({ marks: [], text: 'x', type: 'text' }), 1)
assert.equal(carriedSpans(text('x', { attrs: {}, type: 'em' })), 1)
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/' }, type: 'underline' })), 1)
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/', id: ids.item }, type: 'link' })), 1)
assert.equal(carriedSpans({ attrs: { text: '\n' }, type: 'hardBreak' }), 1)
assert.equal(carriedSpans({ content: [text('x')], type: 'hardBreak' }), 1)
assert.equal(carriedSpans(paragraph(text('x'))), 1)
})
test('writes a link\'s or image\'s URL as the ADF holds it, escaped, whatever its scheme', () => {
test('writes every URL as the ADF holds it, escaped, whatever its scheme', () => {
const linked = (href: string): string => html(adfToHtml(document(paragraph(text('x', { attrs: { href }, type: 'link' })))))
assert.equal(linked('javascript:alert("x")'), '<p><a href="javascript:alert(&quot;x&quot;)">x</a></p>\n')
assert.equal(linked('data:text/html,<b>x</b>'), '<p><a href="data:text/html,&lt;b&gt;x&lt;/b&gt;">x</a></p>\n')
const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { type: 'external', url: 'vbscript:x' }, type: 'media' }], type: 'mediaSingle' }
assert.equal(html(adfToHtml(document(image))), '<p><img src="vbscript:x"></p>\n')
assert.equal(shown(html(adfToHtml(document(paragraph({ attrs: { url: 'javascript:alert(1)' }, type: 'inlineCard' }))))), '<a href="javascript:alert(1)">javascript:alert(1)</a>')
})
test('writes a container holding no content key as its element holding nothing', () => {
@@ -245,7 +319,9 @@ test('refuses what is no document, and a version HTML cannot carry', () => {
test('writes blocks nested 500 levels, and refuses the next level, a list\'s items one level below it', () => {
assert.equal(refusal(adfToHtml(document(nested('blockquote', largestNesting, paragraph(text('x')))))), 'written')
assert.equal(refusal(adfToHtml(document(nested('blockquote', largestNesting + 1, paragraph(text('x')))))).startsWith('unsupported-nesting-depth at /'), true)
const deep = adfToHtml(document(nested('blockquote', largestNesting + 1, paragraph(text('x')))))
assert.equal(refusal(deep).startsWith('unsupported-nesting-depth at /'), true)
assert.equal(!deep.ok && deep.error.message.includes('500'), true)
let items: AdfNode = paragraph(text('x'))
for (let level = 0; level < largestNesting; level += 1) items = { content: [item(items)], type: 'bulletList' }
assert.equal(refusal(adfToHtml(document(items))), 'written')
@@ -274,3 +350,10 @@ test('refuses HTML past V8\'s longest string, at the block passing it', () => {
const code: AdfNode = { content: [text('a'.repeat(536870888 / 2))], type: 'codeBlock' }
assert.equal(tooLong(adfToHtml(document(code, code, nested('blockquote', largestNesting + 1, { type: 'rule' })))), true)
})
test('writes HTML exactly as long as V8\'s longest string', () => {
// Eight paragraphs, each `<p>`, its text, `</p>` and a line feed, make the bound exactly.
const block = paragraph(text('a'.repeat(536870888 / 8 - 8)))
const written = adfToHtml(document(block, block, block, block, block, block, block, block))
assert.equal(written.ok && written.value.length, 536870888)
})
+20 -38
View File
@@ -1,21 +1,23 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { anchorOpener, optionalAttribute, writableValue } from './escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { anchorOpener, languageClass, optionalAttribute, writableValue } from './escaping.ts'
import { blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock, carryElement } from './carry-element.ts'
import { dataAttributes } from './data-attributes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
import { inlineHtml, writableText } from './inline.ts'
import { isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
import { largestNesting } from '../../nesting.ts'
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { plainImage } from '../../adf/plain-image.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; an item's attributes are its element's, checked by its list.
type Block = { depth: number; kind: 'block'; node: AdfNode; path: ConvertErrorPath }
type Item = { attributes: string; depth: number; kind: 'item'; node: AdfNode; path: ConvertErrorPath }
type Pending = Block | Item | string
const headingLevels: readonly unknown[] = [1, 2, 3, 4, 5, 6]
export function adfToHtml(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeHtml(document))
}
@@ -59,23 +61,8 @@ function writeBlock(block: Block): Result<Pending[]> {
return html.ok ? success([html.value]) : html
}
// The attributes a node holds besides those its element writes, as `data-*` attributes sorted by key, a value that is no string as JSON; `undefined` where the node holds what no attribute writes: a mark, text, an empty key, or an attribute its type does not hold as that kind (docs/decisions.md §The HTML dialect).
function dataAttributes(node: AdfNode, written: readonly string[]): string | undefined {
const model = blockNodeModel(node.type)
if (model === undefined || !holdsOnlyAttributes(node, Object.keys(model.attributes))) return undefined
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, written)
if (pairs === undefined) return undefined
let attributes = ''
for (const { key, kind, value } of pairs.sort((left, right) => (left.key < right.key ? -1 : 1))) {
const attribute = optionalAttribute(`data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`, kind === 'string' ? value : serializeCanonicalJson(value, 'compact'))
if (attribute === undefined) return undefined
attributes += attribute
}
return attributes
}
function quotePieces({ depth, node, path }: Block): Pending[] | undefined {
const attributes = dataAttributes(node, [])
const attributes = dataAttributes(node, blockNodes.blockquote.attributes, [])
return attributes === undefined ? undefined : containerPieces('blockquote', attributes, node.content === undefined ? undefined : blockPieces(node.content, path, depth + 1))
}
@@ -86,7 +73,7 @@ function listPieces({ depth, node, path }: Block): Pending[] | undefined {
const items: Pending[] = []
// The items' blocks sit one level below the list, as a markdown list's do.
for (const [index, item] of nodeContent(node).entries()) {
const attributes = item.type === 'listItem' ? dataAttributes(item, []) : undefined
const attributes = item.type === 'listItem' ? dataAttributes(item, blockNodes.listItem.attributes, []) : undefined
if (attributes === undefined) return undefined
if (index > 0) items.push('\n')
items.push({ attributes, depth: depth + 1, kind: 'item', node: item, path: [...path, 'content', index] })
@@ -96,13 +83,13 @@ function listPieces({ depth, node, path }: Block): Pending[] | undefined {
function listElement(node: AdfNode): { attributes: string; tag: string } | undefined {
if (node.type === 'bulletList') {
const attributes = dataAttributes(node, [])
const attributes = dataAttributes(node, blockNodes.bulletList.attributes, [])
return attributes === undefined ? undefined : { attributes, tag: 'ul' }
}
if (node.type !== 'orderedList') return undefined
const order = nodeAttrs(node)['order']
if (order !== undefined && (typeof order !== 'number' || !Number.isSafeInteger(order) || Object.is(order, -0))) return undefined
const attributes = dataAttributes(node, ['order'])
if (order !== undefined && (!Number.isSafeInteger(order) || Object.is(order, -0))) return undefined
const attributes = dataAttributes(node, blockNodes.orderedList.attributes, ['order'])
return attributes === undefined ? undefined : { attributes: `${order === undefined ? '' : ` start="${order}"`}${attributes}`, tag: 'ol' }
}
@@ -126,17 +113,15 @@ function writeItem({ attributes, depth, node, path }: Item): Result<Pending[]> {
// `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, [])
const attributes = dataAttributes(paragraph, blockNodes.paragraph.attributes, [])
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 {
if (node.type === 'paragraph') return inlineElement('p', dataAttributes(node, []), nodeContent(node), path)
if (node.type === 'heading') {
const level = nodeAttrs(node)['level']
if (typeof level === 'number' && Number.isInteger(level) && level >= 1 && level <= 6) return inlineElement(`h${level}`, dataAttributes(node, ['level']), nodeContent(node), path)
}
if (node.type === 'paragraph') return inlineElement('p', dataAttributes(node, blockNodes.paragraph.attributes, []), nodeContent(node), path)
const level = nodeAttrs(node)['level']
if (node.type === 'heading' && headingLevels.includes(level)) return inlineElement(`h${level}`, dataAttributes(node, blockNodes.heading.attributes, ['level']), nodeContent(node), path)
const html = tryCodeBlock(node) ?? tryImage(node) ?? tryRule(node)
return html === undefined ? undefined : success(html)
}
@@ -148,14 +133,11 @@ function inlineElement(tag: string, attributes: string | undefined, nodes: reado
return inline.ok ? success(`<${tag}${attributes}>${inline.value}</${tag}>`) : inline
}
// A class holds the language as one token, so a language holding whitespace rides the carry.
function tryCodeBlock(node: AdfNode): string | undefined {
if (node.type !== 'codeBlock') return undefined
const attributes = dataAttributes(node, ['language'])
const language = nodeAttrs(node)['language']
if (attributes === undefined || (language !== undefined && (typeof language !== 'string' || !/^[^\t\n\f\r ]+$/.test(language)))) return undefined
const className = optionalAttribute('class', language === undefined ? undefined : `language-${language}`)
if (className === undefined) return undefined
const attributes = dataAttributes(node, blockNodes.codeBlock.attributes, ['language'])
const className = languageClass(nodeAttrs(node)['language'])
if (attributes === undefined || className === undefined) return undefined
if (node.content === undefined) return `<pre${attributes}><code${className}></code></pre>`
const [only, ...others] = node.content
const code = only === undefined || others.length > 0 || !isUnmarkedSpellableText(only) ? undefined : writableText(only.text)
@@ -163,7 +145,7 @@ function tryCodeBlock(node: AdfNode): string | undefined {
}
function tryImage(node: AdfNode): string | undefined {
const image = node.type === 'mediaSingle' ? plainImage(node) : undefined
const image = plainImage(node)
if (image === undefined) return undefined
const src = writableValue(image.url)
const alt = optionalAttribute('alt', image.alt)
@@ -175,6 +157,6 @@ function tryImage(node: AdfNode): string | undefined {
}
function tryRule(node: AdfNode): string | undefined {
const attributes = node.type === 'rule' && nodeContent(node).length === 0 ? dataAttributes(node, []) : undefined
const attributes = node.type === 'rule' && nodeContent(node).length === 0 ? dataAttributes(node, blockNodes.rule.attributes, []) : undefined
return attributes === undefined ? undefined : `<hr${attributes}>`
}
+51 -34
View File
@@ -1,64 +1,81 @@
import type { AdfNode } from '../../adf/document.ts'
import { anchorOpener, jsonValue, shownValue } from './escaping.ts'
import { anchorOpener, jsonValue, languageClass, shownValue } from './escaping.ts'
import { carriedJson } from '../../adf/carried-json.ts'
import { largestNesting } from '../../nesting.ts'
import { linkAttrs, type LinkAttrs } from '../../adf/mark-attributes.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { partsShownText, referenceName, shownText, shownUrl } from '../../adf/shown-text.ts'
import { partsShownText, referenceName, shownText, shownUrl, visibleText } from '../../adf/shown-text.ts'
import { success, type ConvertErrorPath, type Result } from '../../result.ts'
// Where the carry element stands: a `<div>` shows each block as a paragraph, a `<span>` parts them with line breaks.
type Position = 'block' | 'inline'
// The content a carry element shows of one block; `code` the code block holding it, if any.
type ShownBlock = { code: AdfNode | undefined; html: string }
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting - depth)
return json.ok ? success(carryElement('div', json.value.json, shownContent(node, 'block'))) : json
if (!json.ok) return json
const shown = shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `<p>${html}</p>` : `<pre><code${languageClass(nodeAttrs(code)['language']) ?? ''}>${html}</code></pre>`))
return success(carryElement('div', json.value.json, shown.join('')))
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting)
return json.ok ? success(carryElement('span', json.value.json, shownContent(node, 'inline'))) : json
return json.ok ? success(carryElement('span', json.value.json, shownBlocks(node, '<br>').map(({ html }) => html).join('<br>'))) : json
}
export function carryElement(tag: string, json: string, content: string): string {
return `<${tag} class="adf-carry" data-adf="${jsonValue(json)}">${content}</${tag}>`
}
// What a carry element shows, its links and its notes as the writer panel picked them (docs/decisions.md §The HTML dialect); a reader of the element reads its JSON alone.
function shownContent(root: AdfNode, position: Position): string {
const groups: string[] = []
let group = ''
const pending: (AdfNode | 'end of block')[] = [root]
// What a carry element shows, block by block, as the writer panels picked it (docs/decisions.md §The HTML dialect); a reader of the element reads its JSON alone.
function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] {
const shown: ShownBlock[] = []
// The blocks the walk is inside, innermost last; a `null` on `pending` closes the innermost.
const blocks: AdfNode[] = []
let html = ''
const pending: (AdfNode | null)[] = [root]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const parts = next === 'end of block' || partsShownText(next.type)
if (parts && group !== '') groups.push(group)
if (parts) group = ''
if (next === 'end of block') continue
group += shownPiece(next)
if (partsShownText(next.type)) pending.push('end of block')
const parts = next === null || partsShownText(next.type)
if (parts && html !== '') shown.push({ code: innermostCode(blocks), html })
if (parts) html = ''
if (next === null) {
blocks.pop()
continue
}
if (parts) {
blocks.push(next)
pending.push(null)
}
html += shownPiece(next, innermostCode(blocks) === undefined ? '<br>' : codeLineFeed)
for (const child of [...nodeContent(next)].reverse()) pending.push(child)
}
if (group !== '') groups.push(group)
return position === 'block' ? groups.map((shown) => `<p>${shown}</p>`).join('') : groups.join('<br>')
if (html !== '') shown.push({ code: undefined, html })
return shown
}
// The node's own part of what its carry element shows: its text or what it shows in its place, linked where it links; a note where it references what it cannot show.
function shownPiece(node: AdfNode): string {
function innermostCode(blocks: readonly AdfNode[]): AdfNode | undefined {
const innermost = blocks.at(-1)
return innermost?.type === 'codeBlock' ? innermost : undefined
}
// The node's own part of what its carry element shows: its text, what it shows in its place, or a note naming what it references, linked to its link mark's target, else to the URL a card or an image links to; an image a link to another target marks is followed by a link to it.
function shownPiece(node: AdfNode, lineFeed: string): string {
if (node.type === 'hardBreak') return '<br>'
const text = node.text ?? shownText(node)
if (text === undefined) {
const name = referenceName(node)
return name === undefined ? '' : `<em>(${shownValue(name)})</em>`
const mark = nodeMarks(node).find((held) => held.type === 'link')
const target = mark === undefined ? undefined : linkAttrs(nodeAttrs(mark))
const url = shownUrl(node)
if (['media', 'mediaInline'].includes(node.type) && target !== undefined && url !== undefined && target.href !== url) {
const linkText = visibleText(nodeAttrs(node)['alt']) ?? visibleText(target.href) ?? url
return `${linked(shownValue(linkText, lineFeed), target)} (${linked('image', { href: url })})`
}
const link = shownLink(node)
const anchor = link === undefined ? undefined : anchorOpener(link)
return anchor === undefined ? shownValue(text) : `${anchor}${shownValue(text)}</a>`
const link = target ?? (url === undefined ? undefined : { href: url })
const text = node.text ?? shownText(node)
if (text !== undefined) return linked(shownValue(text, lineFeed), link)
const name = referenceName(node)
return name === undefined ? '' : linked(`<em>(${shownValue(name, lineFeed)})</em>`, link)
}
// What a reader of the carry element follows from the node: its link mark's target, or the URL a card or an image links to.
function shownLink(node: AdfNode): LinkAttrs | undefined {
const mark = nodeMarks(node).find((held) => held.type === 'link')
if (mark !== undefined) return linkAttrs(nodeAttrs(mark))
const href = shownUrl(node)
return href === undefined ? undefined : { href }
// The shown HTML inside an `<a>` where it links and HTML can hold the link.
function linked(shown: string, link: LinkAttrs | undefined): string {
const anchor = link === undefined ? undefined : anchorOpener(link)
return anchor === undefined ? shown : `${anchor}${shown}</a>`
}
+20
View File
@@ -0,0 +1,20 @@
import type { AdfNode } from '../../adf/document.ts'
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
import { holdsOnlyAttributes, nodeAttrs } from '../../adf/document.ts'
import { optionalAttribute } from './escaping.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
// The attributes a node holds besides those its element writes, as `data-*` attributes sorted by key, a value that is no string as JSON; `undefined` where the node holds what no attribute writes: a mark, text, an empty key, or an attribute the vocabulary does not hold as that kind (docs/decisions.md §The HTML dialect).
export function dataAttributes(node: AdfNode, vocabulary: AttributeVocabulary, written: readonly string[]): string | undefined {
if (!holdsOnlyAttributes(node, Object.keys(vocabulary))) return undefined
const pairs = vocabularyPairs(nodeAttrs(node), vocabulary, written)
if (pairs === undefined) return undefined
let attributes = ''
for (const { key, kind, value } of pairs.sort((left, right) => (left.key < right.key ? -1 : 1))) {
const attribute = optionalAttribute(`data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`, kind === 'string' ? value : serializeCanonicalJson(value, 'compact'))
if (attribute === undefined) return undefined
attributes += attribute
}
return attributes
}
+10 -3
View File
@@ -1,3 +1,4 @@
import type { JsonValue } from '../../json-value.ts'
import type { LinkAttrs } from '../../adf/mark-attributes.ts'
// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): a control other than ASCII whitespace or a carriage return, which a parser reads as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode.
@@ -15,6 +16,12 @@ export function jsonValue(json: string): string {
return escapeHtml(json.replace(unwritables, (character) => jsonEscape(character)))
}
// A code block's ` class="language-…"`, `''` where it holds no language, `undefined` where a class cannot hold the language as one token.
export function languageClass(language: JsonValue | undefined): string | undefined {
if (language === undefined) return ''
return typeof language === 'string' && /^[^\t\n\f\r ]+$/.test(language) ? optionalAttribute('class', `language-${language}`) : undefined
}
// ` name="value"`, `''` where the value is undefined, `undefined` where HTML cannot hold it.
export function optionalAttribute(name: string, value: string | undefined): string | undefined {
if (value === undefined) return ''
@@ -22,9 +29,9 @@ export function optionalAttribute(name: string, value: string | undefined): stri
return escaped === undefined ? undefined : ` ${name}="${escaped}"`
}
// The text a carry element shows, dropping what HTML cannot hold, each line feed a line break.
export function shownValue(value: string): string {
return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', '<br>')
// The text a carry element shows, dropping what HTML cannot hold, each line feed spelled `lineFeed`.
export function shownValue(value: string, lineFeed: string): string {
return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', lineFeed)
}
// `undefined` where HTML cannot hold the value, so its node rides the carry element.
+14 -9
View File
@@ -12,7 +12,7 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 142 | 0.3.0 | | security | **Settle in `docs/decisions.md`, weighing the pros and cons with the maintainer, whether `adfToHtml` writes a `javascript:`, `vbscript:`, `file:` or non-image `data:` URL as the ADF holds it.** | 2 | 2 | 2 | 4 | 3 | 6 by 3 | 4.5 |
| 142 | 0.3.0 | | security | **Settle in `docs/decisions.md`, weighing the pros and cons with the maintainer, whether `adfToHtml` writes a `javascript:`, `vbscript:`, `file:` or non-image `data:` URL as the ADF holds it, in a link's `href`, an image's `src` and each link the carry element shows.** | 2 | 2 | 2 | 4 | 3 | 6 by 3 | 4.5 |
| 131 | 0.3.0 | | | **Ship `htmlToAdf`, reading back exactly what `adfToHtml` writes and refusing any other HTML as `unmappable-html`.** | 4 | 1 | 2 | 5 | 7 | 3 by 3 | 0.6 |
| 133 | 0.3.0 | 131 | | **Write mentions, emoji, dates, statuses and inline cards 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 |
| 132 | 0.3.0 | 131 | | **Write tables as `<table>` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 |
@@ -63,15 +63,18 @@
## Details
### 142. Settle in `docs/decisions.md`, weighing the pros and cons with the maintainer, whether `adfToHtml` writes a `javascript:`, `vbscript:`, `file:` or non-image `data:` URL as the ADF holds it.
### 142. Settle in `docs/decisions.md`, weighing the pros and cons with the maintainer, whether `adfToHtml` writes a `javascript:`, `vbscript:`, `file:` or non-image `data:` URL as the ADF holds it, in a link's `href`, an image's `src` and each link the carry element shows.
The exposure: ADF from an author a consumer does not trust can hold a link's `href` or an external
image's `url` of `javascript:…`. `adfToHtml` writes it into `href` or `src` as held
(`docs/decisions.md` §HTML writes a URL as the ADF holds it, whatever its scheme). An export tool
showing that HTML unsanitized in a browser runs the script when a reader follows the link.
The exposure: ADF from an author a consumer does not trust can hold a `javascript:…` URL in a
link's `href`, an external image's `url`, or a card's `url` or `data.url`. `adfToHtml` writes it as
held into a link's `href` or an image's `src`, and into the `href` of each link the carry element
shows: a carried node's link mark, a card and an external image (`docs/decisions.md` §HTML writes a
URL as the ADF holds it, whatever its scheme). An export tool showing that HTML unsanitized in a
browser runs the script when a reader follows the link.
Weigh writing the URL as held, leaving sanitizing to the consumer as for any HTML, against carrying
such a link or image in the carry element, which keeps the URL in `data-adf`. cmark, the CommonMark
Weigh writing the URL as held, leaving sanitizing to the consumer as for any HTML, against keeping
it in `data-adf` alone. Carrying the node no longer does that, since the carry element links what
it shows: the carry would also have to show such a link without its `href`. cmark, the CommonMark
reference implementation, leaves `javascript:`, `vbscript:`, `file:` and `data:` other than a PNG,
GIF, JPEG or WebP image out of its HTML by default; a browser reads a scheme after dropping tabs,
line breaks and leading control characters. Say whether the markdown writers' links follow the
@@ -237,7 +240,9 @@ A writer panel picks `<s>` or `<del>`.
### 135. Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.
A writer panel picks the spellings; an external image already has one from item 130.
A writer panel picks the spellings; an external image already has one from item 130. Atlassian's
editor puts `localId`, `width` and `widthType` on every `mediaSingle`, so every editor-written
image rides the carry until this item.
### 136. Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.