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

Answer principles round 1: 3 🔴, 8 🟡 (0 misread); attributes as data-*, carried content as paragraphs, links and notes, no character HTML cannot hold, mutants killed

This commit is contained in:
2026-10-23 19:19:14 +02:00
committed by Lillem4n
parent 1f46111fe4
commit 7a38db2563
27 changed files with 612 additions and 295 deletions
+158 -90
View File
@@ -6,6 +6,13 @@ import type { Result } from '../../result.ts'
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',
}
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
@@ -14,6 +21,10 @@ function html(result: Result<string>): string {
return result.ok ? result.value : `${result.error.code}: ${result.error.message}`
}
function refusal(result: Result<string>): string {
return result.ok ? 'written' : `${result.error.code} at /${result.error.path.join('/')}`
}
function paragraph(...content: AdfNode[]): AdfNode {
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
@@ -26,6 +37,25 @@ function item(...content: AdfNode[]): AdfNode {
return { content, type: 'listItem' }
}
function nested(type: string, levels: number, inner: AdfNode): AdfNode {
let node = inner
for (let level = 0; level < levels; level += 1) node = { content: [node], type }
return node
}
function carriedBlock(node: AdfNode): boolean {
return html(adfToHtml(document(node))).startsWith('<div class="adf-carry"')
}
function carriedSpans(...content: AdfNode[]): number {
return html(adfToHtml(document(paragraph(...content)))).split('<span class="adf-carry"').length - 1
}
// What the one carry element written shows: its content after the opening tag, whose `data-adf` holds no `"`.
function shown(written: string): string {
return written.slice(written.indexOf('">') + 2).replace(/<\/(?:div|span)>(?:<\/p>)?\n$/, '')
}
test('writes paragraphs, headings and rules as their elements, one block a line', () => {
const heading = (level: number): AdfNode => ({ attrs: { level }, content: [text(`Level ${level}`)], type: 'heading' })
assert.equal(html(adfToHtml(document(heading(1), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6)))), '<h1>Level 1</h1>\n<p>Read the manual.</p>\n<hr>\n<h6>Level 6</h6>\n')
@@ -42,18 +72,44 @@ test('writes the marks CommonMark spells, the first mark outermost', () => {
assert.equal(html(adfToHtml(document(paragraph(text('Line one'), { type: 'hardBreak' }, text('Line two'))))), '<p>Line one<br>Line two</p>\n')
})
test('escapes text and attribute values, and a carriage return so an HTML parser keeps it', () => {
assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\r\nf'))))), '<p>a &lt; b &amp;&amp; c &gt; &quot;d&quot; \'e\'&#13;\nf</p>\n')
test('escapes text and attribute values, and keeps tabs, line feeds and form feeds', () => {
assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\tf\ng\fh'))))), '<p>a &lt; b &amp;&amp; c &gt; &quot;d&quot; \'e\'\tf\ng\fh</p>\n')
})
test('carries text holding a character HTML text cannot hold, escaping it in the JSON', () => {
for (const value of ['a\r\nb', 'a\0b', 'a\u0001b', 'a\u007fb', 'a\u0085b', 'a﷐b', 'a￿b', 'a🿾b', 'a\ud800b', 'a\udc00b']) {
assert.equal(carriedSpans(text(value)), 1, JSON.stringify(value))
}
assert.equal(carriedSpans(text('😄 a b')), 0)
assert.equal(html(adfToHtml(document(paragraph(text('a\u0085﷐b', { type: 'underline' }))))).includes('&quot;text&quot;:&quot;a\\u0085\\ufdd0b&quot;'), true)
})
test('writes lists tight, an item holding more than one paragraph or a list in elements after the first', () => {
assert.equal(html(adfToHtml(document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))), '<ul>\n<li>Bolt</li>\n<li>Nut</li>\n</ul>\n')
const nested: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' }
assert.equal(html(adfToHtml(document(nested))), '<ol start="3">\n<li>Bolt\n<ul>\n<li>M8</li>\n</ul>\n</li>\n<li>a\n<p>b</p>\n</li>\n</ol>\n')
const listed: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' }
assert.equal(html(adfToHtml(document(listed))), '<ol start="3">\n<li>Bolt\n<ul>\n<li>M8</li>\n</ul>\n</li>\n<li>a\n<p>b</p>\n</li>\n</ol>\n')
assert.equal(html(adfToHtml(document({ content: [item({ type: 'rule' }), { type: 'listItem' }], type: 'orderedList' }))), '<ol>\n<li>\n<hr>\n</li>\n<li></li>\n</ol>\n')
assert.equal(html(adfToHtml(document({ content: [item(paragraph())], type: 'bulletList' }))), '<ul>\n<li>\n<p></p>\n</li>\n</ul>\n')
})
test('writes an item\'s first paragraph as its element where it holds an attribute, and as its carry where it holds a mark', () => {
const first = (node: AdfNode): string => html(adfToHtml(document({ content: [item(node)], type: 'bulletList' })))
assert.equal(first({ attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' }), `<ul>\n<li>\n<p data-local-id="${ids.paragraph}">Bolt</p>\n</li>\n</ul>\n`)
assert.equal(first({ content: [text('Bolt')], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }).startsWith('<ul>\n<li>\n<div class="adf-carry"'), true)
})
test('writes the attributes an element cannot express as data-* attributes on it, sorted, a value that is no string as JSON', () => {
const localId = { localId: ids.paragraph }
assert.equal(html(adfToHtml(document({ attrs: localId, content: [text('x')], type: 'paragraph' }))), `<p data-local-id="${ids.paragraph}">x</p>\n`)
assert.equal(html(adfToHtml(document({ attrs: { level: 2, ...localId }, content: [text('x')], type: 'heading' }))), `<h2 data-local-id="${ids.paragraph}">x</h2>\n`)
assert.equal(html(adfToHtml(document({ attrs: { localId: ids.quote }, content: [paragraph(text('x'))], type: 'blockquote' }))), `<blockquote data-local-id="${ids.quote}">\n<p>x</p>\n</blockquote>\n`)
const listed: AdfNode = { attrs: { localId: ids.list, order: 3 }, content: [{ attrs: { localId: ids.item }, content: [{ attrs: localId, content: [text('Bolt')], type: 'paragraph' }], type: 'listItem' }], type: 'orderedList' }
assert.equal(html(adfToHtml(document(listed))), `<ol start="3" data-local-id="${ids.list}">\n<li data-local-id="${ids.item}">\n<p data-local-id="${ids.paragraph}">Bolt</p>\n</li>\n</ol>\n`)
const code: AdfNode = { attrs: { wrap: true, language: 'js', hideLineNumbers: false, ...localId }, content: [text('x')], type: 'codeBlock' }
assert.equal(html(adfToHtml(document(code))), `<pre data-hide-line-numbers="false" data-local-id="${ids.paragraph}" data-wrap="true"><code class="language-js">x</code></pre>\n`)
assert.equal(html(adfToHtml(document({ attrs: { weight: -0, color: '#ccc', style: 'a"b' }, type: 'rule' }))), '<hr data-color="#ccc" data-style="a&quot;b" data-weight="-0">\n')
})
test('writes quotes and code blocks, the language a class', () => {
assert.equal(html(adfToHtml(document({ content: [paragraph(text('Quoted'))], type: 'blockquote' }))), '<blockquote>\n<p>Quoted</p>\n</blockquote>\n')
assert.equal(html(adfToHtml(document({ attrs: { language: 'javascript' }, content: [text('if (a < b)\n run()')], type: 'codeBlock' }))), '<pre><code class="language-javascript">if (a &lt; b)\n run()</code></pre>\n')
@@ -67,61 +123,85 @@ test('writes an external image standing alone in a paragraph, a link around it w
assert.equal(html(adfToHtml(document(linked))), '<p><a href="https://example.com/"><img src="https://example.com/moon.jpg"></a></p>\n')
})
test('carries every other node in an adf-carry element holding its JSON and showing its text', () => {
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.')), paragraph(text('Then import.'))], type: 'panel' }
test('carries every other node in an adf-carry element holding its JSON', () => {
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' }
assert.equal(
html(adfToHtml(document(panel))),
'<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},&quot;content&quot;:[{&quot;content&quot;:[{&quot;text&quot;:&quot;Check the collation.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;},{&quot;content&quot;:[{&quot;text&quot;:&quot;Then import.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;}],&quot;type&quot;:&quot;panel&quot;}">Check the collation.\nThen import.</div>\n',
'<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},&quot;content&quot;:[{&quot;content&quot;:[{&quot;text&quot;:&quot;Check the collation.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;}],&quot;type&quot;:&quot;panel&quot;}"><p>Check the collation.</p></div>\n',
)
const mention: AdfNode = { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }
assert.equal(html(adfToHtml(document(paragraph(text('Ask '), mention)))), '<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>\n')
})
test('shows the content a carried node holds in an attribute', () => {
const shown = (node: AdfNode): string => {
const written = html(adfToHtml(document(paragraph(node))))
return written.slice(written.indexOf('">') + 2, written.indexOf('</span>'))
}
assert.equal(shown({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14')
assert.equal(shown({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄')
assert.equal(shown({ attrs: { id: '5b10a2' }, type: 'mention' }), '@5b10a2')
assert.equal(shown({ attrs: { color: 'green', text: 'Done' }, type: 'status' }), 'Done')
assert.equal(shown({ attrs: { url: 'https://example.com/<card>' }, type: 'inlineCard' }), 'https://example.com/&lt;card&gt;')
assert.equal(shown(text('underlined', { type: 'underline' })), 'underlined')
assert.equal(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' }))).endsWith('">Details\nInside</div>\n'), true)
test('shows a carried node\'s blocks as paragraphs, its links as anchors and its hard breaks as line breaks', () => {
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>')
assert.equal(shown(html(adfToHtml(document(paragraph(text('x', { type: 'underline' }, link)))))), '<a href="https://spec.commonmark.org/" title="The spec">x</a>')
assert.equal(shown(html(adfToHtml(document(paragraph({ content: [paragraph(text('a')), paragraph(text('b'))], type: 'panel' }))))), 'a<br>b')
assert.equal(shown(html(adfToHtml(document(paragraph(text('a\u0001b\r', { type: 'underline' }, { attrs: { href: 'https://example.com/\u0001' }, type: 'link' })))))), 'ab')
})
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')
assert.equal(inline({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄')
assert.equal(inline({ attrs: { id: '5b10a2' }, type: 'mention' }), '@5b10a2')
assert.equal(inline({ attrs: { color: 'green', text: 'Done' }, type: 'status' }), 'Done')
assert.equal(inline({ attrs: { url: 'https://example.com/<card>' }, type: 'inlineCard' }), '<a href="https://example.com/&lt;card&gt;">https://example.com/&lt;card&gt;</a>')
assert.equal(inline({ attrs: { data: { name: 'Roadmap' } }, type: 'inlineCard' }), 'Roadmap')
assert.equal(inline(text('underlined', { type: 'underline' })), 'underlined')
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>')
})
test('shows a note naming what a carried node references where it shows no text', () => {
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>')
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>')
})
test('carries a node holding what its element cannot', () => {
const carried = (node: AdfNode): boolean => html(adfToHtml(document(node))).startsWith('<div class="adf-carry"')
assert.equal(carried({ attrs: { localId: 'p1' }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carried({ content: [], type: 'paragraph' }), true)
assert.equal(carried({ attrs: {}, type: 'rule' }), true)
assert.equal(carried({ attrs: { level: 7 }, content: [text('x')], type: 'heading' }), true)
assert.equal(carried({ attrs: { localId: 'q1' }, content: [paragraph(text('x'))], type: 'blockquote' }), true)
assert.equal(carried({ attrs: { order: 1.5 }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true)
assert.equal(carried({ content: [item(paragraph(text('x'))), paragraph(text('y'))], type: 'bulletList' }), true)
assert.equal(carried({ content: [], type: 'bulletList' }), true)
assert.equal(carried({ attrs: { language: 'c sharp' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carried({ attrs: { language: 'a\0b' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('a'), text('b')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('x\0')], type: 'codeBlock' }), true)
assert.equal(carried(item(paragraph(text('x')))), true)
assert.equal(carried(text('x')), true)
assert.equal(carried({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
assert.equal(carried({ attrs: { layout: 'center' }, content: [{ attrs: { alt: '', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
assert.equal(carriedBlock({ attrs: { localId: 7 }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carriedBlock({ content: [], type: 'paragraph' }), true)
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)
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({ 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: '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)
assert.equal(carriedBlock({ content: [text('x\0')], type: 'codeBlock' }), true)
assert.equal(carriedBlock(item(paragraph(text('x')))), true)
assert.equal(carriedBlock(text('x')), true)
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
assert.equal(carriedBlock({ attrs: { layout: 'center' }, content: [{ attrs: { alt: '', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
})
test('carries inline what its element cannot hold, and a text node an unmarked one before it would join', () => {
const carriedSpans = (...content: AdfNode[]): number => html(adfToHtml(document(paragraph(...content)))).split('<span class="adf-carry"').length - 1
assert.equal(carriedSpans(text('a'), text('b')), 1)
assert.equal(carriedSpans(text('a'), text('b', { type: 'em' }), text('c')), 0)
assert.equal(carriedSpans(text('')), 1)
assert.equal(carriedSpans(text('x\0y')), 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/', id: 'a1' }, type: 'link' })), 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)
})
@@ -142,40 +222,12 @@ test('carries a link or image holding a value its element cannot', () => {
assert.equal(carried({ attrs: { title: 'No target' }, type: 'link' }), true)
assert.equal(carried({ attrs: { href: 'https://example.com/\0' }, type: 'link' }), true)
assert.equal(carried({ attrs: { href: 'https://example.com/', title: 'x\0' }, type: 'link' }), true)
const imageCarried = (media: AdfNode): boolean => html(adfToHtml(document({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }))).startsWith('<div class="adf-carry"')
const linked = (attrs: AdfAttributes): AdfNode => ({ attrs: { type: 'external', url: 'https://example.com/a.png' }, marks: [{ attrs, type: 'link' }], type: 'media' })
assert.equal(imageCarried(linked({ href: 'https://example.com/\0' })), true)
assert.equal(imageCarried(linked({ href: 'https://example.com/', title: 'x\0' })), true)
assert.equal(imageCarried({ attrs: { type: 'external', url: 'https://example.com/\0.png' }, type: 'media' }), true)
assert.equal(imageCarried({ attrs: { alt: 'The\0moon', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }), true)
})
test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading and a list item, the first in document order', () => {
let deep: AdfNode = text('x')
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'unknownInline' }
const refused = (block: AdfNode): string => {
const result = adfToHtml(document(block))
return result.ok ? 'written' : `${result.error.code} at ${result.error.path.join('/')}`
}
assert.equal(refused(paragraph(deep)), 'unsupported-nesting-depth at content/0/content/0')
assert.equal(refused({ attrs: { level: 1 }, content: [deep], type: 'heading' }), 'unsupported-nesting-depth at content/0/content/0')
assert.equal(refused({ content: [item(paragraph(deep))], type: 'bulletList' }), 'unsupported-nesting-depth at content/0/content/0/content/0/content/0')
assert.equal(refused({ content: [item(paragraph(text('a')), paragraph(deep)), item(paragraph(deep))], type: 'bulletList' }), 'unsupported-nesting-depth at content/0/content/0/content/1/content/0')
})
const tooLong = 'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes'
test('refuses text past V8\'s longest string', () => {
const long = 'a'.repeat(2 ** 28)
assert.equal(html(adfToHtml(document(paragraph(text(long), text(long, { type: 'em' }), text(long))))), tooLong)
})
test('refuses at the block whose HTML passes V8\'s longest string, before a later block refuses', () => {
// The two code blocks' text is the bound exactly, so only what their elements add passes it.
const code: AdfNode = { content: [text('a'.repeat(536870888 / 2))], type: 'codeBlock' }
let deep: AdfNode = { type: 'rule' }
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'blockquote' }
assert.equal(html(adfToHtml(document(code, code, deep))), tooLong)
const image = (media: AdfNode): AdfNode => ({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' })
assert.equal(carriedBlock(image(linked({ href: 'https://example.com/\0' }))), true)
assert.equal(carriedBlock(image(linked({ href: 'https://example.com/', title: 'x\0' }))), true)
assert.equal(carriedBlock(image({ attrs: { type: 'external', url: 'https://example.com/\0.png' }, type: 'media' })), true)
assert.equal(carriedBlock(image({ attrs: { alt: 'The\0moon', type: 'external', url: 'https://example.com/a.png' }, type: 'media' })), true)
})
test('writes an empty document as nothing, and one holding no content key as its carry', () => {
@@ -183,22 +235,38 @@ test('writes an empty document as nothing, and one holding no content key as its
assert.equal(html(adfToHtml({ type: 'doc', version: 1 })), '<div class="adf-carry" data-adf="{&quot;type&quot;:&quot;doc&quot;,&quot;version&quot;:1}"></div>\n')
})
test('refuses what is no document, a version HTML cannot carry, and nesting past the levels a reader reads', () => {
assert.equal(html(adfToHtml({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN')
assert.equal(html(adfToHtml({ type: 'doc', version: 2 })), 'unsupported-document-version: no format this library writes carries ADF version 2')
let deep: AdfNode = paragraph(text('x'))
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'blockquote' }
const refused = adfToHtml(document(deep))
assert.equal(refused.ok ? 'written' : refused.error.code, 'unsupported-nesting-depth')
let fits: AdfNode = paragraph(text('x'))
for (let level = 1; level < largestNesting; level += 1) fits = { content: [fits], type: 'blockquote' }
assert.equal(adfToHtml(document(fits)).ok, true)
let items: AdfNode = paragraph(text('x'))
for (let level = 0; level <= largestNesting; level += 1) items = { content: [item(items)], type: 'bulletList' }
const listed = adfToHtml(document(items))
assert.equal(listed.ok ? 'written' : listed.error.code, 'unsupported-nesting-depth')
let carried: AdfNode = paragraph(text('x'))
for (let level = 1; level < largestNesting; level += 1) carried = { content: [carried], type: 'panel' }
const json = adfToHtml(document(carried))
assert.equal(json.ok ? 'written' : json.error.code, 'unsupported-nesting-depth')
test('refuses what is no document, and a version HTML cannot carry', () => {
assert.equal(refusal(adfToHtml({ type: 'doc', version: Number.NaN })), 'not-an-adf-document at /')
assert.equal(refusal(adfToHtml({ type: 'doc', version: 2 })), 'unsupported-document-version at /')
})
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)
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')
assert.equal(refusal(adfToHtml(document({ content: [item(items)], type: 'bulletList' }))).startsWith('unsupported-nesting-depth at /'), true)
})
test('refuses a carried block whose JSON nests past the levels its depth leaves, and an inline carry past 500', () => {
let value: AdfAttributes[string] = 1
for (let level = 0; level < largestNesting - 2; level += 1) value = [value]
const carried: AdfNode = { attrs: { a: value }, type: 'unknownBlock' }
assert.equal(refusal(adfToHtml(document(carried))), 'written')
assert.equal(refusal(adfToHtml(document({ content: [carried], type: 'blockquote' }))), 'unsupported-nesting-depth at /content/0/content/0')
const deep = nested('unknownInline', largestNesting, text('x'))
assert.equal(refusal(adfToHtml(document(paragraph(deep)))), 'unsupported-nesting-depth at /content/0/content/0')
assert.equal(refusal(adfToHtml(document({ attrs: { level: 1 }, content: [deep], type: 'heading' }))), 'unsupported-nesting-depth at /content/0/content/0')
assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/0/content/0')
assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(text('a')), paragraph(deep)), item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/1/content/0')
})
test('refuses HTML past V8\'s longest string, at the block passing it', () => {
const long = 'a'.repeat(2 ** 28)
const tooLong = (result: Result<string>): boolean => !result.ok && result.error.code === 'unsupported-node-shape' && result.error.message.includes('536870888')
assert.equal(tooLong(adfToHtml(document(paragraph(text(long), text(long, { type: 'em' }), text(long))))), true)
// The two code blocks' text is the bound exactly, so only what their elements add passes it, before the too-deep block after them.
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)
})
+88 -45
View File
@@ -1,18 +1,20 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { anchorOpener, inlineHtml, writableText } from './inline.ts'
import { anchorOpener, optionalAttribute, writableValue } from './escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { carriedBlock, carryElement } from './carry-element.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 { largestNesting } from '../../nesting.ts'
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { htmlValue, optionalAttribute } from './escaping.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; inList: the node is a list's item.
type Block = { depth: number; inList: boolean; node: AdfNode; path: ConvertErrorPath }
type Container = { attributes: string; isList: boolean; tag: string }
type Pending = Block | string
// 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
export function adfToHtml(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeHtml(document))
@@ -23,7 +25,7 @@ function writeHtml(document: AdfDocument): Result<string> {
const content = writableContent(document)
if (!content.ok) return content
if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`)
const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0, false), '\n'].reverse()
const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0), '\n'].reverse()
let html = ''
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === 'string') {
@@ -32,95 +34,131 @@ function writeHtml(document: AdfDocument): Result<string> {
html += next
continue
}
const pieces = writeBlock(next)
if (next.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, next.path)
const pieces = next.kind === 'item' ? writeItem(next) : writeBlock(next)
if (!pieces.ok) return pieces
for (const piece of pieces.value.reverse()) pending.push(piece)
}
return success(html)
}
function blockPieces(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, inList: boolean): Pending[] {
function blockPieces(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number): Pending[] {
const pieces: Pending[] = []
for (const [index, node] of nodes.entries()) {
if (index > 0) pieces.push('\n')
pieces.push({ depth, inList, node, path: [...path, 'content', index] })
pieces.push({ depth, kind: 'block', node, path: [...path, 'content', index] })
}
return pieces
}
function writeBlock(block: Block): Result<Pending[]> {
const { depth, node, path } = block
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, path)
if (block.inList) return writeItem(block)
const container = containerElement(node)
if (container !== undefined) return success(containerPieces(block, container))
const container = node.type === 'blockquote' ? quotePieces(block) : listPieces(block)
if (container !== undefined) return success(container)
const html = tryLeafBlock(node, path) ?? carriedBlock(node, path, depth)
return html.ok ? success([html.value]) : html
}
// `undefined` where the node is no container, or it or a list's item holds what the elements cannot, so the whole node rides the carry.
function containerElement(node: AdfNode): Container | undefined {
if (node.type === 'blockquote') return holdsOnlyAttributes(node, []) ? { attributes: '', isList: false, tag: 'blockquote' } : undefined
if (node.type === 'bulletList') return holdsOnlyAttributes(node, []) && listItemsWritable(node) ? { attributes: '', isList: true, tag: 'ul' } : undefined
if (node.type !== 'orderedList' || !holdsOnlyAttributes(node, ['order']) || !listItemsWritable(node)) return undefined
// 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, [])
return attributes === undefined ? undefined : containerPieces('blockquote', attributes, node.content === undefined ? undefined : blockPieces(node.content, path, depth + 1))
}
// `undefined` where the list, or one of its items, holds what the elements cannot, so the whole list rides the carry.
function listPieces({ depth, node, path }: Block): Pending[] | undefined {
const element = listElement(node)
if (element === undefined) return 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
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] })
}
return containerPieces(element.tag, element.attributes, node.content === undefined ? undefined : items)
}
function listElement(node: AdfNode): { attributes: string; tag: string } | undefined {
if (node.type === 'bulletList') {
const attributes = dataAttributes(node, [])
return attributes === undefined ? undefined : { attributes, tag: 'ul' }
}
if (node.type !== 'orderedList') return undefined
const order = nodeAttrs(node)['order']
if (order === undefined) return { attributes: '', isList: true, tag: 'ol' }
return typeof order === 'number' && Number.isSafeInteger(order) && !Object.is(order, -0) ? { attributes: ` start="${order}"`, isList: true, tag: 'ol' } : undefined
if (order !== undefined && (typeof order !== 'number' || !Number.isSafeInteger(order) || Object.is(order, -0))) return undefined
const attributes = dataAttributes(node, ['order'])
return attributes === undefined ? undefined : { attributes: `${order === undefined ? '' : ` start="${order}"`}${attributes}`, tag: 'ol' }
}
function listItemsWritable(list: AdfNode): boolean {
return nodeContent(list).every((item) => item.type === 'listItem' && holdsOnlyAttributes(item, []))
// `children` undefined where the node holds no `content` key, so its element holds nothing.
function containerPieces(tag: string, attributes: string, children: Pending[] | undefined): Pending[] {
if (children === undefined) return [`<${tag}${attributes}></${tag}>`]
return [`<${tag}${attributes}>\n`, ...children, `\n</${tag}>`]
}
function containerPieces({ depth, node, path }: Block, { attributes, isList, tag }: Container): Pending[] {
if (node.content === undefined) return [`<${tag}${attributes}></${tag}>`]
return [`<${tag}${attributes}>\n`, ...blockPieces(node.content, path, depth + 1, isList), `\n</${tag}>`]
}
// An item's blocks sit at its own level, one below its list, as a markdown list's do; its first paragraph stands bare where it holds content, panel pick, docs/decisions.md §The HTML dialect.
function writeItem({ depth, node, path }: Block): Result<Pending[]> {
if (node.content === undefined) return success(['<li></li>'])
const blocks = blockPieces(node.content, path, depth, false)
// An item's blocks sit at its own level, one below its list; its first paragraph stands bare where it holds content and no attribute, panel pick, docs/decisions.md §The HTML dialect.
function writeItem({ attributes, depth, node, path }: Item): Result<Pending[]> {
if (node.content === undefined) return success([`<li${attributes}></li>`])
const blocks = blockPieces(node.content, path, depth)
const [first] = node.content
if (first === undefined || first.type !== 'paragraph' || !holdsOnlyAttributes(first, []) || nodeContent(first).length === 0) return success(['<li>\n', ...blocks, '\n</li>'])
if (first === undefined || first.type !== 'paragraph' || dataAttributes(first, []) !== '' || nodeContent(first).length === 0) return success([`<li${attributes}>\n`, ...blocks, '\n</li>'])
const bare = inlineHtml(nodeContent(first), [...path, 'content', 0])
if (!bare.ok) return bare
// The bare paragraph's inline content stands in for its block.
return success(blocks.length === 1 ? [`<li>${bare.value}</li>`] : [`<li>${bare.value}`, ...blocks.slice(1), '\n</li>'])
return success(blocks.length === 1 ? [`<li${attributes}>${bare.value}</li>`] : [`<li${attributes}>${bare.value}`, ...blocks.slice(1), '\n</li>'])
}
function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
if (node.type === 'paragraph' && holdsOnlyAttributes(node, [])) return inlineElement('p', nodeContent(node), path)
if (node.type === 'heading' && holdsOnlyAttributes(node, ['level'])) {
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}`, nodeContent(node), path)
if (typeof level === 'number' && Number.isInteger(level) && level >= 1 && level <= 6) return inlineElement(`h${level}`, dataAttributes(node, ['level']), nodeContent(node), path)
}
const html = tryCodeBlock(node) ?? tryImage(node) ?? (node.type === 'rule' && holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '<hr>' : undefined)
const html = tryCodeBlock(node) ?? tryImage(node) ?? tryRule(node)
return html === undefined ? undefined : success(html)
}
function inlineElement(tag: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
// `undefined` where the attributes are, so the node rides the carry.
function inlineElement(tag: string, attributes: string | undefined, nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> | undefined {
if (attributes === undefined) return undefined
const inline = inlineHtml(nodes, path)
return inline.ok ? success(`<${tag}>${inline.value}</${tag}>`) : inline
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' || !holdsOnlyAttributes(node, ['language'])) return undefined
if (node.type !== 'codeBlock') return undefined
const attributes = dataAttributes(node, ['language'])
const language = nodeAttrs(node)['language']
if (language !== undefined && (typeof language !== 'string' || !/^[^\t\n\f\r ]+$/.test(language))) return undefined
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
if (node.content === undefined) return `<pre><code${className}></code></pre>`
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)
return code === undefined ? undefined : `<pre><code${className}>${code}</code></pre>`
return code === undefined ? undefined : `<pre${attributes}><code${className}>${code}</code></pre>`
}
function tryImage(node: AdfNode): string | undefined {
const image = node.type === 'mediaSingle' ? plainImage(node) : undefined
if (image === undefined) return undefined
const src = htmlValue(image.url)
const src = writableValue(image.url)
const alt = optionalAttribute('alt', image.alt)
if (src === undefined || alt === undefined) return undefined
const element = `<img src="${src}"${alt}>`
@@ -128,3 +166,8 @@ function tryImage(node: AdfNode): string | undefined {
const anchor = anchorOpener(image.link)
return anchor === undefined ? undefined : `<p>${anchor}${element}</a></p>`
}
function tryRule(node: AdfNode): string | undefined {
const attributes = node.type === 'rule' && nodeContent(node).length === 0 ? dataAttributes(node, []) : undefined
return attributes === undefined ? undefined : `<hr${attributes}>`
}
+45 -15
View File
@@ -1,34 +1,64 @@
import type { AdfNode } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { anchorOpener, jsonValue, shownValue } from './escaping.ts'
import { carriedJson } from '../../adf/carried-json.ts'
import { carriedValue } from './escaping.ts'
import { largestNesting } from '../../nesting.ts'
import { nodeContent } from '../../adf/document.ts'
import { shownText } from '../../adf/shown-text.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 { 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'
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, carriedText(node))) : json
return json.ok ? success(carryElement('div', json.value.json, shownContent(node, 'block'))) : json
}
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, carriedText(node))) : json
return json.ok ? success(carryElement('span', json.value.json, shownContent(node, 'inline'))) : json
}
export function carryElement(tag: string, json: string, text: string): string {
return `<${tag} class="adf-carry" data-adf="${carriedValue(json)}">${carriedValue(text)}</${tag}>`
export function carryElement(tag: string, json: string, content: string): string {
return `<${tag} class="adf-carry" data-adf="${jsonValue(json)}">${content}</${tag}>`
}
// The text a carried node shows, a line break between its blocks and for each hard break; a reader of the element reads the JSON alone.
function carriedText(node: AdfNode): string {
let text = ''
const pending: AdfNode[] = [node]
// 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]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (text !== '' && (next.type === 'hardBreak' || blockNodeModel(next.type) !== undefined)) text += '\n'
text += next.text ?? shownText(next) ?? ''
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')
for (const child of [...nodeContent(next)].reverse()) pending.push(child)
}
return text
if (group !== '') groups.push(group)
return position === 'block' ? groups.map((shown) => `<p>${shown}</p>`).join('') : groups.join('<br>')
}
// 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 {
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 link = shownLink(node)
const anchor = link === undefined ? undefined : anchorOpener(link)
return anchor === undefined ? shownValue(text) : `${anchor}${shownValue(text)}</a>`
}
// 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 }
}
+33 -12
View File
@@ -1,23 +1,44 @@
// An HTML parser reads a null character as U+FFFD, so each function here refuses or drops one, and the writer escapes through nothing else.
import type { LinkAttrs } from '../../adf/mark-attributes.ts'
// `undefined` where the value holds a null character, so its node rides the carry element.
export function htmlValue(value: string): string | undefined {
return value.includes('\0') ? undefined : escapeHtml(value)
// 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.
const unwritable = /(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u
const unwritables = new RegExp(unwritable.source, 'gu')
export function anchorOpener({ href, title }: LinkAttrs): string | undefined {
const url = writableValue(href)
const titled = optionalAttribute('title', title)
return url === undefined || titled === undefined ? undefined : `<a href="${url}"${titled}>`
}
// ` name="value"`, `''` where the value is undefined, `undefined` where it holds a null character.
// The carry's JSON, which spells each character HTML cannot hold as a JSON escape reading back to the same string.
export function jsonValue(json: string): string {
return escapeHtml(json.replace(unwritables, (character) => jsonEscape(character)))
}
// ` 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 ''
const escaped = htmlValue(value)
const escaped = writableValue(value)
return escaped === undefined ? undefined : ` ${name}="${escaped}"`
}
// What the carry element holds: its JSON spells a null character `\u0000`, and the text it shows drops one.
export function carriedValue(value: string): string {
return escapeHtml(value.replaceAll('\0', ''))
// 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>')
}
// A carriage return is a character reference, since an HTML parser reads a raw one as a line feed.
function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('\r', '&#13;')
// `undefined` where HTML cannot hold the value, so its node rides the carry element.
export function writableValue(value: string): string | undefined {
return unwritable.test(value) ? undefined : escapeHtml(value)
}
function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
// An astral noncharacter spells as its two surrogates' escapes.
function jsonEscape(character: string): string {
let escaped = ''
for (let index = 0; index < character.length; index += 1) escaped += `\\u${character.charCodeAt(index).toString(16).padStart(4, '0')}`
return escaped
}
+11 -13
View File
@@ -1,12 +1,15 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import { anchorOpener, writableValue } from './escaping.ts'
import { carriedInline } from './carry-element.ts'
import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { htmlValue, optionalAttribute } from './escaping.ts'
import { linkAttrs, type LinkAttrs } from '../../adf/mark-attributes.ts'
import { linkAttrs } from '../../adf/mark-attributes.ts'
import { success, type ConvertErrorPath, type Result } from '../../result.ts'
// Each names its element.
const elementMarks: readonly string[] = ['code', 'em', 'strong']
const markElements: ReadonlyMap<string, string> = new Map([
['code', 'code'],
['em', 'em'],
['strong', 'strong'],
])
export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
let html = ''
@@ -41,22 +44,17 @@ function tryInlineNode(node: AdfNode): string | undefined {
return `${openers.join('')}${text}${closers.join('')}`
}
// No element holds empty text apart from no text.
// Empty text reads back as no text node, so it rides the carry.
export function writableText(text: string | undefined): string | undefined {
return text === undefined || text === '' ? undefined : htmlValue(text)
return text === undefined || text === '' ? undefined : writableValue(text)
}
function markElement(mark: AdfMark): { closer: string; opener: string } | undefined {
const { type } = mark
if (elementMarks.includes(type)) return mark.attrs === undefined ? { closer: `</${type}>`, opener: `<${type}>` } : undefined
const element = markElements.get(type)
if (element !== undefined) return mark.attrs === undefined ? { closer: `</${element}>`, opener: `<${element}>` } : undefined
if (type !== 'link' || Object.keys(nodeAttrs(mark)).some((key) => key !== 'href' && key !== 'title')) return undefined
const link = linkAttrs(nodeAttrs(mark))
const opener = link === undefined ? undefined : anchorOpener(link)
return opener === undefined ? undefined : { closer: '</a>', opener }
}
export function anchorOpener({ href, title }: LinkAttrs): string | undefined {
const url = htmlValue(href)
const titled = optionalAttribute('title', title)
return url === undefined || titled === undefined ? undefined : `<a href="${url}"${titled}>`
}