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

Answer architecture round 1: 2 🔴, 6 🟡 (0 misread); one escaping path refusing null characters, block writers returning their pieces, the HTML writer split, shared entry checks, lint between formats

This commit is contained in:
2026-10-23 14:51:15 +02:00
committed by Lillem4n
parent cc029b0bc9
commit e234905c27
19 changed files with 324 additions and 269 deletions
+21 -3
View File
@@ -3,22 +3,40 @@
"categories": { "correctness": "off" },
"ignorePatterns": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
"overrides": [
{
"files": ["src/adf/**"],
"rules": {
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/html/**", "**/markdown/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
}
},
{
"files": ["src/html/**"],
"rules": {
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/markdown/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
}
},
{
"files": ["src/markdown/**"],
"rules": {
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/html/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
}
},
{
"files": ["src/markdown/directive/**"],
"rules": {
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "**/parse/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "**/html/**", "**/parse/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
}
},
{
"files": ["src/markdown/emit/**"],
"rules": {
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/html/**", "**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
}
},
{
"files": ["src/markdown/parse/**"],
"rules": {
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "!../emit/spelling-questions.ts", "**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "!../emit/spelling-questions.ts", "**/html/**", "**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
}
}
],
+14 -8
View File
@@ -9,26 +9,32 @@ leg "lint ($node_image)" in_image "$node_image" npm run lint
# The boundaries pass by finding nothing, so probe that each still fires: oxlint bumps automerge on green.
boundaries_fire() {
local allowed=src/markdown/parse/boundary-probe-allowed.ts directive=src/markdown/directive/boundary-probe.ts directive_emit=src/markdown/directive/boundary-probe-emit.ts emit=src/markdown/emit/boundary-probe.ts lint_log parse=src/markdown/parse/boundary-probe.ts parse_portable=src/markdown/parse/boundary-probe-portable.ts
local adf=src/adf/boundary-probe.ts adf_html=src/adf/boundary-probe-html.ts allowed=src/markdown/parse/boundary-probe-allowed.ts directive=src/markdown/directive/boundary-probe.ts directive_emit=src/markdown/directive/boundary-probe-emit.ts directive_html=src/markdown/directive/boundary-probe-html.ts emit=src/markdown/emit/boundary-probe.ts emit_html=src/markdown/emit/boundary-probe-html.ts html=src/html/boundary-probe.ts lint_log markdown=src/markdown/boundary-probe-html.ts parse=src/markdown/parse/boundary-probe.ts parse_html=src/markdown/parse/boundary-probe-html.ts parse_portable=src/markdown/parse/boundary-probe-portable.ts
local probes="$adf $adf_html $allowed $directive $directive_emit $directive_html $emit $emit_html $html $markdown $parse $parse_html $parse_portable"
lint_log=$(mktemp) &&
# The paths are baked in: the trap fires after this function's locals are gone.
trap "rm -f $allowed $directive $directive_emit $emit $lint_log $parse $parse_portable" EXIT &&
trap "rm -f $probes $lint_log" EXIT &&
trap 'exit 130' INT &&
trap 'exit 143' TERM &&
echo "import '../emit/spelling-questions.ts';" > "$allowed" &&
in_image "$node_image" npm run lint &&
echo "import '../markdown/flavour.ts';" > "$adf" &&
echo "import '../html/emit/adf-to-html.ts';" > "$adf_html" &&
echo "import '../parse/blocks.ts';" > "$directive" &&
echo "import '../emit/image.ts';" > "$directive_emit" &&
echo "import '../../html/emit/adf-to-html.ts';" > "$directive_html" &&
echo "import '../portable/conventions.ts';" > "$emit" &&
echo "import '../../html/emit/adf-to-html.ts';" > "$emit_html" &&
echo "import '../markdown/flavour.ts';" > "$html" &&
echo "import '../html/emit/adf-to-html.ts';" > "$markdown" &&
echo "import '../emit/adf-to-markdown.ts';" > "$parse" &&
echo "import '../../html/emit/adf-to-html.ts';" > "$parse_html" &&
echo "import '../portable/conventions.ts';" > "$parse_portable" &&
! in_image "$node_image" npm run lint -- --format unix 2>&1 | tee "$lint_log" &&
grep -q "^$directive:.*eslint(no-restricted-imports)" "$lint_log" &&
grep -q "^$directive_emit:.*eslint(no-restricted-imports)" "$lint_log" &&
grep -q "^$emit:.*eslint(no-restricted-imports)" "$lint_log" &&
grep -q "^$parse:.*eslint(no-restricted-imports)" "$lint_log" &&
grep -q "^$parse_portable:.*eslint(no-restricted-imports)" "$lint_log" &&
rm -f "$allowed" "$directive" "$directive_emit" "$emit" "$lint_log" "$parse" "$parse_portable"
for probe in $probes; do
[ "$probe" = "$allowed" ] || grep -q "^$probe:.*eslint(no-restricted-imports)" "$lint_log" || return 1
done &&
rm -f $probes "$lint_log"
}
leg "import boundaries fire ($node_image)" boundaries_fire
+3 -2
View File
@@ -968,7 +968,8 @@ spelling the emitter refuses gives its own error rather than a second name for i
is the only import `parse/` takes from `emit/`, through `emit/spelling-questions.ts`.
`.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `parse/` and `emit/` from
importing `markdown/portable/`, and keeps `markdown/directive/` from importing `emit/` or `parse/`,
as `no-restricted-imports` overrides. They are lint rules because the technical principles refuse a
importing `markdown/portable/`, keeps `markdown/directive/` from importing `emit/` or `parse/`,
keeps `markdown/` and `html/` from importing each other, and keeps `adf/` from importing either, as
`no-restricted-imports` overrides. They are lint rules because the technical principles refuse a
test that scans the source, and a lint rule reads every import spelling: `from`, a bare `import`,
double quotes and a dynamic `import()`.
+7 -3
View File
@@ -49,7 +49,9 @@ its scheme).
A node the sections above cannot write where it stands is the carry element: `<div>` in block
position, `<span>` inline, of class `adf-carry`, holding the node's JSON in `data-adf` and showing
the node's text.
the node's text. A carry element may hold a node the sections above write, as the markdown carry
may: it reads back as the node it holds, so HTML stored while a node rode the carry reads the same
once a later release writes it as its own element.
```html
<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},…,&quot;type&quot;:&quot;panel&quot;}">Check the collation.</div>
@@ -68,8 +70,10 @@ the node's text.
- text holding no character, or a null character, which HTML cannot hold; text holding a mark
twice, or a mark with attributes besides a link's `href` and `title`;
- an unmarked text node straight after another, which would read back as one;
- a link or an image whose URL holds a null character;
- a code block holding more than one text node, a marked one, or a language holding whitespace;
- a link whose `href` or `title` holds a null character, and an image whose `url` or `alt` holds
one or whose link's `href` or `title` does;
- a code block holding more than one text node, a marked one, or a language holding whitespace
or a null character;
- a list holding a node that is no `listItem`, or an item holding attributes or marks: the whole
list rides the carry;
- a `listItem` outside a list, an inline node in block position and a block node inline.
+12 -1
View File
@@ -1,6 +1,7 @@
import type { ConvertFault } from '../result.ts'
import { failure, faulted, success, type ConvertFault, type Result } from '../result.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { longestOutput, tooLong } from '../length-bound.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export type AdfAttributes = { [key: string]: JsonValue }
@@ -48,6 +49,16 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
return nestingFault(content)
}
// The checks every writer opens with; `undefined` where the document holds no `content` key, which each format spells its own way.
export function writableContent(document: AdfDocument): Result<readonly AdfNode[] | undefined> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no format this library writes carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(undefined)
// Every character of text is written at least once, so this much text cannot fit, and none of it is written.
return heldTextLength(document.content) > longestOutput ? tooLong() : success(document.content)
}
export function attributeNestingMessage(key: string, type: string): string {
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
}
+3 -3
View File
@@ -28,14 +28,14 @@ export function isMarkType(type: string): type is MarkType {
return Object.hasOwn(markAttributes, type)
}
export type LinkTarget = { href: string; title?: string }
export type LinkAttrs = { href: string; title?: string }
export function linkTargetMark({ href, title }: LinkTarget): AdfMark {
export function linkMarkFrom({ href, title }: LinkAttrs): 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 {
export function linkAttrs(attrs: AdfAttributes): LinkAttrs | undefined {
const { href, title } = attrs
if (typeof href !== 'string') return undefined
if (title === undefined) return { href }
@@ -1,29 +1,28 @@
import type { AdfNode } from './document.ts'
import { linkTarget, linkTargetMark, type LinkTarget } from './mark-attributes.ts'
import { linkAttrs, linkMarkFrom, type LinkAttrs } 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 }
export type PlainImage = { alt: string | undefined; link: LinkAttrs | 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 {
export function externalMedia(alt: string, url: string, link?: LinkAttrs): AdfNode {
const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
return link === undefined ? media : { ...media, marks: [linkTargetMark(link)] }
return link === undefined ? media : { ...media, marks: [linkMarkFrom(link)] }
}
// `undefined` unless the node is exactly the plain image its parts build.
export function plainImage(node: AdfNode): ExternalImage | undefined {
// 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. `undefined` unless the node is exactly the plain image its parts build.
export function plainImage(node: AdfNode): PlainImage | 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 link = mark === undefined ? undefined : linkAttrs(nodeAttrs(mark))
const read = centeredImage([externalMedia(alt ?? '', url, link)])
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? { alt, link, url } : undefined
}
+2 -1
View File
@@ -56,11 +56,12 @@ test('a generated document writes portable markdown refusing only what the guard
)
})
test('a generated document writes HTML', { timeout: propertyTimeout }, () => {
test('a generated document writes HTML holding no null character', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const written = adfToHtml(document)
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
assert.ok(!written.value.includes('\0'), `writing ${JSON.stringify(document)}`)
}),
propertyRuns(gateRuns),
)
+3 -3
View File
@@ -10,8 +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 '../adf/external-image.ts'
import { definitionTarget } from '../markdown/mark-spellings.ts'
import { centeredImage, externalMedia } from '../adf/plain-image.ts'
import { definitionAttrs } 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'
@@ -96,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 === undefined ? undefined : definitionTarget(link))])
return centeredImage([externalMedia(alt ?? '', url, link === undefined ? undefined : definitionAttrs(link))])
}
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
+24 -7
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { Result } from '../../result.ts'
import { adfToHtml } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
@@ -97,10 +97,12 @@ test('carries a node holding what its element cannot', () => {
assert.equal(carried({ content: [], type: 'paragraph' }), true)
assert.equal(carried({ attrs: {}, type: 'rule' }), true)
assert.equal(carried({ attrs: { level: 7 }, content: [text('x')], type: 'heading' }), true)
assert.equal(carried({ attrs: { localId: 'q1' }, content: [paragraph(text('x'))], type: 'blockquote' }), true)
assert.equal(carried({ attrs: { order: 1.5 }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true)
assert.equal(carried({ content: [item(paragraph(text('x'))), paragraph(text('y'))], type: 'bulletList' }), true)
assert.equal(carried({ content: [], type: 'bulletList' }), true)
assert.equal(carried({ attrs: { language: 'c sharp' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carried({ attrs: { language: 'a\0b' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('a'), text('b')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('x\0')], type: 'codeBlock' }), true)
assert.equal(carried(item(paragraph(text('x')))), true)
@@ -135,16 +137,20 @@ 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', () => {
test('carries a link or image holding a value its element cannot', () => {
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: 'https://example.com/\0' }, type: 'link' }], type: 'media' }], type: 'mediaSingle' }
assert.equal(html(adfToHtml(document(image))).startsWith('<div class="adf-carry"'), true)
const imageCarried = (media: AdfNode): boolean => html(adfToHtml(document({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }))).startsWith('<div class="adf-carry"')
const linked = (attrs: AdfAttributes): AdfNode => ({ attrs: { type: 'external', url: 'https://example.com/a.png' }, marks: [{ attrs, type: 'link' }], type: 'media' })
assert.equal(imageCarried(linked({ href: 'https://example.com/\0' })), true)
assert.equal(imageCarried(linked({ href: 'https://example.com/', title: 'x\0' })), true)
assert.equal(imageCarried({ attrs: { type: 'external', url: 'https://example.com/\0.png' }, type: 'media' }), true)
assert.equal(imageCarried({ attrs: { alt: 'The\0moon', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }), true)
})
test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading and a list item', () => {
test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading and a list item, the first in document order', () => {
let deep: AdfNode = text('x')
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'unknownInline' }
const refused = (block: AdfNode): string => {
@@ -154,11 +160,22 @@ test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading
assert.equal(refused(paragraph(deep)), 'unsupported-nesting-depth at content/0/content/0')
assert.equal(refused({ attrs: { level: 1 }, content: [deep], type: 'heading' }), 'unsupported-nesting-depth at content/0/content/0')
assert.equal(refused({ content: [item(paragraph(deep))], type: 'bulletList' }), 'unsupported-nesting-depth at content/0/content/0/content/0/content/0')
assert.equal(refused({ content: [item(paragraph(text('a')), paragraph(deep)), item(paragraph(deep))], type: 'bulletList' }), 'unsupported-nesting-depth at content/0/content/0/content/1/content/0')
})
const tooLong = 'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes'
test('refuses text past V8\'s longest string', () => {
const long = 'a'.repeat(2 ** 28)
assert.equal(html(adfToHtml(document(paragraph(text(long), text(long, { type: 'em' }), text(long))))), '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')
assert.equal(html(adfToHtml(document(paragraph(text(long), text(long, { type: 'em' }), text(long))))), tooLong)
})
test('refuses at the block whose HTML passes V8\'s longest string, before a later block refuses', () => {
// The two code blocks' text is the bound exactly, so only what their elements add passes it.
const code: AdfNode = { content: [text('a'.repeat(536870888 / 2))], type: 'codeBlock' }
let deep: AdfNode = { type: 'rule' }
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'blockquote' }
assert.equal(html(adfToHtml(document(code, code, deep))), tooLong)
})
test('writes an empty document as nothing, and one holding no content key as its carry', () => {
@@ -168,7 +185,7 @@ test('writes an empty document as nothing, and one holding no content key as its
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')
assert.equal(html(adfToHtml({ type: 'doc', version: 2 })), 'unsupported-document-version: no format this library writes carries ADF version 2')
let deep: AdfNode = paragraph(text('x'))
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'blockquote' }
const refused = adfToHtml(document(deep))
+82 -198
View File
@@ -1,88 +1,107 @@
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 type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { anchorOpener, inlineHtml, writableText } from './inline.ts'
import { carriedBlock, carryElement } from './carry-element.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
import { largestNesting } from '../../nesting.ts'
import { linkTarget, type LinkTarget } from '../../adf/mark-attributes.ts'
import { plainImage } from '../../adf/external-image.ts'
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { htmlValue, optionalAttribute } from './escaping.ts'
import { plainImage } from '../../adf/plain-image.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { 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 }
// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; inList: the node is a list's item.
type Block = { depth: number; inList: boolean; node: AdfNode; path: ConvertErrorPath }
type Container = { attributes: string; isList: boolean; tag: string }
type Pending = Block | string
// Each names its element.
const elementMarks: readonly string[] = ['code', 'em', 'strong']
export function adfToHtml(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeHtml(document))
}
// A stack rather than recursion, since each level would cost several frames: a block returns its pieces in document order, and only this loop pushes them.
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[] = []
const content = writableContent(document)
if (!content.ok) return content
if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`)
const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0, false), '\n'].reverse()
let html = ''
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === 'string') {
pieces.push(next)
// Held to the bound as it grows, so every engine stops at the block passing it.
if (html.length + next.length > longestOutput) return tooLong()
html += next
continue
}
const written = writeBlock(next, pending)
if (!written.ok) return written
if (written.value !== undefined) pieces.push(written.value)
const pieces = writeBlock(next)
if (!pieces.ok) return pieces
for (const piece of pieces.value.reverse()) pending.push(piece)
}
return joinedWithin(pieces)
return success(html)
}
// 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]
function blockPieces(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, inList: boolean): Pending[] {
const pieces: Pending[] = []
for (const [index, node] of nodes.entries()) {
if (index > 0) scheduled.push('\n')
scheduled.push({ depth, node, path: [...path, 'content', index] })
if (index > 0) pieces.push('\n')
pieces.push({ depth, inList, node, path: [...path, 'content', index] })
}
scheduled.push(closer)
for (const item of scheduled.reverse()) pending.push(item)
return pieces
}
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> {
function writeBlock(block: Block): Result<Pending[]> {
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)
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, path)
if (block.inList) return writeItem(block)
const container = containerElement(node)
if (container !== undefined) return success(containerPieces(block, container))
const html = tryLeafBlock(node, path) ?? carriedBlock(node, path, depth)
return html.ok ? success([html.value]) : html
}
// `undefined` where the node is no container, or it or a list's item holds what the elements cannot, so the whole node rides the carry.
function containerElement(node: AdfNode): Container | undefined {
if (node.type === 'blockquote') return holdsOnlyAttributes(node, []) ? { attributes: '', isList: false, tag: 'blockquote' } : undefined
if (node.type === 'bulletList') return holdsOnlyAttributes(node, []) && listItemsWritable(node) ? { attributes: '', isList: true, tag: 'ul' } : undefined
if (node.type !== 'orderedList' || !holdsOnlyAttributes(node, ['order']) || !listItemsWritable(node)) return undefined
const order = nodeAttrs(node)['order']
if (order === undefined) return { attributes: '', isList: true, tag: 'ol' }
return typeof order === 'number' && Number.isSafeInteger(order) && !Object.is(order, -0) ? { attributes: ` start="${order}"`, isList: true, tag: 'ol' } : undefined
}
function listItemsWritable(list: AdfNode): boolean {
return nodeContent(list).every((item) => item.type === 'listItem' && holdsOnlyAttributes(item, []))
}
function containerPieces({ depth, node, path }: Block, { attributes, isList, tag }: Container): Pending[] {
if (node.content === undefined) return [`<${tag}${attributes}></${tag}>`]
return [`<${tag}${attributes}>\n`, ...blockPieces(node.content, path, depth + 1, isList), `\n</${tag}>`]
}
// An item's blocks sit at its own level, one below its list, as a markdown list's do; its first paragraph stands bare where it holds content, panel pick, docs/decisions.md §The HTML dialect.
function writeItem({ depth, node, path }: Block): Result<Pending[]> {
if (node.content === undefined) return success(['<li></li>'])
const blocks = blockPieces(node.content, path, depth, false)
const [first] = node.content
if (first === undefined || first.type !== 'paragraph' || !holdsOnlyAttributes(first, []) || nodeContent(first).length === 0) return success(['<li>\n', ...blocks, '\n</li>'])
const bare = inlineHtml(nodeContent(first), [...path, 'content', 0])
if (!bare.ok) return bare
// The bare paragraph's inline content stands in for its block.
return success(blocks.length === 1 ? [`<li>${bare.value}</li>`] : [`<li>${bare.value}`, ...blocks.slice(1), '\n</li>'])
}
function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
if (node.type === 'paragraph' && holdsOnlyAttributes(node, [])) return inlineElement('p', '', nodeContent(node), path)
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)
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)
const html = tryCodeBlock(node) ?? tryImage(node) ?? (node.type === 'rule' && holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '<hr>' : undefined)
return html === undefined ? undefined : success(html)
}
function inlineElement(tag: string, attributes: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
function inlineElement(tag: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
const inline = inlineHtml(nodes, path)
return inline.ok ? success(`<${tag}${attributes}>${inline.value}</${tag}>`) : inline
return inline.ok ? success(`<${tag}>${inline.value}</${tag}>`) : inline
}
// A class holds the language as one token, so a language holding whitespace rides the carry.
@@ -90,157 +109,22 @@ 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}`)}"`
const className = optionalAttribute('class', language === undefined ? undefined : `language-${language}`)
if (className === undefined) return undefined
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>`
const code = only === undefined || others.length > 0 || !isUnmarkedSpellableText(only) ? undefined : writableText(only.text)
return code === undefined ? undefined : `<pre><code${className}>${code}</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 === undefined) return undefined
const src = htmlValue(image.url)
const alt = optionalAttribute('alt', image.alt)
if (src === undefined || alt === undefined) return undefined
const element = `<img src="${src}"${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')
}
// Any scheme, `javascript:` included, as the ADF holds it: docs/decisions.md §HTML writes a URL as the ADF holds it, whatever its scheme.
function writableUrl(url: string): boolean {
return !url.includes('\0')
}
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;')
}
+34
View File
@@ -0,0 +1,34 @@
import type { AdfNode } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { carriedJson } from '../../adf/carried-json.ts'
import { carriedValue } from './escaping.ts'
import { largestNesting } from '../../nesting.ts'
import { nodeContent } from '../../adf/document.ts'
import { shownText } from '../../adf/shown-text.ts'
import { success, type ConvertErrorPath, type Result } from '../../result.ts'
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting - depth)
return json.ok ? success(carryElement('div', json.value.json, carriedText(node))) : json
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting)
return json.ok ? success(carryElement('span', json.value.json, carriedText(node))) : json
}
export function carryElement(tag: string, json: string, text: string): string {
return `<${tag} class="adf-carry" data-adf="${carriedValue(json)}">${carriedValue(text)}</${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
}
+23
View File
@@ -0,0 +1,23 @@
// An HTML parser reads a null character as U+FFFD, so each function here refuses or drops one, and the writer escapes through nothing else.
// `undefined` where the value holds a null character, so its node rides the carry element.
export function htmlValue(value: string): string | undefined {
return value.includes('\0') ? undefined : escapeHtml(value)
}
// ` name="value"`, `''` where the value is undefined, `undefined` where it holds a null character.
export function optionalAttribute(name: string, value: string | undefined): string | undefined {
if (value === undefined) return ''
const escaped = htmlValue(value)
return escaped === undefined ? undefined : ` ${name}="${escaped}"`
}
// What the carry element holds: its JSON spells a null character `\u0000`, and the text it shows drops one.
export function carriedValue(value: string): string {
return escapeHtml(value.replaceAll('\0', ''))
}
// 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;')
}
+62
View File
@@ -0,0 +1,62 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import { carriedInline } from './carry-element.ts'
import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { htmlValue, optionalAttribute } from './escaping.ts'
import { linkAttrs, type LinkAttrs } from '../../adf/mark-attributes.ts'
import { success, type ConvertErrorPath, type Result } from '../../result.ts'
// Each names its element.
const elementMarks: readonly string[] = ['code', 'em', 'strong']
export 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
const text = isSpellableText(node) ? writableText(node.text) : undefined
if (text === undefined) 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('')}${text}${closers.join('')}`
}
// No element holds empty text apart from no text.
export function writableText(text: string | undefined): string | undefined {
return text === undefined || text === '' ? undefined : htmlValue(text)
}
function markElement(mark: AdfMark): { closer: string; opener: string } | undefined {
const { type } = mark
if (elementMarks.includes(type)) return mark.attrs === undefined ? { closer: `</${type}>`, opener: `<${type}>` } : undefined
if (type !== 'link' || Object.keys(nodeAttrs(mark)).some((key) => key !== 'href' && key !== 'title')) return undefined
const link = linkAttrs(nodeAttrs(mark))
const opener = link === undefined ? undefined : anchorOpener(link)
return opener === undefined ? undefined : { closer: '</a>', opener }
}
export function anchorOpener({ href, title }: LinkAttrs): string | undefined {
const url = htmlValue(href)
const titled = optionalAttribute('title', title)
return url === undefined || titled === undefined ? undefined : `<a href="${url}"${titled}>`
}
+7 -10
View File
@@ -1,22 +1,22 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { adfDocumentFault, heldTextLength, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { blockDirectiveForm, documentSpelling } from '../directive/block.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { flavourClaims } from '../flavour.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.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'
import { tryPipeTable } from './pipe-table.ts'
import { withinEngineLimits, withinLongest } from '../../length-bound.ts'
type BlockContainer = 'directive' | 'document' | 'list-item'
type Delimiters = readonly [string, string]
@@ -50,13 +50,10 @@ export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
}
export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Result<string> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
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) > longestOutput) return tooLong()
const walk = walkBlocks(document.content, [], 0, { ...writtenAs, memo: undefined })
const content = writableContent(document)
if (!content.ok) return content
if (content.value === undefined) return success(`${documentSpelling}\n`)
const walk = walkBlocks(content.value, [], 0, { ...writtenAs, memo: undefined })
if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document')
return withinLongest(text === '' ? '' : `${text}\n`)
+3 -3
View File
@@ -1,12 +1,12 @@
import type { AdfNode } from '../../adf/document.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { plainImage } from '../../adf/external-image.ts'
import { targetDefinition } from '../mark-spellings.ts'
import { attrsDefinition } from '../mark-spellings.ts'
import { plainImage } from '../../adf/plain-image.ts'
import { tryImageLine } from './inline-line.ts'
export function tryImage(node: AdfNode, flavour: WrittenFlavour): string | 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))
return tryImageLine(alt, url, flavour, link === undefined ? undefined : attrsDefinition(link))
}
+6 -6
View File
@@ -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, linkTarget, linkTargetMark, markAttributes, type LinkTarget } from '../adf/mark-attributes.ts'
import { isMarkType, linkAttrs, linkMarkFrom, markAttributes, type LinkAttrs } from '../adf/mark-attributes.ts'
import { spellEmptyKeys } from './directive/empty-keys.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
@@ -38,19 +38,19 @@ export function markSpelling(type: string): MarkSpelling | undefined {
}
export function linkMark(definition: LinkDefinition): AdfMark {
return linkTargetMark(definitionTarget(definition))
return linkMarkFrom(definitionAttrs(definition))
}
export function definitionTarget({ destination, title }: LinkDefinition): LinkTarget {
export function definitionAttrs({ destination, title }: LinkDefinition): LinkAttrs {
return title === undefined ? { href: destination } : { href: destination, title }
}
export function linkDefinition(attrs: AdfAttributes): LinkDefinition | undefined {
const target = linkTarget(attrs)
return target === undefined ? undefined : targetDefinition(target)
const target = linkAttrs(attrs)
return target === undefined ? undefined : attrsDefinition(target)
}
export function targetDefinition({ href, title }: LinkTarget): LinkDefinition {
export function attrsDefinition({ href, title }: LinkAttrs): LinkDefinition {
return title === undefined ? { destination: href } : { destination: href, title }
}
+3 -3
View File
@@ -5,9 +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 } from '../../adf/external-image.ts'
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
import { imageAside } from '../image-aside.ts'
import { commonMarkLink, definitionTarget, linkDefinition, linkHref, linkMark } from '../mark-spellings.ts'
import { commonMarkLink, definitionAttrs, 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'
@@ -319,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 === undefined ? undefined : definitionTarget(link)), ...caption])
return centeredImage([externalMedia(alt, definition.destination, link === undefined ? undefined : definitionAttrs(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.
@@ -1,20 +1,20 @@
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 '../../adf/external-image.ts'
import { centeredImage, externalMedia } from '../../adf/plain-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 { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { inlineLeaves, isBlockNodeType, oneLine, portableLink, reduceInline, writableHref } from './inline-reduction.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { linkTarget } from '../../adf/mark-attributes.ts'
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { linkAttrs } from '../../adf/mark-attributes.ts'
import { nodeAttrs, nodeContent, nodeMarks, writableContent } from '../../adf/document.ts'
import { portableWriting } from './block-spelling.ts'
import { splitTaskList } from './task-list.ts'
import { taskMarker } from './conventions.ts'
import { toEditorNormal } from './editor-normal.ts'
import { withinEngineLimits } from '../../length-bound.ts'
// depth: the level the node reduced stands at, counted as the emitter counts it.
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
@@ -59,11 +59,9 @@ export function adfToPortableMarkdown(document: AdfDocument): Result<string> {
}
export function reduceToPortable(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document)
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)) > longestOutput) return tooLong()
const checked = writableContent(document)
if (!checked.ok) return checked
// 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 +365,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 : linkTarget(nodeAttrs(link)))])])
return success([centeredImage([externalMedia(alt, writableHref(url), link === undefined ? undefined : linkAttrs(nodeAttrs(link)))])])
}