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

Ship adfToHtml: semantic HTML for the CommonMark blocks and marks, an adf-carry element for the rest

This commit is contained in:
2026-10-23 12:50:48 +02:00
committed by Lillem4n
parent 33ef8db768
commit 6fe36d5947
33 changed files with 826 additions and 147 deletions
+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { Result } from '../../result.ts'
import { adfToHtml } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function html(result: Result<string>): string {
return result.ok ? result.value : `${result.error.code}: ${result.error.message}`
}
function paragraph(...content: AdfNode[]): AdfNode {
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
function item(...content: AdfNode[]): AdfNode {
return { content, type: 'listItem' }
}
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')
assert.equal(html(adfToHtml(document(paragraph(), { attrs: { level: 2 }, type: 'heading' }))), '<p></p>\n<h2></h2>\n')
})
test('writes the marks CommonMark spells, the first mark outermost', () => {
const link: AdfMark = { attrs: { href: 'https://example.com/a?b=1&c=2', title: 'The "A" page' }, type: 'link' }
const line = paragraph(text('Read '), text('the', { type: 'em' }), text(' '), text('manual', { type: 'strong' }, { type: 'em' }), text(': '), text('npm ci', { type: 'code' }, link), text(' or '), text('this', link))
assert.equal(
html(adfToHtml(document(line))),
'<p>Read <em>the</em> <strong><em>manual</em></strong>: <code><a href="https://example.com/a?b=1&amp;c=2" title="The &quot;A&quot; page">npm ci</a></code> or <a href="https://example.com/a?b=1&amp;c=2" title="The &quot;A&quot; page">this</a></p>\n',
)
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('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')
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 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')
assert.equal(html(adfToHtml(document({ content: [text('plain')], type: 'codeBlock' }, { type: 'codeBlock' }))), '<pre><code>plain</code></pre>\n<pre><code></code></pre>\n')
})
test('writes an external image standing alone in a paragraph, a link around it where one marks it', () => {
const image = (media: AdfNode): AdfNode => ({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' })
assert.equal(html(adfToHtml(document(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' })))), '<p><img src="https://example.com/moon.jpg" alt="The moon"></p>\n')
const linked = image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'media' })
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' }
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',
)
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('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: { 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({ 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)
})
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({ attrs: { text: '\n' }, type: 'hardBreak' }), 1)
assert.equal(carriedSpans(paragraph(text('x'))), 1)
})
test('carries a link or image whose URL a browser would run as script, or read as a file or non-image data', () => {
const linked = (href: string): string => html(adfToHtml(document(paragraph(text('x', { attrs: { href }, type: 'link' })))))
for (const href of ['javascript:alert(1)', 'JavaScript:alert(1)', ' \u0001java\tscript:alert(1)', 'vbscript:x', 'file:///etc/passwd', 'data:text/html,x', 'data:image/svg+xml,x']) {
assert.equal(linked(href).startsWith('<p><span class="adf-carry"'), true, href)
}
for (const href of ['https://example.com/', '/relative', '#top', 'mailto:a@example.com', 'data:image/png;base64,AAAA', 'javascriptx:alert(1)']) {
assert.equal(linked(href).startsWith('<p><a href='), true, href)
}
const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { type: 'external', url: 'javascript:alert(1)' }, type: 'media' }], type: 'mediaSingle' }
assert.equal(html(adfToHtml(document(image))).startsWith('<div class="adf-carry"'), true)
})
test('writes a container holding no content key as its element holding nothing', () => {
assert.equal(html(adfToHtml(document({ type: 'blockquote' }, { type: 'bulletList' }, { type: 'orderedList' }))), '<blockquote></blockquote>\n<ul></ul>\n<ol></ol>\n')
})
test('carries a link or image whose target the anchor cannot hold', () => {
const carried = (mark: AdfMark): boolean => html(adfToHtml(document(paragraph(text('x', mark))))).startsWith('<p><span class="adf-carry"')
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 image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { type: 'external', url: 'https://example.com/a.png' }, marks: [{ attrs: { href: 'javascript:alert(1)' }, type: 'link' }], type: 'media' }], type: 'mediaSingle' }
assert.equal(html(adfToHtml(document(image))).startsWith('<div class="adf-carry"'), true)
})
test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading and a list item', () => {
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')
})
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))))), '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('writes an empty document as nothing, and one holding no content key as its carry', () => {
assert.equal(html(adfToHtml(document())), '')
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 HTML spelling 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')
})
+249
View File
@@ -0,0 +1,249 @@
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, heldTextLength, holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { carriedJson } from '../../adf/carried-json.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { joinedWithin, longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { largestNesting } from '../../nesting.ts'
import { linkTarget, type LinkTarget } from '../../adf/mark-attributes.ts'
import { plainImage } from '../../adf/external-image.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { shownText } from '../../adf/shown-text.ts'
// depth: the nesting level of the block list holding the node, as `largestNesting` counts it.
type Block = { depth: number; node: AdfNode; path: ConvertErrorPath }
type Pending = Block | string
// Each names its element.
const elementMarks: readonly string[] = ['code', 'em', 'strong']
export function adfToHtml(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeHtml(document))
}
function writeHtml(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no HTML spelling carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`)
// Every character of text is written at least once, so this much text cannot fit, and none of it is written.
if (heldTextLength(document.content) > longestOutput) return tooLong()
const pending: Pending[] = []
scheduleBlocks(pending, document.content, [], 0, '', document.content.length === 0 ? '' : '\n')
const pieces: string[] = []
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === 'string') {
pieces.push(next)
continue
}
const written = writeBlock(next, pending)
if (!written.ok) return written
if (written.value !== undefined) pieces.push(written.value)
}
return joinedWithin(pieces)
}
// Pushed in reverse, so the walk pops them in document order: a stack rather than recursion, since each level would cost several frames.
function scheduleBlocks(pending: Pending[], nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, opener: string, closer: string): void {
const scheduled: Pending[] = [opener]
for (const [index, node] of nodes.entries()) {
if (index > 0) scheduled.push('\n')
scheduled.push({ depth, node, path: [...path, 'content', index] })
}
scheduled.push(closer)
for (const item of scheduled.reverse()) pending.push(item)
}
function tooDeep(path: ConvertErrorPath): Result<never> {
return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, path)
}
// `undefined` where the block scheduled its children rather than writing itself whole.
function writeBlock(block: Block, pending: Pending[]): Result<string | undefined> {
const { depth, node, path } = block
if (depth > largestNesting) return tooDeep(path)
const leaf = tryLeafBlock(node, path)
if (leaf !== undefined) return leaf
if (node.type === 'blockquote' && holdsOnlyAttributes(node, [])) return containerBlock(pending, block, 'blockquote', '')
const list = listElement(node)
if (list !== undefined) return scheduleList(pending, block, list)
return carriedBlock(node, path, depth)
}
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'])) {
const level = nodeAttrs(node)['level']
if (typeof level === 'number' && Number.isInteger(level) && level >= 1 && level <= 6) return inlineElement(`h${level}`, '', nodeContent(node), path)
}
const text = tryCodeBlock(node) ?? tryImage(node) ?? (node.type === 'rule' && holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '<hr>' : undefined)
return text === undefined ? undefined : success(text)
}
function inlineElement(tag: string, attributes: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
const inline = inlineHtml(nodes, path)
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
const language = nodeAttrs(node)['language']
if (language !== undefined && (typeof language !== 'string' || !/^[^\t\n\f\r ]+$/.test(language))) return undefined
const className = language === undefined ? '' : ` class="${escapeHtml(`language-${language}`)}"`
if (node.content === undefined) return `<pre><code${className}></code></pre>`
const [only, ...others] = node.content
if (only === undefined || others.length > 0 || !writableText(only) || !isUnmarkedSpellableText(only)) return undefined
return `<pre><code${className}>${escapeHtml(only.text)}</code></pre>`
}
function tryImage(node: AdfNode): string | undefined {
const image = node.type === 'mediaSingle' ? plainImage(node) : undefined
if (image === undefined || !writableUrl(image.url) || (image.alt !== undefined && image.alt.includes('\0'))) return undefined
const element = `<img src="${escapeHtml(image.url)}"${image.alt === undefined ? '' : ` alt="${escapeHtml(image.alt)}"`}>`
if (image.link === undefined) return `<p>${element}</p>`
const anchor = anchorOpener(image.link)
return anchor === undefined ? undefined : `<p>${anchor}${element}</a></p>`
}
function containerBlock(pending: Pending[], { depth, node, path }: Block, tag: string, attributes: string): Result<string | undefined> {
if (node.content === undefined) return success(`<${tag}${attributes}></${tag}>`)
scheduleBlocks(pending, node.content, path, depth + 1, `<${tag}${attributes}>\n`, `\n</${tag}>`)
return success(undefined)
}
// The list's element, `undefined` where the list or an item holds what the elements cannot, so the whole list rides the carry.
function listElement(node: AdfNode): { attributes: string; tag: string } | undefined {
if (node.type === 'bulletList' && holdsOnlyAttributes(node, [])) return listItemsWritable(node) ? { attributes: '', tag: 'ul' } : undefined
if (node.type !== 'orderedList' || !holdsOnlyAttributes(node, ['order']) || !listItemsWritable(node)) return undefined
const order = nodeAttrs(node)['order']
if (order === undefined) return { attributes: '', tag: 'ol' }
return typeof order === 'number' && Number.isSafeInteger(order) && !Object.is(order, -0) ? { attributes: ` start="${order}"`, tag: 'ol' } : undefined
}
function listItemsWritable(list: AdfNode): boolean {
return nodeContent(list).every((item) => item.type === 'listItem' && holdsOnlyAttributes(item, []))
}
function scheduleList(pending: Pending[], block: Block, { attributes, tag }: { attributes: string; tag: string }): Result<string | undefined> {
const { depth, node, path } = block
if (node.content === undefined) return success(`<${tag}${attributes}></${tag}>`)
const items: { opening: string; rest: Pending[] }[] = []
// The items' blocks sit one level below the list, as a markdown list's do.
for (const [index, item] of node.content.entries()) {
const rest: Pending[] = []
const opening = writeItem(rest, item, [...path, 'content', index], depth + 1)
if (!opening.ok) return opening
items.push({ opening: `${index === 0 ? '' : '\n'}${opening.value}`, rest })
}
pending.push(`\n</${tag}>`)
for (const { opening, rest } of items.reverse()) {
for (const piece of rest) pending.push(piece)
pending.push(opening)
}
pending.push(`<${tag}${attributes}>\n`)
return success(undefined)
}
// Returns the item's opening, its first paragraph written bare where it holds content, and pushes the rest; panel pick, docs/decisions.md §The HTML dialect.
function writeItem(pending: Pending[], item: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
if (depth > largestNesting) return tooDeep(path)
if (item.content === undefined) return success('<li></li>')
const [first] = item.content
const bare = first !== undefined && first.type === 'paragraph' && holdsOnlyAttributes(first, []) && nodeContent(first).length > 0
const rest = bare ? item.content.slice(1) : item.content
scheduleBlocks(pending, rest, path, depth, rest.length === 0 ? '' : '\n', rest.length === 0 ? '</li>' : '\n</li>')
if (!bare) return success('<li>')
const inline = inlineHtml(nodeContent(first), [...path, 'content', 0])
return inline.ok ? success(`<li>${inline.value}`) : inline
}
function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
let html = ''
let afterPlainText = false
for (const [index, node] of nodes.entries()) {
// Two unmarked text nodes side by side read back as one, so the second rides the carry.
const written: string | undefined = afterPlainText && isUnmarkedSpellableText(node) ? undefined : tryInlineNode(node)
if (written === undefined) {
const carried = carriedInline(node, [...path, 'content', index])
if (!carried.ok) return carried
html += carried.value
} else html += written
afterPlainText = written !== undefined && isUnmarkedSpellableText(node)
}
return success(html)
}
function tryInlineNode(node: AdfNode): string | undefined {
if (node.type === 'hardBreak') return holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '<br>' : undefined
if (!isSpellableText(node) || !writableText(node)) return undefined
const marks = nodeMarks(node)
const openers: string[] = []
const closers: string[] = []
for (const mark of marks) {
if (marks.filter(({ type }) => type === mark.type).length > 1) return undefined
const element = markElement(mark)
if (element === undefined) return undefined
openers.push(element.opener)
closers.unshift(element.closer)
}
return `${openers.join('')}${escapeHtml(node.text)}${closers.join('')}`
}
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
if (type !== 'link' || Object.keys(nodeAttrs(mark)).some((key) => key !== 'href' && key !== 'title')) return undefined
const target = linkTarget(nodeAttrs(mark))
const opener = target === undefined ? undefined : anchorOpener(target)
return opener === undefined ? undefined : { closer: '</a>', opener }
}
function anchorOpener({ href, title }: LinkTarget): string | undefined {
if (!writableUrl(href) || title?.includes('\0') === true) return undefined
return `<a href="${escapeHtml(href)}"${title === undefined ? '' : ` title="${escapeHtml(title)}"`}>`
}
// HTML holds no null character, and no element holds empty text apart from no text.
function writableText(node: AdfNode): node is AdfNode & { text: string } {
return typeof node.text === 'string' && node.text !== '' && !node.text.includes('\0')
}
// cmark's dangerous-URL rule: its schemes and its safe image data. A browser drops tabs and line breaks and a leading control or space before it reads the scheme, so the check does too.
function writableUrl(url: string): boolean {
if (url.includes('\0')) return false
const scheme = url.replace(/[\t\n\r]/g, '').replace(/^[\u0001- ]+/, '').toLowerCase()
if (/^(?:file|javascript|vbscript):/.test(scheme)) return false
return !scheme.startsWith('data:') || /^data:image\/(?:gif|jpeg|png|webp)/.test(scheme)
}
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
}
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
}
function carryElement(tag: string, json: string, text: string): string {
return `<${tag} class="adf-carry" data-adf="${escapeHtml(json)}">${escapeHtml(text.replaceAll('\0', ''))}</${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]
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) ?? ''
for (const child of [...nodeContent(next)].reverse()) pending.push(child)
}
return text
}
// 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;')
}