diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4fb00..b8575cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4bc5361..7641501 100644 --- a/README.md +++ b/README.md @@ -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 `
` in a `
`, and a code block's as + `
` keeping its indentation; in a ``, it parts blocks with `
`. 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, `(image)`, 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
diff --git a/docs/decisions.md b/docs/decisions.md
index 5b8829c..260de52 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -418,6 +418,15 @@ paragraph holds an attribute, the shape of every item Atlassian's editor writes.
the `` on a line of its own, 1 of 7, and over
`
Bolt `, 0 of 7.
+A fourth panel of the same three roles picked, 3 of 3, a code block inside a carry element as
+`…
` over `…
` and over a `` whose lines part at `
`, which
+loses the indentation. All three wrote ``, 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
diff --git a/spec/html.md b/spec/html.md
index 1830639..fd733a7 100644
--- a/spec/html.md
+++ b/spec/html.md
@@ -47,9 +47,9 @@ marks are two `` elements.
## Escaping
Text and attribute values escape `&`, `<`, `>` and `"` as `&`, `<`, `>` and `"`.
-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 ``, each block's content is a ``; in a ``, `
` parts two blocks'.
- - A hard break and a line feed in text are `
`.
+ - In a ``, each block's content is a ``, and a code block's is
+ `
…
`, 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 ``, `
` parts two blocks'.
+ - A hard break is `
`, and so is a line feed in text outside a ``'s code block.
- Text a link marks is `` 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: `The moon (image)`.
- A node referencing what it cannot show is a note naming it, `(image)`: `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: `(image)`.
- 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
diff --git a/src/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts
index 4ecc177..7601790 100644
--- a/src/html/emit/adf-to-html.test.ts
+++ b/src/html/emit/adf-to-html.test.ts
@@ -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\u0085b', { type: 'underline' }))))).includes('"text":"a\\u0085\\ufdd0b"'), true)
+ assert.equal(html(adfToHtml(document(paragraph(text('a\u{1fffe}b', { type: 'underline' }))))).includes('"text":"a\\ud83f\\udffeb"'), 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)))), 'Ask @Mikael
\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)))), 'Check the collation.
Then import.
')
+})
+
+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' })))), 'a
b
')
+})
+
+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)))), 'Read the CommonMark spec first.
Then
import.
')
- assert.equal(shown(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' })))), 'Details
Inside
')
- assert.equal(shown(html(adfToHtml(document({ content: [text('a\nb'), text('c')], type: 'codeBlock' })))), 'a
bc
')
+ const panel: AdfNode = { content: [paragraph(text('Read the '), text('CommonMark spec', link), text(' first.'))], type: 'panel' }
+ assert.equal(shown(html(adfToHtml(document(panel)))), 'Read the CommonMark spec first.
')
assert.equal(shown(html(adfToHtml(document(paragraph(text('x', { type: 'underline' }, link)))))), 'x')
+})
+
+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' })))), 'Then
import.
')
+ assert.equal(shown(html(adfToHtml(document({ content: [paragraph(text('Then\nimport.'))], type: 'panel' })))), 'Then
import.
')
+ assert.equal(shown(html(adfToHtml(document(paragraph(text('a\nb', { type: 'underline' })))))), 'a
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' })))), 'Details
Inside
')
+})
+
+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
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' })))), 'Run this:
if ready:\n deploy()
')
+ assert.equal(shown(html(adfToHtml(document({ content: [text('a\r\n b'), text('c')], type: 'codeBlock' })))), 'a\n bc
')
+})
+
+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'), 'x
')
+ assert.equal(carried('c sharp'), 'x
')
+ assert.equal(carried(5), 'x
')
+})
+
+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:
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' }), '')
assert.equal(image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '')
+ assert.equal(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { title: 'No target' }, type: 'link' }], type: 'media' }), '')
})
-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' }), '')
+ assert.equal(image({ type: 'external', url: 'https://example.com/moon.jpg' }), '')
+ assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/' }), '')
+ 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, 'https://example.com/moon.jpg (image)
')
+ 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))))), 'The moon (image)')
+})
+
+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))))), 'https://example.com/roadmap')
+})
+
+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' }), '(image)')
+ const stored = { collection: ids.collection, id: ids.file, type: 'file' }
+ assert.equal(inline({ attrs: stored, type: 'mediaInline' }), '(image)')
+ assert.equal(inline({ attrs: stored, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'mediaInline' }), '(image)')
assert.equal(inline({ attrs: {}, type: 'inlineCard' }), '(link card)')
assert.equal(inline({ attrs: { extensionKey: 'jira-issues' }, type: 'inlineExtension' }), '(jira-issues)')
assert.equal(inline({ attrs: { extensionType: 'com.example' }, type: 'inlineExtension' }), '(extension)')
- assert.equal(shown(html(adfToHtml(document({ attrs: { resourceId: 'r1' }, type: 'syncBlock' })))), '(synced block)
')
+ assert.equal(shown(html(adfToHtml(document({ attrs: { resourceId: ids.resource }, type: 'syncBlock' })))), '(synced block)
')
})
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")'), '\n')
assert.equal(linked('data:text/html,x'), '\n')
const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { type: 'external', url: 'vbscript:x' }, type: 'media' }], type: 'mediaSingle' }
assert.equal(html(adfToHtml(document(image))), '
\n')
+ assert.equal(shown(html(adfToHtml(document(paragraph({ attrs: { url: 'javascript:alert(1)' }, type: 'inlineCard' }))))), 'javascript:alert(1)')
})
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 ``, its text, `
` 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)
+})
diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts
index 4cd6c30..e949c04 100644
--- a/src/html/emit/adf-to-html.ts
+++ b/src/html/emit/adf-to-html.ts
@@ -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 {
return withinEngineLimits(() => writeHtml(document))
}
@@ -59,23 +61,8 @@ function writeBlock(block: Block): Result {
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 {
// `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 | 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 | 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 `
`
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 : `
`
}
diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts
index 71bed2a..31b71ed 100644
--- a/src/html/emit/carry-element.ts
+++ b/src/html/emit/carry-element.ts
@@ -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 `` shows each block as a paragraph, a `` 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 {
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 ? `${html}
` : `${html}
`))
+ return success(carryElement('div', json.value.json, shown.join('')))
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result {
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, '
').map(({ html }) => html).join('
'))) : 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 ? '
' : codeLineFeed)
for (const child of [...nodeContent(next)].reverse()) pending.push(child)
}
- if (group !== '') groups.push(group)
- return position === 'block' ? groups.map((shown) => `${shown}
`).join('') : groups.join('
')
+ 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 '
'
- const text = node.text ?? shownText(node)
- if (text === undefined) {
- const name = referenceName(node)
- return name === undefined ? '' : `(${shownValue(name)})`
+ 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)}`
+ 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(`(${shownValue(name, lineFeed)})`, 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 `` 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}`
}
diff --git a/src/html/emit/data-attributes.ts b/src/html/emit/data-attributes.ts
new file mode 100644
index 0000000..24645d7
--- /dev/null
+++ b/src/html/emit/data-attributes.ts
@@ -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
+}
diff --git a/src/html/emit/escaping.ts b/src/html/emit/escaping.ts
index f462266..101ac51 100644
--- a/src/html/emit/escaping.ts
+++ b/src/html/emit/escaping.ts
@@ -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', '
')
+// 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.
diff --git a/todo.md b/todo.md
index edb4a21..b88ad8f 100644
--- a/todo.md
+++ b/todo.md
@@ -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 `` 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 `` or ``.
### 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.