mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-13 12:18:28 +01:00
Ship adfToHtml: semantic HTML for the CommonMark blocks and marks, an adf-carry element for the rest
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { AdfNode } from './document.ts'
|
||||
import type { JsonSpelling } from '../canonical-json.ts'
|
||||
import { canonicalJsonPieces } from '../canonical-json.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||
import { isJsonValue, nestingDepth } from '../json-value.ts'
|
||||
import { joinedWithin } from '../length-bound.ts'
|
||||
|
||||
// `spelled` is the node as the carry holds it, `type` dropped where the format names it apart; `levels` the nesting its position leaves.
|
||||
export function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
|
||||
const headroom = levels - nestingDepth(node)
|
||||
if (!isJsonValue(spelled) || headroom < 0) {
|
||||
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
|
||||
}
|
||||
// Two-space JSON indents each value by its depth, so a short compact value can spell past the bound: join only up to it.
|
||||
const json = joinedWithin(canonicalJsonPieces(spelled, spelling))
|
||||
return json.ok ? success({ headroom, json: json.value }) : json
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AdfNode } from './document.ts'
|
||||
import { linkTarget, linkTargetMark, type LinkTarget } from './mark-attributes.ts'
|
||||
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
// `alt` undefined where the media holds none: an empty alt builds no `alt`, so a media holding an empty one is no plain image.
|
||||
export type ExternalImage = { alt: string | undefined; link: LinkTarget | undefined; url: string }
|
||||
|
||||
// The plain image, the one shape each format writes as its own image element: a centered `mediaSingle` holding one external `media`, which a link may mark.
|
||||
export function centeredImage(content: AdfNode[]): AdfNode {
|
||||
return { attrs: { layout: 'center' }, content, type: 'mediaSingle' }
|
||||
}
|
||||
|
||||
export function externalMedia(alt: string, url: string, link?: LinkTarget): AdfNode {
|
||||
const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
|
||||
return link === undefined ? media : { ...media, marks: [linkTargetMark(link)] }
|
||||
}
|
||||
|
||||
// `undefined` unless the node is exactly the plain image its parts build.
|
||||
export function plainImage(node: AdfNode): ExternalImage | undefined {
|
||||
const [media] = nodeContent(node)
|
||||
if (media === undefined) return undefined
|
||||
const { alt, url } = nodeAttrs(media)
|
||||
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined
|
||||
const [mark] = nodeMarks(media)
|
||||
const link = mark === undefined ? undefined : linkTarget(nodeAttrs(mark))
|
||||
const read = centeredImage([externalMedia(alt ?? '', url, link)])
|
||||
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? { alt, link, url } : undefined
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AdfAttributes, AdfMark } from './document.ts'
|
||||
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||
|
||||
export const blockMarkAttributes = {
|
||||
@@ -26,3 +27,17 @@ export type MarkType = keyof typeof markAttributes
|
||||
export function isMarkType(type: string): type is MarkType {
|
||||
return Object.hasOwn(markAttributes, type)
|
||||
}
|
||||
|
||||
export type LinkTarget = { href: string; title?: string }
|
||||
|
||||
export function linkTargetMark({ href, title }: LinkTarget): AdfMark {
|
||||
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
// `undefined` where `href` is no string, or `title` is there and no string; any other key is the caller's to check.
|
||||
export function linkTarget(attrs: AdfAttributes): LinkTarget | undefined {
|
||||
const { href, title } = attrs
|
||||
if (typeof href !== 'string') return undefined
|
||||
if (title === undefined) return { href }
|
||||
return typeof title === 'string' ? { href, title } : undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AdfNode } from './document.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { nodeAttrs } from './document.ts'
|
||||
|
||||
// The text a node shows in place of itself, read from the attribute holding its content; `undefined` where it holds none.
|
||||
export function shownText(node: AdfNode): string | undefined {
|
||||
const attrs = nodeAttrs(node)
|
||||
switch (node.type) {
|
||||
case 'blockCard':
|
||||
case 'embedCard':
|
||||
case 'inlineCard':
|
||||
return visibleText(attrs['url']) ?? visibleText(cardData(node)['name']) ?? cardUrl(node)
|
||||
case 'date':
|
||||
return isoDate(attrs['timestamp'])
|
||||
case 'emoji':
|
||||
return visibleText(attrs['text']) ?? stringValue(attrs['shortName'])
|
||||
case 'expand':
|
||||
case 'nestedExpand':
|
||||
case 'status':
|
||||
return stringValue(node.type === 'status' ? attrs['text'] : attrs['title'])
|
||||
case 'extension':
|
||||
case 'inlineExtension':
|
||||
return visibleText(attrs['text'])
|
||||
case 'media':
|
||||
case 'mediaInline':
|
||||
return visibleText(attrs['alt']) ?? (attrs['type'] === 'external' ? visibleText(attrs['url']) : undefined)
|
||||
case 'mention': {
|
||||
const id = attrs['id']
|
||||
return visibleText(attrs['text']) ?? (typeof id === 'string' && id !== '' ? `@${id}` : undefined)
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function cardUrl(card: AdfNode): string | undefined {
|
||||
return visibleText(nodeAttrs(card)['url']) ?? visibleText(cardData(card)['url'])
|
||||
}
|
||||
|
||||
// `undefined` where the value is no string or holds only whitespace and null characters.
|
||||
export function visibleText(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && /[^\s\0]/.test(value) ? value : undefined
|
||||
}
|
||||
|
||||
function cardData(card: AdfNode): Readonly<Record<string, JsonValue>> {
|
||||
const data = nodeAttrs(card)['data']
|
||||
return typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
|
||||
}
|
||||
|
||||
function isoDate(timestamp: unknown): string | undefined {
|
||||
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
|
||||
const date = new Date(milliseconds)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
const iso = date.toISOString()
|
||||
return iso.slice(0, iso.indexOf('T'))
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test'
|
||||
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import { adfDocument, blockTaskItemChildren, propertyRuns, propertyTimeout } from './property-harness.ts'
|
||||
import { adfToHtml } from '../html/emit/adf-to-html.ts'
|
||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { adfToPortableMarkdown, reduceToPortable } from '../markdown/portable/adf-to-portable-markdown.ts'
|
||||
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
@@ -55,6 +56,16 @@ test('a generated document writes portable markdown refusing only what the guard
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes HTML', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const written = adfToHtml(document)
|
||||
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes the portable markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { LinkDefinition } from '../markdown/commonmark/link-syntax.ts'
|
||||
import { blockArgument } from '../markdown/directive/block.ts'
|
||||
import { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { centeredImage, externalMedia } from '../markdown/external-image.ts'
|
||||
import { centeredImage, externalMedia } from '../adf/external-image.ts'
|
||||
import { definitionTarget } from '../markdown/mark-spellings.ts'
|
||||
import { directivePrefix } from '../markdown/directive/syntax.ts'
|
||||
import { emptyKeys, mergeAdjacentText, nodeContent } from '../adf/document.ts'
|
||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
@@ -95,7 +96,7 @@ function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]
|
||||
}
|
||||
|
||||
function externalImage({ alt, link, url }: { alt: string | undefined; link: LinkDefinition | undefined; url: string }): AdfNode {
|
||||
return centeredImage([externalMedia(alt ?? '', url, link)])
|
||||
return centeredImage([externalMedia(alt ?? '', url, link === undefined ? undefined : definitionTarget(link))])
|
||||
}
|
||||
|
||||
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
||||
|
||||
@@ -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&c=2" title="The "A" page">npm ci</a></code> or <a href="https://example.com/a?b=1&c=2" title="The "A" 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 < b && c > "d" \'e\' \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 < 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="{"attrs":{"panelType":"info"},"content":[{"content":[{"text":"Check the collation.","type":"text"}],"type":"paragraph"},{"content":[{"text":"Then import.","type":"text"}],"type":"paragraph"}],"type":"panel"}">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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@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/<card>')
|
||||
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="{"type":"doc","version":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')
|
||||
})
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('\r', ' ')
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
||||
export type { JsonValue } from './json-value.ts'
|
||||
export { adfToHtml } from './html/emit/adf-to-html.ts'
|
||||
export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
|
||||
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { Result } from '../result.ts'
|
||||
import type { Result } from './result.ts'
|
||||
import { joinedWithin, tooLong, withinEngineLimits, withinLongest } from './length-bound.ts'
|
||||
import { success } from '../result.ts'
|
||||
import { success } from './result.ts'
|
||||
|
||||
function thrown(name: string, message: string): () => Result<string> {
|
||||
return () => {
|
||||
@@ -1,9 +1,9 @@
|
||||
import { failure, success, type Result } from '../result.ts'
|
||||
import { failure, success, type Result } from './result.ts'
|
||||
|
||||
// V8's longest string, refused on every engine so a document converts alike everywhere (docs/decisions.md §Markdown past V8's longest string is refused on every engine).
|
||||
export const longestMarkdown = 2 ** 29 - 24
|
||||
// V8's longest string, refused on every engine so a document converts alike everywhere (docs/decisions.md §Output past V8's longest string is refused on every engine).
|
||||
export const longestOutput = 2 ** 29 - 24
|
||||
|
||||
// Measured on Firefox 155, Node 24 and Bun 1.4; SpiderMonkey's `invalid array length` is taken from its js.msg. SpiderMonkey's overflow and V8's string overflow both mean a string past longestMarkdown; the others name an engine's own limit or its memory running short.
|
||||
// Measured on Firefox 155, Node 24 and Bun 1.4; SpiderMonkey's `invalid array length` is taken from its js.msg. SpiderMonkey's overflow and V8's string overflow both mean a string past longestOutput; the others name an engine's own limit or its memory running short.
|
||||
const pastLongestString = ['InternalError: allocation size overflow', 'RangeError: Invalid string length']
|
||||
const pastEngineLimit = [
|
||||
'RangeError: Array buffer allocation failed',
|
||||
@@ -15,11 +15,11 @@ const pastEngineLimit = [
|
||||
]
|
||||
|
||||
export function tooLong<T>(): Result<T> {
|
||||
return failure('unsupported-node-shape', `the markdown for this document, or for a directive's plain CommonMark, would be longer than ${longestMarkdown} characters, the longest this library writes`, [])
|
||||
return failure('unsupported-node-shape', `the markdown or HTML for this document, or a directive's plain CommonMark, would be longer than ${longestOutput} characters, the longest this library writes`, [])
|
||||
}
|
||||
|
||||
export function withinLongest(text: string): Result<string> {
|
||||
return text.length > longestMarkdown ? tooLong() : success(text)
|
||||
return text.length > longestOutput ? tooLong() : success(text)
|
||||
}
|
||||
|
||||
export function joinedWithin(pieces: Iterable<string>): Result<string> {
|
||||
@@ -27,7 +27,7 @@ export function joinedWithin(pieces: Iterable<string>): Result<string> {
|
||||
let length = 0
|
||||
for (const piece of pieces) {
|
||||
length += piece.length
|
||||
if (length > longestMarkdown) return tooLong()
|
||||
if (length > longestOutput) return tooLong()
|
||||
kept.push(piece)
|
||||
}
|
||||
return success(kept.join(''))
|
||||
@@ -44,7 +44,7 @@ test('refuses a value that is not an ADF document, naming the check it failed',
|
||||
assert.equal(markdown(adfToLosslessMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN')
|
||||
})
|
||||
|
||||
const tooLong = 'unsupported-node-shape: the markdown for this document, or for a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes'
|
||||
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'
|
||||
|
||||
// A code block of 300 lines; two of these together fall some 1000 characters short of V8's longest string, so only what their spelling adds passes it.
|
||||
function nearlyHalfTheBound(): AdfNode {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { flavourClaims } from '../flavour.ts'
|
||||
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
|
||||
import { languageSlot, type LanguageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { longestMarkdown, tooLong, withinEngineLimits, withinLongest } from '../length-bound.ts'
|
||||
import { longestOutput, tooLong, withinEngineLimits, withinLongest } from '../../length-bound.ts'
|
||||
import { spellBlockDirectiveOpener } from '../directive/block-spelling.ts'
|
||||
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive/syntax.ts'
|
||||
import { tryImage } from './image.ts'
|
||||
@@ -55,7 +55,7 @@ export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Resu
|
||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
if (document.content === undefined) return success(`${documentSpelling}\n`)
|
||||
// Every character of text is spelled at least once, so this much text cannot fit, and none of it is spelled.
|
||||
if (heldTextLength(document.content) > longestMarkdown) return tooLong()
|
||||
if (heldTextLength(document.content) > longestOutput) return tooLong()
|
||||
const walk = walkBlocks(document.content, [], 0, { ...writtenAs, memo: undefined })
|
||||
if (!walk.ok) return walk
|
||||
const text = joinBlocks(walk.value.blocks, 'document')
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { WrittenFlavour } from '../flavour.ts'
|
||||
import { centeredImage, externalMedia } from '../external-image.ts'
|
||||
import { linkDefinition } from '../mark-spellings.ts'
|
||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { plainImage } from '../../adf/external-image.ts'
|
||||
import { targetDefinition } from '../mark-spellings.ts'
|
||||
import { tryImageLine } from './inline-line.ts'
|
||||
|
||||
export function tryImage(node: AdfNode, flavour: WrittenFlavour): string | undefined {
|
||||
const [media] = nodeContent(node)
|
||||
if (media === undefined) return undefined
|
||||
const { alt, url } = nodeAttrs(media)
|
||||
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined
|
||||
const [mark] = nodeMarks(media)
|
||||
const link = mark === undefined ? undefined : linkDefinition(nodeAttrs(mark))
|
||||
const read = centeredImage([externalMedia(alt ?? '', url, link)])
|
||||
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url, flavour, link) : undefined
|
||||
const image = plainImage(node)
|
||||
if (image === undefined) return undefined
|
||||
const { alt, link, url } = image
|
||||
return tryImageLine(alt, url, flavour, link === undefined ? undefined : targetDefinition(link))
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import type { LinkDefinition } from './commonmark/link-syntax.ts'
|
||||
import { linkMark } from './mark-spellings.ts'
|
||||
|
||||
// spec/flavour.md, The CommonMark image: `` reads to `centeredImage([externalMedia(alt, url)])`, the only shape `adfToLosslessMarkdown` writes as ``.
|
||||
export function centeredImage(content: AdfNode[]): AdfNode {
|
||||
return { attrs: { layout: 'center' }, content, type: 'mediaSingle' }
|
||||
}
|
||||
|
||||
// `` builds no `alt`, so a media holding an empty one takes the directive form.
|
||||
export function externalMedia(alt: string, url: string, link?: LinkDefinition): AdfNode {
|
||||
const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
|
||||
return link === undefined ? media : { ...media, marks: [linkMark(link)] }
|
||||
}
|
||||
|
||||
// The ` (image)` after a link for an image inside it, as the readers build it (spec/flavour.md, The CommonMark image) and `adfToPortableMarkdown` writes it; `link` builds the linked word `image`.
|
||||
export function imageAside(link: (text: string) => AdfNode[]): AdfNode[] {
|
||||
return [{ text: ' (', type: 'text' }, ...link('image'), { text: ')', type: 'text' }]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
|
||||
// The ` (image)` after a link for an image inside it, as the readers build it (spec/flavour.md, The CommonMark image) and `adfToPortableMarkdown` writes it; `link` builds the linked word `image`.
|
||||
export function imageAside(link: (text: string) => AdfNode[]): AdfNode[] {
|
||||
return [{ text: ' (', type: 'text' }, ...link('image'), { text: ')', type: 'text' }]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts'
|
||||
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive/syntax.ts'
|
||||
import { holdsEntityReference } from './commonmark/entity-references.ts'
|
||||
import { isAutolink } from './commonmark/grammar.ts'
|
||||
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { isMarkType, linkTarget, linkTargetMark, markAttributes, type LinkTarget } from '../adf/mark-attributes.ts'
|
||||
import { spellEmptyKeys } from './directive/empty-keys.ts'
|
||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||
|
||||
@@ -37,16 +37,21 @@ export function markSpelling(type: string): MarkSpelling | undefined {
|
||||
return { attributes, kind: spelling.kind }
|
||||
}
|
||||
|
||||
export function linkMark({ destination, title }: LinkDefinition): AdfMark {
|
||||
return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' }
|
||||
export function linkMark(definition: LinkDefinition): AdfMark {
|
||||
return linkTargetMark(definitionTarget(definition))
|
||||
}
|
||||
|
||||
export function definitionTarget({ destination, title }: LinkDefinition): LinkTarget {
|
||||
return title === undefined ? { href: destination } : { href: destination, title }
|
||||
}
|
||||
|
||||
export function linkDefinition(attrs: AdfAttributes): LinkDefinition | undefined {
|
||||
const destination = linkHref(attrs)
|
||||
const title = attrs['title']
|
||||
if (destination === undefined) return undefined
|
||||
if (title === undefined) return { destination }
|
||||
return typeof title === 'string' ? { destination, title } : undefined
|
||||
const target = linkTarget(attrs)
|
||||
return target === undefined ? undefined : targetDefinition(target)
|
||||
}
|
||||
|
||||
export function targetDefinition({ href, title }: LinkTarget): LinkDefinition {
|
||||
return title === undefined ? { destination: href } : { destination: href, title }
|
||||
}
|
||||
|
||||
export function linkHref(attrs: AdfAttributes): string | undefined {
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { AdfNode } from '../adf/document.ts'
|
||||
import type { DirectiveSpan } from './directive/syntax.ts'
|
||||
import type { JsonSpelling } from '../canonical-json.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts'
|
||||
import { carriedJson } from '../adf/carried-json.ts'
|
||||
import { spellsCanonicalJson } from '../canonical-json.ts'
|
||||
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive/syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Read, type Result } from '../result.ts'
|
||||
import { success, type ConvertErrorPath, type Read, type Result } from '../result.ts'
|
||||
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
|
||||
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||
import { isAdfNode } from '../adf/document.ts'
|
||||
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
||||
import { joinedWithin } from './length-bound.ts'
|
||||
import { overNested } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { parseJsonText } from '../json-text.ts'
|
||||
|
||||
@@ -54,16 +54,6 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
|
||||
return readCarriedJson(spelled.value, 'compact', largestNesting)
|
||||
}
|
||||
|
||||
function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
|
||||
const headroom = levels - nestingDepth(node)
|
||||
if (!isJsonValue(spelled) || headroom < 0) {
|
||||
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
|
||||
}
|
||||
// Two-space JSON indents each value by its depth, so a short compact value can spell past the bound: join only up to it.
|
||||
const json = joinedWithin(canonicalJsonPieces(spelled, spelling))
|
||||
return json.ok ? success({ headroom, json: json.value }) : json
|
||||
}
|
||||
|
||||
// `type` is the one the fence's info string names, `undefined` for the inline carry.
|
||||
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
|
||||
const wayOut = type === undefined ? directiveEscape : fenceEscape(type)
|
||||
|
||||
@@ -5,8 +5,9 @@ import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
||||
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
import { centeredImage, externalMedia, imageAside } from '../external-image.ts'
|
||||
import { commonMarkLink, linkDefinition, linkHref, linkMark } from '../mark-spellings.ts'
|
||||
import { centeredImage, externalMedia } from '../../adf/external-image.ts'
|
||||
import { imageAside } from '../image-aside.ts'
|
||||
import { commonMarkLink, definitionTarget, linkDefinition, linkHref, linkMark } from '../mark-spellings.ts'
|
||||
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
@@ -318,7 +319,7 @@ function imageLink(definition: LinkDefinition): ImageLink {
|
||||
function mediaSingle({ alt, definition, link }: Image): AdfNode {
|
||||
const { title } = definition
|
||||
const caption: AdfNode[] = title === undefined || title === '' ? [] : [{ content: [{ text: title, type: 'text' }], type: 'caption' }]
|
||||
return centeredImage([externalMedia(alt, definition.destination, link), ...caption])
|
||||
return centeredImage([externalMedia(alt, definition.destination, link === undefined ? undefined : definitionTarget(link)), ...caption])
|
||||
}
|
||||
|
||||
// ADF holds no empty text node and a hard break takes no link, so the destination text carries the link ahead of the text's hard breaks; an empty destination leaves the breaks alone.
|
||||
|
||||
@@ -364,7 +364,7 @@ test('refuses a blockquote directive whose CommonMark spelling would pass V8\'s
|
||||
const fence = `\`\`\`\n${`${'a'.repeat(894_782)}\n`.repeat(300)}\`\`\`\n`
|
||||
assert.equal(
|
||||
content(losslessMarkdownToAdf(`!adf:blockquote\n${fence}\n${fence}!adf:/blockquote\n`)),
|
||||
'unsupported-node-shape: the markdown for this document, or for a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes',
|
||||
'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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { parseInlineContent } from './inline-content.ts'
|
||||
import { readBlockDirectiveNode } from '../directive/node-reading.ts'
|
||||
import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts'
|
||||
import { spellsEmpty } from '../directive/empty-keys.ts'
|
||||
import { withinEngineLimits } from '../length-bound.ts'
|
||||
import { withinEngineLimits } from '../../length-bound.ts'
|
||||
|
||||
// A flavour's own reading of a quote, and of a bullet list into one or more blocks; `path` is the container's, `index` the list's place among the container's blocks.
|
||||
export type FlavourBlockReaders = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
|
||||
import { centeredImage, externalMedia } from '../external-image.ts'
|
||||
import { centeredImage, externalMedia } from '../../adf/external-image.ts'
|
||||
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
@@ -9,8 +9,8 @@ import { inlineLeaves, isBlockNodeType, oneLine, portableLink, reduceInline, wri
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { linkDefinition } from '../mark-spellings.ts'
|
||||
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
|
||||
import { linkTarget } from '../../adf/mark-attributes.ts'
|
||||
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
|
||||
import { portableWriting } from './block-spelling.ts'
|
||||
import { splitTaskList } from './task-list.ts'
|
||||
import { taskMarker } from './conventions.ts'
|
||||
@@ -63,7 +63,7 @@ export function reduceToPortable(document: AdfDocument): Result<AdfDocument> {
|
||||
if (fault !== undefined) return faulted(fault, [])
|
||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
// Measured on the caller's text, edge whitespace and all: `toEditorNormal`'s merge could build past the bound.
|
||||
if (heldTextLength(nodeContent(document)) > longestMarkdown) return tooLong()
|
||||
if (heldTextLength(nodeContent(document)) > longestOutput) return tooLong()
|
||||
// Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep).
|
||||
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
|
||||
if (!blocks.ok) {
|
||||
@@ -367,5 +367,5 @@ function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const alt = typeof held === 'string' ? oneLine(held).trim() : ''
|
||||
const mark = nodeMarks(media).find(({ type }) => type === 'link')
|
||||
const link = mark === undefined ? undefined : portableLink(mark, 'paragraph')
|
||||
return success([centeredImage([externalMedia(alt, writableHref(url), link === undefined ? undefined : linkDefinition(nodeAttrs(link)))])])
|
||||
return success([centeredImage([externalMedia(alt, writableHref(url), link === undefined ? undefined : linkTarget(nodeAttrs(link)))])])
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { MarkRun } from '../emit/line-escaping.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { cardUrl, shownText, visibleText } from '../../adf/shown-text.ts'
|
||||
import { codeTextMarkTypes } from '../../adf/inline-nodes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { imageAside } from '../external-image.ts'
|
||||
import { imageAside } from '../image-aside.ts'
|
||||
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
@@ -47,50 +48,38 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP
|
||||
const attrs = nodeAttrs(node)
|
||||
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
|
||||
if (node.type === 'hardBreak') return success([lineBreak(container)])
|
||||
if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container))
|
||||
if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container))
|
||||
if (['date', 'emoji', 'mention', 'status'].includes(node.type)) return success(textLeaves(shownText(node), marks, container))
|
||||
if (node.type === 'placeholder') return success([])
|
||||
if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container))
|
||||
if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container))
|
||||
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
|
||||
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(shownText(node), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
|
||||
if (node.type === 'syncBlock') return success(noteLeaves('synced block'))
|
||||
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container))
|
||||
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container))
|
||||
const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container)
|
||||
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(node, marks, container))
|
||||
const own = textLeaves(node.text ?? shownText(node), marks, container)
|
||||
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
|
||||
if (!held.ok) return held
|
||||
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
|
||||
}
|
||||
|
||||
function cardLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const data = attrs['data']
|
||||
const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
|
||||
const url = nonEmpty(attrs['url'])
|
||||
const heldUrl = nonEmpty(held['url'])
|
||||
const name = nonEmpty(held['name'])
|
||||
const href = url ?? heldUrl
|
||||
if (href === undefined) return textLeaves(name, marks, container, 'link card')
|
||||
return linkedLeaves(url ?? name ?? href, href, marks, container)
|
||||
function cardLeaves(card: AdfNode, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const href = cardUrl(card)
|
||||
if (href === undefined) return textLeaves(shownText(card), marks, container, 'link card')
|
||||
return linkedLeaves(shownText(card) ?? href, href, marks, container)
|
||||
}
|
||||
|
||||
function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container)
|
||||
}
|
||||
|
||||
function idMention(id: unknown): string | undefined {
|
||||
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
|
||||
}
|
||||
|
||||
// An image standing inline is a link to it: the reader builds no node for an inline image, reading it as its alt text linked, and inside a link as the link's text, then (image).
|
||||
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const alt = nonEmpty(attrs['alt'])
|
||||
const url = nonEmpty(attrs['url'])
|
||||
const alt = visibleText(attrs['alt'])
|
||||
const url = visibleText(attrs['url'])
|
||||
if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image')
|
||||
const link = marks.find((mark) => mark.type === 'link')
|
||||
const href = link === undefined ? undefined : nodeAttrs(link)['href']
|
||||
if (typeof href !== 'string') return linkedLeaves(alt ?? url, url, marks, container)
|
||||
if (href === url) return textLeaves(alt ?? url, marks, container)
|
||||
return [...textLeaves(alt ?? nonEmpty(href) ?? url, marks, container), ...imageAside((text) => linkedLeaves(text, url, marks, container))]
|
||||
return [...textLeaves(alt ?? visibleText(href) ?? url, marks, container), ...imageAside((text) => linkedLeaves(text, url, marks, container))]
|
||||
}
|
||||
|
||||
function noteLeaves(name: string): AdfNode[] {
|
||||
@@ -98,25 +87,13 @@ function noteLeaves(name: string): AdfNode[] {
|
||||
}
|
||||
|
||||
function noteName(value: unknown): string | undefined {
|
||||
return nonEmpty(value) === undefined ? undefined : oneLine(String(value)).trim()
|
||||
return visibleText(value) === undefined ? undefined : oneLine(String(value)).trim()
|
||||
}
|
||||
|
||||
export function oneLine(text: string): string {
|
||||
return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
function nonEmpty(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined
|
||||
}
|
||||
|
||||
function isoDate(timestamp: unknown): string | undefined {
|
||||
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
|
||||
const date = new Date(milliseconds)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
const iso = date.toISOString()
|
||||
return iso.slice(0, iso.indexOf('T'))
|
||||
}
|
||||
|
||||
function lineBreak(container: LineContainer): AdfNode {
|
||||
return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', [])
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mintTaskIds } from './task-ids.ts'
|
||||
import { parseInlineContent } from '../parse/inline-content.ts'
|
||||
import { pathTo, splitTaskList } from './task-list.ts'
|
||||
import { positioned, success } from '../../result.ts'
|
||||
import { withinEngineLimits } from '../length-bound.ts'
|
||||
import { withinEngineLimits } from '../../length-bound.ts'
|
||||
|
||||
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user