From 73363a6756e43ce943d808a27a619ce326bbcd6f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 24 Oct 2026 00:53:04 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20principles=20round=202:=201=20?= =?UTF-8?q?=F0=9F=94=B4,=202=20=F0=9F=9F=A1=20(0=20misread);=20mutants=20k?= =?UTF-8?q?illed,=20dead=20branches=20gone,=20data-*=20in=20its=20own=20mo?= =?UTF-8?q?dule,=20linked=20images=20keep=20both=20targets,=20every=20URL?= =?UTF-8?q?=20sink=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 +-- README.md | 16 +++-- docs/decisions.md | 20 ++++-- spec/html.md | 19 +++-- src/html/emit/adf-to-html.test.ts | 111 ++++++++++++++++++++++++++---- src/html/emit/adf-to-html.ts | 58 ++++++---------- src/html/emit/carry-element.ts | 85 ++++++++++++++--------- src/html/emit/data-attributes.ts | 20 ++++++ src/html/emit/escaping.ts | 13 +++- todo.md | 23 ++++--- 10 files changed, 256 insertions(+), 119 deletions(-) create mode 100644 src/html/emit/data-attributes.ts 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 `

`, parted by `
` in a - ``. 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, `(image)`. -- 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 `
`, the carry element shows each block's content as 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\u0085﷐b', { 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' }), '

    The moon

    ') assert.equal(image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '

    https://example.com/moon.jpg

    ') + assert.equal(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { title: 'No target' }, type: 'link' }], type: 'media' }), '

    The moon

    ') }) -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' }), '

    The moon (image)

    ') + assert.equal(image({ type: 'external', url: 'https://example.com/moon.jpg' }), '

    https://example.com/ (image)

    ') + assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/' }), '

    The moon

    ') + 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")'), '

    x

    \n') assert.equal(linked('data:text/html,x'), '

    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}`) : 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}` } -// 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.