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