From e234905c2706e799a7dcfe6b736897fba7ca45a5 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 23 Oct 2026 14:51:15 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20architecture=20round=201:=202=20?= =?UTF-8?q?=F0=9F=94=B4,=206=20=F0=9F=9F=A1=20(0=20misread);=20one=20escap?= =?UTF-8?q?ing=20path=20refusing=20null=20characters,=20block=20writers=20?= =?UTF-8?q?returning=20their=20pieces,=20the=20HTML=20writer=20split,=20sh?= =?UTF-8?q?ared=20entry=20checks,=20lint=20between=20formats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .oxlintrc.json | 24 +- ci.sh | 22 +- docs/decisions.md | 5 +- spec/html.md | 10 +- src/adf/document.ts | 13 +- src/adf/mark-attributes.ts | 6 +- src/adf/{external-image.ts => plain-image.ts} | 15 +- src/conformance/adf-property.test.ts | 3 +- src/conformance/property-harness.ts | 6 +- src/html/emit/adf-to-html.test.ts | 31 +- src/html/emit/adf-to-html.ts | 280 +++++------------- src/html/emit/carry-element.ts | 34 +++ src/html/emit/escaping.ts | 23 ++ src/html/emit/inline.ts | 62 ++++ src/markdown/emit/adf-to-markdown.ts | 17 +- src/markdown/emit/image.ts | 6 +- src/markdown/mark-spellings.ts | 12 +- src/markdown/parse/inline-content.ts | 6 +- .../portable/adf-to-portable-markdown.ts | 18 +- 19 files changed, 324 insertions(+), 269 deletions(-) rename src/adf/{external-image.ts => plain-image.ts} (69%) create mode 100644 src/html/emit/carry-element.ts create mode 100644 src/html/emit/escaping.ts create mode 100644 src/html/emit/inline.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 48131ae..32f6278 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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" }] }] } } ], diff --git a/ci.sh b/ci.sh index 5639d85..c18296f 100755 --- a/ci.sh +++ b/ci.sh @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index e5eaeff..8df444e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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()`. diff --git a/spec/html.md b/spec/html.md index 71f564e..ab89c44 100644 --- a/spec/html.md +++ b/spec/html.md @@ -49,7 +49,9 @@ its scheme). A node the sections above cannot write where it stands is the carry element: `
` in block position, `` 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
Check the collation.
@@ -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. diff --git a/src/adf/document.ts b/src/adf/document.ts index d7e9223..544e653 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -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 { + 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` } diff --git a/src/adf/mark-attributes.ts b/src/adf/mark-attributes.ts index 9a53955..a3e9a41 100644 --- a/src/adf/mark-attributes.ts +++ b/src/adf/mark-attributes.ts @@ -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 } diff --git a/src/adf/external-image.ts b/src/adf/plain-image.ts similarity index 69% rename from src/adf/external-image.ts rename to src/adf/plain-image.ts index 0800b1b..0da3c72 100644 --- a/src/adf/external-image.ts +++ b/src/adf/plain-image.ts @@ -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 } diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 3b4d56e..bb2ff01 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -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), ) diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 2d87d53..1444964 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -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): Arbitrary { 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' }))), '
\n
    \n
      \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('

      html(adfToHtml(document({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }))).startsWith('

      ({ 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)) diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index 60a247e..31a21b9 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -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 { 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 { - 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 { - 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 { +function writeBlock(block: Block): Result { 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}>`] + return [`<${tag}${attributes}>\n`, ...blockPieces(node.content, path, depth + 1, isList), `\n`] +} + +// 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 { + if (node.content === undefined) return success(['
    1. ']) + 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(['
    2. \n', ...blocks, '\n
    3. ']) + 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 ? [`
    4. ${bare.value}
    5. `] : [`
    6. ${bare.value}`, ...blocks.slice(1), '\n
    7. ']) } function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result | 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 ? '
      ' : undefined) - return text === undefined ? undefined : success(text) + const html = tryCodeBlock(node) ?? tryImage(node) ?? (node.type === 'rule' && holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '
      ' : undefined) + return html === undefined ? undefined : success(html) } -function inlineElement(tag: string, attributes: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result { +function inlineElement(tag: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result { const inline = inlineHtml(nodes, path) - return inline.ok ? success(`<${tag}${attributes}>${inline.value}`) : inline + return inline.ok ? success(`<${tag}>${inline.value}`) : 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 `
      ` const [only, ...others] = node.content - if (only === undefined || others.length > 0 || !writableText(only) || !isUnmarkedSpellableText(only)) return undefined - return `
      ${escapeHtml(only.text)}
      ` + const code = only === undefined || others.length > 0 || !isUnmarkedSpellableText(only) ? undefined : writableText(only.text) + return code === undefined ? undefined : `
      ${code}
      ` } 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 = `${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 = `` if (image.link === undefined) return `

      ${element}

      ` const anchor = anchorOpener(image.link) return anchor === undefined ? undefined : `

      ${anchor}${element}

      ` } - -function containerBlock(pending: Pending[], { depth, node, path }: Block, tag: string, attributes: string): Result { - if (node.content === undefined) return success(`<${tag}${attributes}>`) - scheduleBlocks(pending, node.content, path, depth + 1, `<${tag}${attributes}>\n`, `\n`) - 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 { - const { depth, node, path } = block - if (node.content === undefined) return success(`<${tag}${attributes}>`) - 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`) - 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 { - if (depth > largestNesting) return tooDeep(path) - if (item.content === undefined) return success('
    8. ') - 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 ? '' : '\n') - if (!bare) return success('
    9. ') - const inline = inlineHtml(nodeContent(first), [...path, 'content', 0]) - return inline.ok ? success(`
    10. ${inline.value}`) : inline -} - -function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result { - 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 ? '
      ' : 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: ``, 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: '', opener } -} - -function anchorOpener({ href, title }: LinkTarget): string | undefined { - if (!writableUrl(href) || title?.includes('\0') === true) return undefined - return `` -} - -// 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 { - 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 { - 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', ''))}` -} - -// 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', ' ') -} diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts new file mode 100644 index 0000000..f4632a3 --- /dev/null +++ b/src/html/emit/carry-element.ts @@ -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 { + 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 { + 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)}` +} + +// 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 +} diff --git a/src/html/emit/escaping.ts b/src/html/emit/escaping.ts new file mode 100644 index 0000000..73c223e --- /dev/null +++ b/src/html/emit/escaping.ts @@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('\r', ' ') +} diff --git a/src/html/emit/inline.ts b/src/html/emit/inline.ts new file mode 100644 index 0000000..12fcfaa --- /dev/null +++ b/src/html/emit/inline.ts @@ -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 { + 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 ? '
      ' : 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: ``, 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: '
      ', opener } +} + +export function anchorOpener({ href, title }: LinkAttrs): string | undefined { + const url = htmlValue(href) + const titled = optionalAttribute('title', title) + return url === undefined || titled === undefined ? undefined : `` +} diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 2671ca7..5440e3e 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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 { } export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Result { - 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`) diff --git a/src/markdown/emit/image.ts b/src/markdown/emit/image.ts index 9bc8be8..de4e010 100644 --- a/src/markdown/emit/image.ts +++ b/src/markdown/emit/image.ts @@ -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)) } diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index ada6a69..36e982a 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -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 } } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index ed71fd6..20ef481 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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. diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index c4a255b..d7120cc 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -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 { } export function reduceToPortable(document: AdfDocument): Result { - 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 { 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)))])]) }