diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc8ad9..ba4fb00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,11 @@ ## Unreleased - `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, code blocks, rules, hard - breaks, external images, and `em`, `strong`, `code` and links as their elements. Every other node - is an element of class `adf-carry` holding the node's JSON in `data-adf` and showing its text. + breaks, external images, and `em`, `strong`, `code` and links as their elements, an attribute such + as `localId` as `data-local-id`. Every other node is an element of class `adf-carry` holding the + node's JSON in `data-adf` and showing its content, links included. A link's URL is written as the + ADF holds it, `javascript:` included: sanitize HTML from an author you do not trust before showing + it. - **Breaking:** `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status` or `mediaInline` only the marks Atlassian's schema lets it hold: `**!adf:mention[@A]{id=…}**` now reads as a plain mention. A link or directive mark around only nodes that cannot take it is diff --git a/README.md b/README.md index e3ecf1a..ec8107d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ products store as ADF. drops the rest. - **HTML out:** `adfToHtml` writes paragraphs, headings, lists, quotes, code blocks, images and links as their HTML elements, and every other node as an element holding its JSON and showing its - text. + content. - **No throws:** every call returns a result; a refusal carries a code from a closed list. - **Runs anywhere:** pure functions with no runtime dependencies, in Node, Deno, Bun and browsers. TypeScript types ship beside the JavaScript. @@ -75,11 +75,30 @@ if (portable.ok) console.log(portable.value) Full reference: [HTML](#html). ```ts -import { adfToHtml } from '@larvit/adf-codec' +import { adfToHtml, type AdfDocument } from '@larvit/adf-codec' -const html = adfToHtml(doc) // the panel above +const page: AdfDocument = { + content: [ + { attrs: { level: 2, localId: 'a1f3' }, content: [{ text: 'Release notes', type: 'text' }], type: 'heading' }, + { + content: [ + { text: 'Read the ', type: 'text' }, + { marks: [{ attrs: { href: 'https://spec.commonmark.org/' }, type: 'link' }], text: 'spec', type: 'text' }, + { text: ', then ask ', type: 'text' }, + { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }, + ], + type: 'paragraph', + }, + ], + type: 'doc', + version: 1, +} + +// URLs are written as the ADF holds them, javascript: included: sanitize HTML from an author you do not trust before showing it. +const html = adfToHtml(page) if (html.ok) console.log(html.value) -//
Check the collation before importing.
+//

Release notes

+//

Read the spec, then ask @Mikael

``` ### Markdown to ADF @@ -447,9 +466,9 @@ saving what this pair read replaces mentions, attachments and macros with text. ## HTML Serves [Goals](#goals) 1, 6 and 7. `adfToHtml` writes the HTML element for each node HTML has one -for, and keeps every other node whole: an element of class `adf-carry` holds the node's JSON in -`data-adf` and shows the node's text, so a reader of the page loses no content. No stylesheet -ships. The complete dialect: +for, an attribute HTML has none for as `data-*`, and keeps every other node whole: an element of +class `adf-carry` holds the node's JSON in `data-adf` and shows the node's content, its links +included, so a reader of the page loses no content. No stylesheet ships. The complete dialect: [`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md). | ADF | HTML | @@ -464,11 +483,14 @@ ships. The complete dialect: | `paragraph` | `

` | | `rule` | `


` | | `text` marked `code`, `em`, `strong` | ``, ``, ``, the first mark outermost | -| any other node, or a node holding what its element cannot | `
its text
`, a `` inline | +| any other attribute the node's type holds | `data-*` named as HTML's `dataset` names it: `

` | +| any other node, or a node holding what its element cannot | `

its content

`, a `` inline | -- Text and attribute values are escaped: `&`, `<`, `>`, `"` and a carriage return. -- The carry element shows a mention, status or emoji by its text, a date as `2026-09-13` in UTC, a - card by its URL, an image by its `alt`, else its URL, and an expand by its title. +- Text and attribute values are escaped: `&`, `<`, `>` and `"`. A node holding what HTML cannot + hold, such as a carriage return or a control character, rides the carry element. +- The carry element shows each block's content as a `

`, text a link marks as a link, a mention, + status or emoji by its text, a date as `2026-09-13` in UTC, a card as a link to its URL, an image as + a link to it, and an expand by its title. What it cannot show leaves a note, `(image)`. - A link's or image's URL is written as the ADF holds it, `javascript:` included: sanitize the HTML before showing a document from an author you do not trust. diff --git a/browser-tests/convert-corpus.js b/browser-tests/convert-corpus.js index c9274c2..ffc5493 100644 --- a/browser-tests/convert-corpus.js +++ b/browser-tests/convert-corpus.js @@ -1,5 +1,5 @@ try { - const { adfToLosslessMarkdown, isAdfDocument, losslessMarkdownToAdf } = await import('/dist/index.js') + const { adfToHtml, adfToLosslessMarkdown, isAdfDocument, losslessMarkdownToAdf } = await import('/dist/index.js') // WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it. const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result) window.convertCorpus = (corpus) => ({ @@ -8,7 +8,7 @@ try { realPayloads: corpus.realPayloads.map(({ json, name }) => { const adf = JSON.parse(json) const emitted = adfToLosslessMarkdown(adf) - return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined } + return { emitted, html: adfToHtml(adf), isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined } }), roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => { const adf = JSON.parse(json) diff --git a/browser-tests/run.js b/browser-tests/run.js index b0652df..ac9d6f1 100644 --- a/browser-tests/run.js +++ b/browser-tests/run.js @@ -3,6 +3,8 @@ import { createServer } from 'node:http' import { extname, join } from 'node:path' import { readFileSync, readdirSync } from 'node:fs' +import { adfToHtml } from '../dist/index.js' + const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' } const driver = 'http://127.0.0.1:4444' const repoRoot = join(import.meta.dirname, '..') @@ -130,6 +132,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) { assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`) assert.deepEqual(revived(result.parsed.value), JSON.parse(json)) + assert.deepEqual(result.html, adfToHtml(JSON.parse(json)), 'it wrote other HTML than Node wrote') }) } diff --git a/ci.sh b/ci.sh index c18296f..7a16935 100755 --- a/ci.sh +++ b/ci.sh @@ -7,34 +7,38 @@ leg "install ($node_image)" in_image "$node_image" npm ci leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck 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. +# The boundaries pass by finding nothing, so probe that each still fires: oxlint bumps automerge on green. Each line is a probe and the import a boundary refuses it. +boundary_probes="src/adf/boundary-probe.ts ../markdown/flavour.ts +src/adf/boundary-probe-html.ts ../html/emit/adf-to-html.ts +src/html/boundary-probe.ts ../markdown/flavour.ts +src/markdown/boundary-probe-html.ts ../html/emit/adf-to-html.ts +src/markdown/directive/boundary-probe.ts ../parse/blocks.ts +src/markdown/directive/boundary-probe-emit.ts ../emit/image.ts +src/markdown/directive/boundary-probe-html.ts ../../html/emit/adf-to-html.ts +src/markdown/emit/boundary-probe.ts ../portable/conventions.ts +src/markdown/emit/boundary-probe-html.ts ../../html/emit/adf-to-html.ts +src/markdown/parse/boundary-probe.ts ../emit/adf-to-markdown.ts +src/markdown/parse/boundary-probe-html.ts ../../html/emit/adf-to-html.ts +src/markdown/parse/boundary-probe-portable.ts ../portable/conventions.ts" + boundaries_fire() { - 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" + local allowed=src/markdown/parse/boundary-probe-allowed.ts lint_log probe probes target + probes=$(cut -d ' ' -f 1 <<< "$boundary_probes" | tr '\n' ' ') && lint_log=$(mktemp) && # The paths are baked in: the trap fires after this function's locals are gone. - trap "rm -f $probes $lint_log" EXIT && + trap "rm -f $allowed $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" && + while read -r probe target; do + echo "import '$target';" > "$probe" || return 1 + done <<< "$boundary_probes" && ! in_image "$node_image" npm run lint -- --format unix 2>&1 | tee "$lint_log" && for probe in $probes; do - [ "$probe" = "$allowed" ] || grep -q "^$probe:.*eslint(no-restricted-imports)" "$lint_log" || return 1 + grep -q "^$probe:.*eslint(no-restricted-imports)" "$lint_log" || return 1 done && - rm -f $probes "$lint_log" + rm -f "$allowed" $probes "$lint_log" } leg "import boundaries fire ($node_image)" boundaries_fire diff --git a/docs/decisions.md b/docs/decisions.md index 8df444e..00a73b7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -405,6 +405,13 @@ bare ``, a list item's first paragraph bare, `

  • Bolt
  • `, over `
  • picked `data-adf` 6 of 7 over `data-adf-node`. Four of them wanted the class `adf-carry` and three no class; this entry's stable `adf-*` classes settle it. +A second panel of the same three roles picked, 3 of 3: an attribute the element cannot express as +`data-local-id` over `data-adf-local-id` and `id`, since Atlassian's editor puts a `localId` on every +block and a block carried for it would show no element at all; inside a carry element a link as +`` over `text (url)` and over its text alone, a carried node's blocks as `

    ` over `
    ` and a +line feed, and media it cannot show as `(image)` over `(image not shown)` and +nothing. + ## No runtime dependencies 2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each @@ -970,6 +977,7 @@ is the only import `parse/` takes from `emit/`, through `emit/spelling-questions `.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `parse/` and `emit/` from 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 +`no-restricted-imports` overrides. A later override replaces an earlier one's rule, so each +`markdown/` subdirectory's override repeats the ban on `html/`, and `ci.sh` probes every override. 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 ab89c44..26cc4f2 100644 --- a/spec/html.md +++ b/spec/html.md @@ -15,8 +15,8 @@ document whose `content` is an empty array is the empty string. | `heading`, `level` 1 to 6 | `

    …

    ` to `
    …
    ` | | `blockquote` | `
    `, a line feed, its blocks, a line feed, `
    ` | | `bulletList` | `
      `, a line feed, its items a line each, a line feed, `
    ` | -| `orderedList` | as `bulletList` with `
      `; `
        ` for an `order` of 3, a safe integer; no `start` where the list holds no `order` | -| `listItem` | `
      1. …
      2. `: a first paragraph holding content bare, its inline content straight after `
      3. `, then each other block after a line feed and a line feed before `
      4. `; with no bare paragraph, `
      5. `, a line feed, its blocks, a line feed, `
      6. ` | +| `orderedList` | as `bulletList` with `
          `; `
            ` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` | +| `listItem` | `
          1. …
          2. `: a first paragraph holding content and no attribute bare, its inline content straight after `
          3. `, then each other block after a line feed and a line feed before `
          4. `; with no bare paragraph, `
          5. `, a line feed, its blocks, a line feed, `
          6. ` | | `codeBlock` | `
            …
            `, the language one token holding no whitespace; no `class` where it holds no `language` | | `rule` | `
            ` | | `mediaSingle`, centered, holding one external `media` | `

            …

            `, no `alt` where the media holds none; `

            ` where a link marks it | @@ -24,6 +24,12 @@ document whose `content` is an empty array is the empty string. A container holding no `content` key is its element holding nothing: `
            `, `
              `, `
            • `; so is a paragraph or heading, `

              `. +Every other attribute its type holds (`spec/flavour.md` §Block nodes) is a `data-*` attribute on the +element, named as HTML's `dataset` names it, `localId` as `data-local-id`, after the element's own +attributes and sorted by name. A string is its value; any other value is its JSON, compact: +`
                `, `
                `. A code block's
                +`data-*` attributes stand on its `
                `.
                +
                 ## Inline
                 
                 | ADF | HTML |
                @@ -39,43 +45,58 @@ marks are two `` elements.
                 
                 ## Escaping
                 
                -Text and attribute values escape `&`, `<`, `>` and `"` as `&`, `<`, `>` and `"`,
                -and a carriage return as `
`, which an HTML parser would otherwise read as a line feed.
                +Text and attribute values escape `&`, `<`, `>` and `"` as `&`, `<`, `>` and `"`.
                 Attribute values are double-quoted. A link's `href` and an image's `src` are the URL as the ADF
                 holds it, whatever its scheme (`docs/decisions.md` §HTML writes a URL as the ADF holds it, whatever
                 its scheme).
                 
                +HTML text and attribute values cannot hold a carriage return, which a parser reads as a line feed,
                +a control other than a tab, line feed or form feed, a noncharacter, or a lone surrogate, which
                +UTF-8 cannot encode (WHATWG HTML §13.1.3). A node holding one in a value its element writes rides
                +the carry.
                +
                 ## The carry element
                 
                 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. 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. +its content. 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.
                +

                Read the CommonMark spec first.

                Then import.

                Ask @Mikael

                ``` - `data-adf` holds the node's JSON compact, keys sorted, as `!adf:carry` does in markdown, its type - included. A document holding no `content` key is the block carry of `{"type":"doc","version":1}`. -- The text shown is every text node's text below the node, and what a node holding no text shows + included, each character HTML cannot hold spelled as a JSON escape, `\u0085`. A document holding + no `content` key is the block carry of `{"type":"doc","version":1}`. +- The content shown is every text node's text below the node, and what a node holding no text shows in its place: a mention's text, else `@` and its id; an emoji's text, else its `shortName`; a status's text; a date as `2026-09-13` in UTC; a card's URL, else its name, else the URL its data - holds; media's `alt`, else an external one's URL; an extension's text; an expand's title. A line - feed parts two blocks and stands for a hard break. Null characters are dropped. -- A node rides the carry where it holds an attribute, mark or key its element does not write, an - empty `attrs`, `content` or `marks`, or a value its element cannot hold: - - 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`; + holds; media's `alt`, else an external one's URL; an extension's text; an expand's title. + - In a `
                `, each block's content is a `

                `; in a ``, `
                ` parts two blocks'. + - A hard break and a line feed in text are `
                `. + - Text a link marks is `` around it, and a card or an external image is a + link to its URL. + - A node referencing what it cannot show is a note naming it, `(image)`: `image` for + media with no `alt` or URL, `link card` for a card with neither URL nor name, an extension's + key, else `extension`, for an extension with no text, and `synced block` for a `syncBlock`. + - Characters HTML cannot hold are dropped. +- A node rides the carry where it holds a mark or a key its element does not write, an attribute + its type does not hold as that kind, an empty `attrs`, `content` or `marks`, or a value its + element cannot hold: + - text holding no character, or a character 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 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 link whose `href` or `title` holds a character HTML cannot hold, and an image whose `url` or + `alt` holds one or whose link's `href` or `title` does; + - a heading whose `level` is not 1 to 6, and an ordered list whose `order` is not a safe integer + or is `-0`; + - a code block holding more than one text node, a marked one, or a language holding whitespace; + - a rule or hard break holding `content`; + - a list holding a node that is no `listItem`, or an item holding a mark, text, an empty key or + an attribute its type does not hold: the whole list rides the carry; - a `listItem` outside a list, an inline node in block position and a block node inline. - A block carry's JSON nests at most 500 levels less the depth it stands at, an inline carry's at most 500, as in markdown. diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 9e986f8..f262684 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -2,8 +2,9 @@ import assert from 'node:assert/strict' import test from 'node:test' import vm from 'node:vm' +import type { AdfDocument } from './document.ts' import type { JsonValue } from '../json-value.ts' -import { adfDocumentFault, isAdfDocument } from './document.ts' +import { adfDocumentFault, isAdfDocument, writableContent } from './document.ts' import { largestNesting } from '../nesting.ts' function fault(value: unknown): string { @@ -114,3 +115,17 @@ test('refuses an object holding itself, and accepts one shared', () => { const values = { b: 1 } assert.equal(fault({ content: [shared, shared, { attrs: { a: [values, values] }, type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted') }) + +test('opens every writer with the same checks: a document, version 1, and no more text than the longest output holds', () => { + const checked = (document: AdfDocument): string => { + const result = writableContent(document) + return result.ok ? JSON.stringify(result.value) ?? 'no content key' : result.error.code + } + assert.equal(checked({ type: 'doc', version: Number.NaN }), 'not-an-adf-document') + assert.equal(checked({ type: 'doc', version: 2 }), 'unsupported-document-version') + assert.equal(checked({ type: 'doc', version: 1 }), 'no content key') + assert.equal(checked({ content: [{ type: 'rule' }], type: 'doc', version: 1 }), '[{"type":"rule"}]') + const long = 'a'.repeat(2 ** 28) + const text = { content: [{ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' }], type: 'paragraph' } + assert.equal(checked({ content: [text], type: 'doc', version: 1 }), 'unsupported-node-shape') +}) diff --git a/src/adf/shown-text.ts b/src/adf/shown-text.ts index 143f3f6..4ea0340 100644 --- a/src/adf/shown-text.ts +++ b/src/adf/shown-text.ts @@ -1,7 +1,43 @@ import type { AdfNode } from './document.ts' import type { JsonValue } from '../json-value.ts' +import { blockNodeModel } from './block-nodes.ts' import { nodeAttrs } from './document.ts' +export function isBlockNodeType(type: string): boolean { + return blockNodeModel(type) !== undefined || type === 'blockCard' || type === 'embedCard' +} + +// Whether the text a node shows stands apart from the text beside it; media shows inline, as an image in a line does. +export function partsShownText(type: string): boolean { + return isBlockNodeType(type) && type !== 'media' +} + +export function oneLine(text: string): string { + return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ') +} + +// The name of what a node references where it shows no text of its own, for a note standing in its place. +export function referenceName(node: AdfNode): string | undefined { + switch (node.type) { + case 'blockCard': + case 'embedCard': + case 'inlineCard': + return 'link card' + case 'extension': + case 'inlineExtension': { + const key = visibleText(nodeAttrs(node)['extensionKey']) + return key === undefined ? 'extension' : oneLine(key).trim() + } + case 'media': + case 'mediaInline': + return 'image' + case 'syncBlock': + return 'synced block' + default: + return undefined + } +} + // The text a node shows in place of itself, read from the attribute holding its content; `undefined` where it holds none. export function shownText(node: AdfNode): string | undefined { const attrs = nodeAttrs(node) @@ -9,32 +45,45 @@ export function shownText(node: AdfNode): string | undefined { case 'blockCard': case 'embedCard': case 'inlineCard': - return visibleText(attrs['url']) ?? visibleText(cardData(node)['name']) ?? cardUrl(node) + return visibleText(attrs['url']) ?? visibleText(cardData(node)['name']) ?? shownUrl(node) case 'date': return isoDate(attrs['timestamp']) case 'emoji': return visibleText(attrs['text']) ?? stringValue(attrs['shortName']) case 'expand': case 'nestedExpand': - case 'status': - return stringValue(node.type === 'status' ? attrs['text'] : attrs['title']) + return stringValue(attrs['title']) case 'extension': case 'inlineExtension': return visibleText(attrs['text']) case 'media': case 'mediaInline': - return visibleText(attrs['alt']) ?? (attrs['type'] === 'external' ? visibleText(attrs['url']) : undefined) + return visibleText(attrs['alt']) ?? shownUrl(node) case 'mention': { const id = attrs['id'] return visibleText(attrs['text']) ?? (typeof id === 'string' && id !== '' ? `@${id}` : undefined) } + case 'status': + return stringValue(attrs['text']) default: return undefined } } -export function cardUrl(card: AdfNode): string | undefined { - return visibleText(nodeAttrs(card)['url']) ?? visibleText(cardData(card)['url']) +// The URL a card or an external image links a reader to; `undefined` for every other node. +export function shownUrl(node: AdfNode): string | undefined { + const attrs = nodeAttrs(node) + switch (node.type) { + case 'blockCard': + case 'embedCard': + case 'inlineCard': + return visibleText(attrs['url']) ?? visibleText(cardData(node)['url']) + case 'media': + case 'mediaInline': + return attrs['type'] === 'external' ? visibleText(attrs['url']) : undefined + default: + return undefined + } } // `undefined` where the value is no string or holds only whitespace and null characters. diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index bb2ff01..82954f2 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -12,6 +12,19 @@ import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to import { toEditorNormal } from '../markdown/portable/editor-normal.ts' const gateRuns = 1600 +// What HTML text cannot hold (WHATWG HTML §13.1.3), and the line feed a carry element shows as a line break. +const unshown = /\n|(?![\t\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/gu + +// Every text node's text in document order. +function documentTexts(nodes: readonly AdfNode[]): string[] { + const texts: string[] = [] + const pending = [...nodes].reverse() + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + if (node.text !== undefined) texts.push(node.text) + for (const child of [...(node.content ?? [])].reverse()) pending.push(child) + } + return texts +} // Each block's text, an expand's title and an image's alt and url, in document order: what the portable pair keeps. function shownText(nodes: readonly AdfNode[]): string[] { @@ -56,12 +69,20 @@ test('a generated document writes portable markdown refusing only what the guard ) }) -test('a generated document writes HTML holding no null character', { timeout: propertyTimeout }, () => { +test('a generated document writes HTML holding no character HTML cannot hold, and showing every text node\'s text in document order', { 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)}`) + assert.ok(!/(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u.test(written.value), `writing ${JSON.stringify(document)}`) + const page = written.value.replace(/<[^>]*>/g, '').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('&', '&').replace(unshown, '') + let from = 0 + for (const text of documentTexts(document.content ?? [])) { + const shown = text.replace(unshown, '') + const at = page.indexOf(shown, from) + assert.ok(at >= 0, `writing ${JSON.stringify(document)}: ${JSON.stringify(shown)} is not shown after ${from}`) + from = at + shown.length + } }), propertyRuns(gateRuns), ) diff --git a/src/conformance/corpus.test.ts b/src/conformance/corpus.test.ts index 0067e8e..ce0d033 100644 --- a/src/conformance/corpus.test.ts +++ b/src/conformance/corpus.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { readFileSync, readdirSync } from 'node:fs' import test from 'node:test' +import { adfToHtml } from '../html/emit/adf-to-html.ts' import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts' import { isAdfDocument } from '../adf/document.ts' import { isJsonValue } from '../json-value.ts' @@ -149,6 +150,23 @@ for (const name of names(realPayloadsRoot, '.json')) { }) } +// The element each block an editor writes with no attribute but `localId`, and `level` or `order`, opens with; every other block is carried. +const blockElements: Readonly> = { blockquote: ' { + const payload: unknown = JSON.parse(readFileSync(join(realPayloadsRoot, `${name}.json`), 'utf8')) + assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`) + assert.ok(adfToHtml(payload).ok) + for (const block of payload.content ?? []) { + const opening = Object.hasOwn(blockElements, block.type) ? blockElements[block.type] : undefined + if (opening === undefined || block.marks !== undefined || Object.keys(block.attrs ?? {}).some((key) => !['level', 'localId', 'order'].includes(key))) continue + const written = adfToHtml({ content: [block], type: 'doc', version: 1 }) + assert.ok(written.ok && written.value.startsWith(opening), `${block.type} wrote ${written.ok ? written.value.slice(0, 80) : written.error.code}`) + } + }) +} + // The position the input itself gives an offset, recomputed rather than trusted from the parser. function lineStarting(markdown: string, offset: number): { line: number; offset: number } | undefined { const before = markdown.slice(0, offset) diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 1444964..67fa48e 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -11,11 +11,11 @@ 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/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' import { joinsWhenRead } from '../markdown/adjacent-text.ts' +import { toLinkAttrs } from '../markdown/mark-spellings.ts' type Positions = { block: AdfNode; inline: AdfNode } @@ -96,7 +96,7 @@ function occasionallyApart(arbitrary: Arbitrary): Arbitrary): string { return result.ok ? result.value : `${result.error.code}: ${result.error.message}` } +function refusal(result: Result): string { + return result.ok ? 'written' : `${result.error.code} at /${result.error.path.join('/')}` +} + function paragraph(...content: AdfNode[]): AdfNode { return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' } } @@ -26,6 +37,25 @@ function item(...content: AdfNode[]): AdfNode { return { content, type: 'listItem' } } +function nested(type: string, levels: number, inner: AdfNode): AdfNode { + let node = inner + for (let level = 0; level < levels; level += 1) node = { content: [node], type } + return node +} + +function carriedBlock(node: AdfNode): boolean { + return html(adfToHtml(document(node))).startsWith('

                ') + 2).replace(/<\/(?:div|span)>(?:<\/p>)?\n$/, '') +} + test('writes paragraphs, headings and rules as their elements, one block a line', () => { const heading = (level: number): AdfNode => ({ attrs: { level }, content: [text(`Level ${level}`)], type: 'heading' }) assert.equal(html(adfToHtml(document(heading(1), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6)))), '

                Level 1

                \n

                Read the manual.

                \n
                \n
                Level 6
                \n') @@ -42,18 +72,44 @@ test('writes the marks CommonMark spells, the first mark outermost', () => { assert.equal(html(adfToHtml(document(paragraph(text('Line one'), { type: 'hardBreak' }, text('Line two'))))), '

                Line one
                Line two

                \n') }) -test('escapes text and attribute values, and a carriage return so an HTML parser keeps it', () => { - assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\r\nf'))))), '

                a < b && c > "d" \'e\' \nf

                \n') +test('escapes text and attribute values, and keeps tabs, line feeds and form feeds', () => { + assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\tf\ng\fh'))))), '

                a < b && c > "d" \'e\'\tf\ng\fh

                \n') +}) + +test('carries text holding a character HTML text cannot hold, escaping it in the JSON', () => { + for (const value of ['a\r\nb', 'a\0b', 'a\u0001b', 'a\u007fb', 'a\u0085b', 'a﷐b', 'a￿b', 'a🿾b', 'a\ud800b', 'a\udc00b']) { + assert.equal(carriedSpans(text(value)), 1, JSON.stringify(value)) + } + assert.equal(carriedSpans(text('😄 a b')), 0) + assert.equal(html(adfToHtml(document(paragraph(text('a\u0085﷐b', { type: 'underline' }))))).includes('"text":"a\\u0085\\ufdd0b"'), true) }) test('writes lists tight, an item holding more than one paragraph or a list in elements after the first', () => { assert.equal(html(adfToHtml(document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))), '
                  \n
                • Bolt
                • \n
                • Nut
                • \n
                \n') - const nested: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' } - assert.equal(html(adfToHtml(document(nested))), '
                  \n
                1. Bolt\n
                    \n
                  • M8
                  • \n
                  \n
                2. \n
                3. a\n

                  b

                  \n
                4. \n
                \n') + const listed: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' } + assert.equal(html(adfToHtml(document(listed))), '
                  \n
                1. Bolt\n
                    \n
                  • M8
                  • \n
                  \n
                2. \n
                3. a\n

                  b

                  \n
                4. \n
                \n') assert.equal(html(adfToHtml(document({ content: [item({ type: 'rule' }), { type: 'listItem' }], type: 'orderedList' }))), '
                  \n
                1. \n
                  \n
                2. \n
                3. \n
                \n') assert.equal(html(adfToHtml(document({ content: [item(paragraph())], type: 'bulletList' }))), '
                  \n
                • \n

                  \n
                • \n
                \n') }) +test('writes an item\'s first paragraph as its element where it holds an attribute, and as its carry where it holds a mark', () => { + const first = (node: AdfNode): string => html(adfToHtml(document({ content: [item(node)], type: 'bulletList' }))) + assert.equal(first({ attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' }), `
                  \n
                • \n

                  Bolt

                  \n
                • \n
                \n`) + assert.equal(first({ content: [text('Bolt')], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }).startsWith('
                  \n
                • \n
                  { + const localId = { localId: ids.paragraph } + assert.equal(html(adfToHtml(document({ attrs: localId, content: [text('x')], type: 'paragraph' }))), `

                  x

                  \n`) + assert.equal(html(adfToHtml(document({ attrs: { level: 2, ...localId }, content: [text('x')], type: 'heading' }))), `

                  x

                  \n`) + assert.equal(html(adfToHtml(document({ attrs: { localId: ids.quote }, content: [paragraph(text('x'))], type: 'blockquote' }))), `
                  \n

                  x

                  \n
                  \n`) + const listed: AdfNode = { attrs: { localId: ids.list, order: 3 }, content: [{ attrs: { localId: ids.item }, content: [{ attrs: localId, content: [text('Bolt')], type: 'paragraph' }], type: 'listItem' }], type: 'orderedList' } + assert.equal(html(adfToHtml(document(listed))), `
                    \n
                  1. \n

                    Bolt

                    \n
                  2. \n
                  \n`) + const code: AdfNode = { attrs: { wrap: true, language: 'js', hideLineNumbers: false, ...localId }, content: [text('x')], type: 'codeBlock' } + assert.equal(html(adfToHtml(document(code))), `
                  x
                  \n`) + assert.equal(html(adfToHtml(document({ attrs: { weight: -0, color: '#ccc', style: 'a"b' }, type: 'rule' }))), '
                  \n') +}) + test('writes quotes and code blocks, the language a class', () => { assert.equal(html(adfToHtml(document({ content: [paragraph(text('Quoted'))], type: 'blockquote' }))), '
                  \n

                  Quoted

                  \n
                  \n') assert.equal(html(adfToHtml(document({ attrs: { language: 'javascript' }, content: [text('if (a < b)\n run()')], type: 'codeBlock' }))), '
                  if (a < b)\n  run()
                  \n') @@ -67,61 +123,85 @@ test('writes an external image standing alone in a paragraph, a link around it w assert.equal(html(adfToHtml(document(linked))), '

                  \n') }) -test('carries every other node in an adf-carry element holding its JSON and showing its text', () => { - const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.')), paragraph(text('Then import.'))], type: 'panel' } +test('carries every other node in an adf-carry element holding its JSON', () => { + const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' } assert.equal( html(adfToHtml(document(panel))), - '
                  Check the collation.\nThen import.
                  \n', + '

                  Check the collation.

                  \n', ) const mention: AdfNode = { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' } assert.equal(html(adfToHtml(document(paragraph(text('Ask '), mention)))), '

                  Ask @Mikael

                  \n') }) -test('shows the content a carried node holds in an attribute', () => { - const shown = (node: AdfNode): string => { - const written = html(adfToHtml(document(paragraph(node)))) - return written.slice(written.indexOf('">') + 2, written.indexOf('')) - } - assert.equal(shown({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14') - assert.equal(shown({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄') - assert.equal(shown({ attrs: { id: '5b10a2' }, type: 'mention' }), '@5b10a2') - assert.equal(shown({ attrs: { color: 'green', text: 'Done' }, type: 'status' }), 'Done') - assert.equal(shown({ attrs: { url: 'https://example.com/' }, type: 'inlineCard' }), 'https://example.com/<card>') - assert.equal(shown(text('underlined', { type: 'underline' })), 'underlined') - assert.equal(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' }))).endsWith('">Details\nInside
                  \n'), true) +test('shows a carried node\'s blocks as paragraphs, its links as anchors and its hard breaks as line breaks', () => { + const link: AdfMark = { attrs: { href: 'https://spec.commonmark.org/', title: 'The spec' }, type: 'link' } + const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Read the '), text('CommonMark spec', link), text(' first.')), paragraph(text('Then'), { type: 'hardBreak' }, text('import.'))], type: 'panel' } + assert.equal(shown(html(adfToHtml(document(panel)))), '

                  Read the CommonMark spec first.

                  Then
                  import.

                  ') + assert.equal(shown(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' })))), '

                  Details

                  Inside

                  ') + assert.equal(shown(html(adfToHtml(document({ content: [text('a\nb'), text('c')], type: 'codeBlock' })))), '

                  a
                  bc

                  ') + assert.equal(shown(html(adfToHtml(document(paragraph(text('x', { type: 'underline' }, link)))))), 'x') + assert.equal(shown(html(adfToHtml(document(paragraph({ content: [paragraph(text('a')), paragraph(text('b'))], type: 'panel' }))))), 'a
                  b') + assert.equal(shown(html(adfToHtml(document(paragraph(text('a\u0001b\r', { type: 'underline' }, { attrs: { href: 'https://example.com/\u0001' }, type: 'link' })))))), 'ab') +}) + +test('shows the content a carried node holds in an attribute, a link to what it links to', () => { + const inline = (node: AdfNode): string => shown(html(adfToHtml(document(paragraph(node))))) + assert.equal(inline({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14') + assert.equal(inline({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄') + assert.equal(inline({ attrs: { id: '5b10a2' }, type: 'mention' }), '@5b10a2') + assert.equal(inline({ attrs: { color: 'green', text: 'Done' }, type: 'status' }), 'Done') + assert.equal(inline({ attrs: { url: 'https://example.com/' }, type: 'inlineCard' }), 'https://example.com/<card>') + assert.equal(inline({ attrs: { data: { name: 'Roadmap' } }, type: 'inlineCard' }), 'Roadmap') + assert.equal(inline(text('underlined', { type: 'underline' })), 'underlined') + const image = (media: AdfNode): string => shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [media], type: 'mediaSingle' })))) + assert.equal(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '

                  The moon

                  ') + assert.equal(image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '

                  https://example.com/moon.jpg

                  ') +}) + +test('shows a note naming what a carried node references where it shows no text', () => { + const inline = (node: AdfNode): string => shown(html(adfToHtml(document(paragraph(node))))) + assert.equal(inline({ attrs: { collection: 'contentId-123', id: 'f1', type: 'file' }, type: 'mediaInline' }), '(image)') + assert.equal(inline({ attrs: {}, type: 'inlineCard' }), '(link card)') + assert.equal(inline({ attrs: { extensionKey: 'jira-issues' }, type: 'inlineExtension' }), '(jira-issues)') + assert.equal(inline({ attrs: { extensionType: 'com.example' }, type: 'inlineExtension' }), '(extension)') + assert.equal(shown(html(adfToHtml(document({ attrs: { resourceId: 'r1' }, type: 'syncBlock' })))), '

                  (synced block)

                  ') }) test('carries a node holding what its element cannot', () => { - const carried = (node: AdfNode): boolean => html(adfToHtml(document(node))).startsWith('
                  { - const carriedSpans = (...content: AdfNode[]): number => html(adfToHtml(document(paragraph(...content)))).split(' { assert.equal(carried({ attrs: { title: 'No target' }, type: 'link' }), true) assert.equal(carried({ attrs: { href: 'https://example.com/\0' }, type: 'link' }), true) assert.equal(carried({ attrs: { href: 'https://example.com/', title: 'x\0' }, type: 'link' }), true) - const imageCarried = (media: AdfNode): boolean => html(adfToHtml(document({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }))).startsWith('
                  ({ attrs: { type: 'external', url: 'https://example.com/a.png' }, marks: [{ attrs, type: 'link' }], type: 'media' }) - assert.equal(imageCarried(linked({ href: 'https://example.com/\0' })), true) - assert.equal(imageCarried(linked({ href: 'https://example.com/', title: 'x\0' })), true) - assert.equal(imageCarried({ attrs: { type: 'external', url: 'https://example.com/\0.png' }, type: 'media' }), true) - assert.equal(imageCarried({ attrs: { alt: 'The\0moon', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }), true) -}) - -test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading and a list item, the first in document order', () => { - let deep: AdfNode = text('x') - for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'unknownInline' } - const refused = (block: AdfNode): string => { - const result = adfToHtml(document(block)) - return result.ok ? 'written' : `${result.error.code} at ${result.error.path.join('/')}` - } - assert.equal(refused(paragraph(deep)), 'unsupported-nesting-depth at content/0/content/0') - assert.equal(refused({ attrs: { level: 1 }, content: [deep], type: 'heading' }), 'unsupported-nesting-depth at content/0/content/0') - assert.equal(refused({ content: [item(paragraph(deep))], type: 'bulletList' }), 'unsupported-nesting-depth at content/0/content/0/content/0/content/0') - assert.equal(refused({ content: [item(paragraph(text('a')), paragraph(deep)), item(paragraph(deep))], type: 'bulletList' }), 'unsupported-nesting-depth at content/0/content/0/content/1/content/0') -}) - -const tooLong = 'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes' - -test('refuses text past V8\'s longest string', () => { - const long = 'a'.repeat(2 ** 28) - assert.equal(html(adfToHtml(document(paragraph(text(long), text(long, { type: 'em' }), text(long))))), tooLong) -}) - -test('refuses at the block whose HTML passes V8\'s longest string, before a later block refuses', () => { - // The two code blocks' text is the bound exactly, so only what their elements add passes it. - const code: AdfNode = { content: [text('a'.repeat(536870888 / 2))], type: 'codeBlock' } - let deep: AdfNode = { type: 'rule' } - for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'blockquote' } - assert.equal(html(adfToHtml(document(code, code, deep))), tooLong) + const image = (media: AdfNode): AdfNode => ({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }) + assert.equal(carriedBlock(image(linked({ href: 'https://example.com/\0' }))), true) + assert.equal(carriedBlock(image(linked({ href: 'https://example.com/', title: 'x\0' }))), true) + assert.equal(carriedBlock(image({ attrs: { type: 'external', url: 'https://example.com/\0.png' }, type: 'media' })), true) + assert.equal(carriedBlock(image({ attrs: { alt: 'The\0moon', type: 'external', url: 'https://example.com/a.png' }, type: 'media' })), true) }) test('writes an empty document as nothing, and one holding no content key as its carry', () => { @@ -183,22 +235,38 @@ test('writes an empty document as nothing, and one holding no content key as its assert.equal(html(adfToHtml({ type: 'doc', version: 1 })), '
                  \n') }) -test('refuses what is no document, a version HTML cannot carry, and nesting past the levels a reader reads', () => { - assert.equal(html(adfToHtml({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN') - assert.equal(html(adfToHtml({ type: 'doc', version: 2 })), 'unsupported-document-version: no format this library writes carries ADF version 2') - let deep: AdfNode = paragraph(text('x')) - for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'blockquote' } - const refused = adfToHtml(document(deep)) - assert.equal(refused.ok ? 'written' : refused.error.code, 'unsupported-nesting-depth') - let fits: AdfNode = paragraph(text('x')) - for (let level = 1; level < largestNesting; level += 1) fits = { content: [fits], type: 'blockquote' } - assert.equal(adfToHtml(document(fits)).ok, true) - let items: AdfNode = paragraph(text('x')) - for (let level = 0; level <= largestNesting; level += 1) items = { content: [item(items)], type: 'bulletList' } - const listed = adfToHtml(document(items)) - assert.equal(listed.ok ? 'written' : listed.error.code, 'unsupported-nesting-depth') - let carried: AdfNode = paragraph(text('x')) - for (let level = 1; level < largestNesting; level += 1) carried = { content: [carried], type: 'panel' } - const json = adfToHtml(document(carried)) - assert.equal(json.ok ? 'written' : json.error.code, 'unsupported-nesting-depth') +test('refuses what is no document, and a version HTML cannot carry', () => { + assert.equal(refusal(adfToHtml({ type: 'doc', version: Number.NaN })), 'not-an-adf-document at /') + assert.equal(refusal(adfToHtml({ type: 'doc', version: 2 })), 'unsupported-document-version at /') +}) + +test('writes blocks nested 500 levels, and refuses the next level, a list\'s items one level below it', () => { + assert.equal(refusal(adfToHtml(document(nested('blockquote', largestNesting, paragraph(text('x')))))), 'written') + assert.equal(refusal(adfToHtml(document(nested('blockquote', largestNesting + 1, paragraph(text('x')))))).startsWith('unsupported-nesting-depth at /'), true) + let items: AdfNode = paragraph(text('x')) + for (let level = 0; level < largestNesting; level += 1) items = { content: [item(items)], type: 'bulletList' } + assert.equal(refusal(adfToHtml(document(items))), 'written') + assert.equal(refusal(adfToHtml(document({ content: [item(items)], type: 'bulletList' }))).startsWith('unsupported-nesting-depth at /'), true) +}) + +test('refuses a carried block whose JSON nests past the levels its depth leaves, and an inline carry past 500', () => { + let value: AdfAttributes[string] = 1 + for (let level = 0; level < largestNesting - 2; level += 1) value = [value] + const carried: AdfNode = { attrs: { a: value }, type: 'unknownBlock' } + assert.equal(refusal(adfToHtml(document(carried))), 'written') + assert.equal(refusal(adfToHtml(document({ content: [carried], type: 'blockquote' }))), 'unsupported-nesting-depth at /content/0/content/0') + const deep = nested('unknownInline', largestNesting, text('x')) + assert.equal(refusal(adfToHtml(document(paragraph(deep)))), 'unsupported-nesting-depth at /content/0/content/0') + assert.equal(refusal(adfToHtml(document({ attrs: { level: 1 }, content: [deep], type: 'heading' }))), 'unsupported-nesting-depth at /content/0/content/0') + assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/0/content/0') + assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(text('a')), paragraph(deep)), item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/1/content/0') +}) + +test('refuses HTML past V8\'s longest string, at the block passing it', () => { + const long = 'a'.repeat(2 ** 28) + const tooLong = (result: Result): boolean => !result.ok && result.error.code === 'unsupported-node-shape' && result.error.message.includes('536870888') + assert.equal(tooLong(adfToHtml(document(paragraph(text(long), text(long, { type: 'em' }), text(long))))), true) + // The two code blocks' text is the bound exactly, so only what their elements add passes it, before the too-deep block after them. + const code: AdfNode = { content: [text('a'.repeat(536870888 / 2))], type: 'codeBlock' } + assert.equal(tooLong(adfToHtml(document(code, code, nested('blockquote', largestNesting + 1, { type: 'rule' })))), true) }) diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index 31a21b9..d7e7ae1 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -1,18 +1,20 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' -import { anchorOpener, inlineHtml, writableText } from './inline.ts' +import { anchorOpener, optionalAttribute, writableValue } from './escaping.ts' +import { blockNodeModel } from '../../adf/block-nodes.ts' import { carriedBlock, carryElement } from './carry-element.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts' +import { inlineHtml, writableText } from './inline.ts' import { largestNesting } from '../../nesting.ts' import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts' -import { htmlValue, optionalAttribute } from './escaping.ts' import { plainImage } from '../../adf/plain-image.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' +import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' -// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; inList: the node is a list's item. -type Block = { depth: number; inList: boolean; node: AdfNode; path: ConvertErrorPath } -type Container = { attributes: string; isList: boolean; tag: string } -type Pending = Block | string +// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; an item's attributes are its element's, checked by its list. +type Block = { depth: number; kind: 'block'; node: AdfNode; path: ConvertErrorPath } +type Item = { attributes: string; depth: number; kind: 'item'; node: AdfNode; path: ConvertErrorPath } +type Pending = Block | Item | string export function adfToHtml(document: AdfDocument): Result { return withinEngineLimits(() => writeHtml(document)) @@ -23,7 +25,7 @@ function writeHtml(document: AdfDocument): Result { const content = writableContent(document) if (!content.ok) return content if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`) - const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0, false), '\n'].reverse() + const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0), '\n'].reverse() let html = '' for (let next = pending.pop(); next !== undefined; next = pending.pop()) { if (typeof next === 'string') { @@ -32,95 +34,131 @@ function writeHtml(document: AdfDocument): Result { html += next continue } - const pieces = writeBlock(next) + if (next.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, next.path) + const pieces = next.kind === 'item' ? writeItem(next) : writeBlock(next) if (!pieces.ok) return pieces for (const piece of pieces.value.reverse()) pending.push(piece) } return success(html) } -function blockPieces(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, inList: boolean): Pending[] { +function blockPieces(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number): Pending[] { const pieces: Pending[] = [] for (const [index, node] of nodes.entries()) { if (index > 0) pieces.push('\n') - pieces.push({ depth, inList, node, path: [...path, 'content', index] }) + pieces.push({ depth, kind: 'block', node, path: [...path, 'content', index] }) } return pieces } function writeBlock(block: Block): Result { const { depth, node, path } = block - if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, path) - if (block.inList) return writeItem(block) - const container = containerElement(node) - if (container !== undefined) return success(containerPieces(block, container)) + const container = node.type === 'blockquote' ? quotePieces(block) : listPieces(block) + if (container !== undefined) return success(container) const html = tryLeafBlock(node, path) ?? carriedBlock(node, path, depth) return html.ok ? success([html.value]) : html } -// `undefined` where the node is no container, or it or a list's item holds what the elements cannot, so the whole node rides the carry. -function containerElement(node: AdfNode): Container | undefined { - if (node.type === 'blockquote') return holdsOnlyAttributes(node, []) ? { attributes: '', isList: false, tag: 'blockquote' } : undefined - if (node.type === 'bulletList') return holdsOnlyAttributes(node, []) && listItemsWritable(node) ? { attributes: '', isList: true, tag: 'ul' } : undefined - if (node.type !== 'orderedList' || !holdsOnlyAttributes(node, ['order']) || !listItemsWritable(node)) return undefined +// The attributes a node holds besides those its element writes, as `data-*` attributes sorted by key, a value that is no string as JSON; `undefined` where the node holds what no attribute writes: a mark, text, an empty key, or an attribute its type does not hold as that kind (docs/decisions.md §The HTML dialect). +function dataAttributes(node: AdfNode, written: readonly string[]): string | undefined { + const model = blockNodeModel(node.type) + if (model === undefined || !holdsOnlyAttributes(node, Object.keys(model.attributes))) return undefined + const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, written) + if (pairs === undefined) return undefined + let attributes = '' + for (const { key, kind, value } of pairs.sort((left, right) => (left.key < right.key ? -1 : 1))) { + const attribute = optionalAttribute(`data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`, kind === 'string' ? value : serializeCanonicalJson(value, 'compact')) + if (attribute === undefined) return undefined + attributes += attribute + } + return attributes +} + +function quotePieces({ depth, node, path }: Block): Pending[] | undefined { + const attributes = dataAttributes(node, []) + return attributes === undefined ? undefined : containerPieces('blockquote', attributes, node.content === undefined ? undefined : blockPieces(node.content, path, depth + 1)) +} + +// `undefined` where the list, or one of its items, holds what the elements cannot, so the whole list rides the carry. +function listPieces({ depth, node, path }: Block): Pending[] | undefined { + const element = listElement(node) + if (element === undefined) return undefined + const items: Pending[] = [] + // The items' blocks sit one level below the list, as a markdown list's do. + for (const [index, item] of nodeContent(node).entries()) { + const attributes = item.type === 'listItem' ? dataAttributes(item, []) : undefined + if (attributes === undefined) return undefined + if (index > 0) items.push('\n') + items.push({ attributes, depth: depth + 1, kind: 'item', node: item, path: [...path, 'content', index] }) + } + return containerPieces(element.tag, element.attributes, node.content === undefined ? undefined : items) +} + +function listElement(node: AdfNode): { attributes: string; tag: string } | undefined { + if (node.type === 'bulletList') { + const attributes = dataAttributes(node, []) + return attributes === undefined ? undefined : { attributes, tag: 'ul' } + } + if (node.type !== 'orderedList') return undefined const order = nodeAttrs(node)['order'] - if (order === undefined) return { attributes: '', isList: true, tag: 'ol' } - return typeof order === 'number' && Number.isSafeInteger(order) && !Object.is(order, -0) ? { attributes: ` start="${order}"`, isList: true, tag: 'ol' } : undefined + if (order !== undefined && (typeof order !== 'number' || !Number.isSafeInteger(order) || Object.is(order, -0))) return undefined + const attributes = dataAttributes(node, ['order']) + return attributes === undefined ? undefined : { attributes: `${order === undefined ? '' : ` start="${order}"`}${attributes}`, tag: 'ol' } } -function listItemsWritable(list: AdfNode): boolean { - return nodeContent(list).every((item) => item.type === 'listItem' && holdsOnlyAttributes(item, [])) +// `children` undefined where the node holds no `content` key, so its element holds nothing. +function containerPieces(tag: string, attributes: string, children: Pending[] | undefined): Pending[] { + if (children === undefined) return [`<${tag}${attributes}>`] + return [`<${tag}${attributes}>\n`, ...children, `\n`] } -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(['
                • ']) - const blocks = blockPieces(node.content, path, depth, false) +// An item's blocks sit at its own level, one below its list; its first paragraph stands bare where it holds content and no attribute, panel pick, docs/decisions.md §The HTML dialect. +function writeItem({ attributes, depth, node, path }: Item): Result { + if (node.content === undefined) return success([``]) + const blocks = blockPieces(node.content, path, depth) const [first] = node.content - if (first === undefined || first.type !== 'paragraph' || !holdsOnlyAttributes(first, []) || nodeContent(first).length === 0) return success(['
                • \n', ...blocks, '\n
                • ']) + if (first === undefined || first.type !== 'paragraph' || dataAttributes(first, []) !== '' || nodeContent(first).length === 0) return success([`\n`, ...blocks, '\n']) 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 ? [`
                • ${bare.value}
                • `] : [`
                • ${bare.value}`, ...blocks.slice(1), '\n
                • ']) + return success(blocks.length === 1 ? [`${bare.value}`] : [`${bare.value}`, ...blocks.slice(1), '\n']) } function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined { - if (node.type === 'paragraph' && holdsOnlyAttributes(node, [])) return inlineElement('p', nodeContent(node), path) - if (node.type === 'heading' && holdsOnlyAttributes(node, ['level'])) { + if (node.type === 'paragraph') return inlineElement('p', dataAttributes(node, []), nodeContent(node), path) + if (node.type === 'heading') { const level = nodeAttrs(node)['level'] - if (typeof level === 'number' && Number.isInteger(level) && level >= 1 && level <= 6) return inlineElement(`h${level}`, nodeContent(node), path) + if (typeof level === 'number' && Number.isInteger(level) && level >= 1 && level <= 6) return inlineElement(`h${level}`, dataAttributes(node, ['level']), nodeContent(node), path) } - const html = tryCodeBlock(node) ?? tryImage(node) ?? (node.type === 'rule' && holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '
                  ' : undefined) + const html = tryCodeBlock(node) ?? tryImage(node) ?? tryRule(node) return html === undefined ? undefined : success(html) } -function inlineElement(tag: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result { +// `undefined` where the attributes are, so the node rides the carry. +function inlineElement(tag: string, attributes: string | undefined, nodes: readonly AdfNode[], path: ConvertErrorPath): Result | undefined { + if (attributes === undefined) return undefined const inline = inlineHtml(nodes, path) - return inline.ok ? success(`<${tag}>${inline.value}`) : inline + return inline.ok ? success(`<${tag}${attributes}>${inline.value}`) : inline } // A class holds the language as one token, so a language holding whitespace rides the carry. function tryCodeBlock(node: AdfNode): string | undefined { - if (node.type !== 'codeBlock' || !holdsOnlyAttributes(node, ['language'])) return undefined + if (node.type !== 'codeBlock') return undefined + const attributes = dataAttributes(node, ['language']) const language = nodeAttrs(node)['language'] - if (language !== undefined && (typeof language !== 'string' || !/^[^\t\n\f\r ]+$/.test(language))) return undefined + if (attributes === undefined || (language !== undefined && (typeof language !== 'string' || !/^[^\t\n\f\r ]+$/.test(language)))) return undefined const className = optionalAttribute('class', language === undefined ? undefined : `language-${language}`) if (className === undefined) return undefined - if (node.content === undefined) return `
                  ` + if (node.content === undefined) return `
                ` const [only, ...others] = node.content const code = only === undefined || others.length > 0 || !isUnmarkedSpellableText(only) ? undefined : writableText(only.text) - return code === undefined ? undefined : `
                ${code}
                ` + return code === undefined ? undefined : `${code}
                ` } function tryImage(node: AdfNode): string | undefined { const image = node.type === 'mediaSingle' ? plainImage(node) : undefined if (image === undefined) return undefined - const src = htmlValue(image.url) + const src = writableValue(image.url) const alt = optionalAttribute('alt', image.alt) if (src === undefined || alt === undefined) return undefined const element = `` @@ -128,3 +166,8 @@ function tryImage(node: AdfNode): string | undefined { const anchor = anchorOpener(image.link) return anchor === undefined ? undefined : `

                ${anchor}${element}

                ` } + +function tryRule(node: AdfNode): string | undefined { + const attributes = node.type === 'rule' && nodeContent(node).length === 0 ? dataAttributes(node, []) : undefined + return attributes === undefined ? undefined : `` +} diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts index f4632a3..71bed2a 100644 --- a/src/html/emit/carry-element.ts +++ b/src/html/emit/carry-element.ts @@ -1,34 +1,64 @@ import type { AdfNode } from '../../adf/document.ts' -import { blockNodeModel } from '../../adf/block-nodes.ts' +import { anchorOpener, jsonValue, shownValue } from './escaping.ts' import { carriedJson } from '../../adf/carried-json.ts' -import { carriedValue } from './escaping.ts' import { largestNesting } from '../../nesting.ts' -import { nodeContent } from '../../adf/document.ts' -import { shownText } from '../../adf/shown-text.ts' +import { linkAttrs, type LinkAttrs } from '../../adf/mark-attributes.ts' +import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { partsShownText, referenceName, shownText, shownUrl } from '../../adf/shown-text.ts' import { success, type ConvertErrorPath, type Result } from '../../result.ts' +// Where the carry element stands: a `
                ` shows each block as a paragraph, a `` parts them with line breaks. +type Position = 'block' | 'inline' + 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 + return json.ok ? success(carryElement('div', json.value.json, shownContent(node, 'block'))) : 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 + return json.ok ? success(carryElement('span', json.value.json, shownContent(node, 'inline'))) : json } -export function carryElement(tag: string, json: string, text: string): string { - return `<${tag} class="adf-carry" data-adf="${carriedValue(json)}">${carriedValue(text)}` +export function carryElement(tag: string, json: string, content: string): string { + return `<${tag} class="adf-carry" data-adf="${jsonValue(json)}">${content}` } -// The text a carried node shows, a line break between its blocks and for each hard break; a reader of the element reads the JSON alone. -function carriedText(node: AdfNode): string { - let text = '' - const pending: AdfNode[] = [node] +// What a carry element shows, its links and its notes as the writer panel picked them (docs/decisions.md §The HTML dialect); a reader of the element reads its JSON alone. +function shownContent(root: AdfNode, position: Position): string { + const groups: string[] = [] + let group = '' + const pending: (AdfNode | 'end of block')[] = [root] for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - if (text !== '' && (next.type === 'hardBreak' || blockNodeModel(next.type) !== undefined)) text += '\n' - text += next.text ?? shownText(next) ?? '' + const parts = next === 'end of block' || partsShownText(next.type) + if (parts && group !== '') groups.push(group) + if (parts) group = '' + if (next === 'end of block') continue + group += shownPiece(next) + if (partsShownText(next.type)) pending.push('end of block') for (const child of [...nodeContent(next)].reverse()) pending.push(child) } - return text + if (group !== '') groups.push(group) + return position === 'block' ? groups.map((shown) => `

                ${shown}

                `).join('') : groups.join('
                ') +} + +// The node's own part of what its carry element shows: its text or what it shows in its place, linked where it links; a note where it references what it cannot show. +function shownPiece(node: AdfNode): string { + if (node.type === 'hardBreak') return '
                ' + const text = node.text ?? shownText(node) + if (text === undefined) { + const name = referenceName(node) + return name === undefined ? '' : `(${shownValue(name)})` + } + const link = shownLink(node) + const anchor = link === undefined ? undefined : anchorOpener(link) + return anchor === undefined ? shownValue(text) : `${anchor}${shownValue(text)}` +} + +// What a reader of the carry element follows from the node: its link mark's target, or the URL a card or an image links to. +function shownLink(node: AdfNode): LinkAttrs | undefined { + const mark = nodeMarks(node).find((held) => held.type === 'link') + if (mark !== undefined) return linkAttrs(nodeAttrs(mark)) + const href = shownUrl(node) + return href === undefined ? undefined : { href } } diff --git a/src/html/emit/escaping.ts b/src/html/emit/escaping.ts index 73c223e..f462266 100644 --- a/src/html/emit/escaping.ts +++ b/src/html/emit/escaping.ts @@ -1,23 +1,44 @@ -// An HTML parser reads a null character as U+FFFD, so each function here refuses or drops one, and the writer escapes through nothing else. +import type { LinkAttrs } from '../../adf/mark-attributes.ts' -// `undefined` where the value holds a null character, so its node rides the carry element. -export function htmlValue(value: string): string | undefined { - return value.includes('\0') ? undefined : escapeHtml(value) +// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): a control other than ASCII whitespace or a carriage return, which a parser reads as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode. +const unwritable = /(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u +const unwritables = new RegExp(unwritable.source, 'gu') + +export function anchorOpener({ href, title }: LinkAttrs): string | undefined { + const url = writableValue(href) + const titled = optionalAttribute('title', title) + return url === undefined || titled === undefined ? undefined : `` } -// ` name="value"`, `''` where the value is undefined, `undefined` where it holds a null character. +// The carry's JSON, which spells each character HTML cannot hold as a JSON escape reading back to the same string. +export function jsonValue(json: string): string { + return escapeHtml(json.replace(unwritables, (character) => jsonEscape(character))) +} + +// ` name="value"`, `''` where the value is undefined, `undefined` where HTML cannot hold it. export function optionalAttribute(name: string, value: string | undefined): string | undefined { if (value === undefined) return '' - const escaped = htmlValue(value) + const escaped = writableValue(value) return escaped === undefined ? undefined : ` ${name}="${escaped}"` } -// What the carry element holds: its JSON spells a null character `\u0000`, and the text it shows drops one. -export function carriedValue(value: string): string { - return escapeHtml(value.replaceAll('\0', '')) +// The text a carry element shows, dropping what HTML cannot hold, each line feed a line break. +export function shownValue(value: string): string { + return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', '
                ') } -// 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', ' ') +// `undefined` where HTML cannot hold the value, so its node rides the carry element. +export function writableValue(value: string): string | undefined { + return unwritable.test(value) ? undefined : escapeHtml(value) +} + +function escapeHtml(text: string): string { + return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') +} + +// An astral noncharacter spells as its two surrogates' escapes. +function jsonEscape(character: string): string { + let escaped = '' + for (let index = 0; index < character.length; index += 1) escaped += `\\u${character.charCodeAt(index).toString(16).padStart(4, '0')}` + return escaped } diff --git a/src/html/emit/inline.ts b/src/html/emit/inline.ts index 12fcfaa..7d253af 100644 --- a/src/html/emit/inline.ts +++ b/src/html/emit/inline.ts @@ -1,12 +1,15 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' +import { anchorOpener, writableValue } from './escaping.ts' import { carriedInline } from './carry-element.ts' import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' -import { htmlValue, optionalAttribute } from './escaping.ts' -import { linkAttrs, type LinkAttrs } from '../../adf/mark-attributes.ts' +import { linkAttrs } from '../../adf/mark-attributes.ts' import { success, type ConvertErrorPath, type Result } from '../../result.ts' -// Each names its element. -const elementMarks: readonly string[] = ['code', 'em', 'strong'] +const markElements: ReadonlyMap = new Map([ + ['code', 'code'], + ['em', 'em'], + ['strong', 'strong'], +]) export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result { let html = '' @@ -41,22 +44,17 @@ function tryInlineNode(node: AdfNode): string | undefined { return `${openers.join('')}${text}${closers.join('')}` } -// No element holds empty text apart from no text. +// Empty text reads back as no text node, so it rides the carry. export function writableText(text: string | undefined): string | undefined { - return text === undefined || text === '' ? undefined : htmlValue(text) + return text === undefined || text === '' ? undefined : writableValue(text) } function markElement(mark: AdfMark): { closer: string; opener: string } | undefined { const { type } = mark - if (elementMarks.includes(type)) return mark.attrs === undefined ? { closer: ``, opener: `<${type}>` } : undefined + const element = markElements.get(type) + if (element !== undefined) return mark.attrs === undefined ? { closer: ``, opener: `<${element}>` } : undefined if (type !== 'link' || Object.keys(nodeAttrs(mark)).some((key) => key !== 'href' && key !== 'title')) return undefined const link = linkAttrs(nodeAttrs(mark)) const opener = link === undefined ? undefined : anchorOpener(link) return opener === undefined ? undefined : { closer: '
                ', 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/length-bound.test.ts b/src/length-bound.test.ts index 524629a..b542b73 100644 --- a/src/length-bound.test.ts +++ b/src/length-bound.test.ts @@ -2,9 +2,14 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { Result } from './result.ts' -import { joinedWithin, tooLong, withinEngineLimits, withinLongest } from './length-bound.ts' +import { joinedWithin, withinEngineLimits, withinLongest } from './length-bound.ts' import { success } from './result.ts' +// The code at the document's path, and the bound the message names. +function pastTheBound(result: Result): boolean { + return !result.ok && result.error.code === 'unsupported-node-shape' && result.error.path.length === 0 && result.error.message.includes('536870888') +} + function thrown(name: string, message: string): () => Result { return () => { const error = new Error(message) @@ -16,7 +21,7 @@ function thrown(name: string, message: string): () => Result { test('joins pieces up to the bound, and builds nothing past it', () => { assert.deepEqual(joinedWithin(['a', 'b']), success('ab')) const half = 'a'.repeat(2 ** 28) - assert.deepEqual(joinedWithin([half, half]), tooLong()) + assert.equal(pastTheBound(joinedWithin([half, half])), true) }) test('passes a text within the bound', () => { @@ -24,8 +29,8 @@ test('passes a text within the bound', () => { }) test('refuses a string past the bound as too long on every engine, and any other engine limit as engine-limit', () => { - assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Invalid string length')), tooLong()) - assert.deepEqual(withinEngineLimits(thrown('InternalError', 'allocation size overflow')), tooLong()) + assert.equal(pastTheBound(withinEngineLimits(thrown('RangeError', 'Invalid string length'))), true) + assert.equal(pastTheBound(withinEngineLimits(thrown('InternalError', 'allocation size overflow'))), true) assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Out of memory')), { error: { code: 'engine-limit', message: 'the conversion passed a limit of this JavaScript engine on a string, array, Map or Set, or ran it out of memory (Out of memory)', path: [] }, ok: false, diff --git a/src/length-bound.ts b/src/length-bound.ts index ab844f6..c72c5bf 100644 --- a/src/length-bound.ts +++ b/src/length-bound.ts @@ -15,7 +15,7 @@ const pastEngineLimit = [ ] export function tooLong(): Result { - return failure('unsupported-node-shape', `the markdown or HTML for this document, or a directive's plain CommonMark, would be longer than ${longestOutput} characters, the longest this library writes`, []) + return failure('unsupported-node-shape', `this conversion would build text longer than ${longestOutput} characters, the longest this library writes`, []) } export function withinLongest(text: string): Result { diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 2f7f708..7349781 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -44,7 +44,10 @@ test('refuses a value that is not an ADF document, naming the check it failed', assert.equal(markdown(adfToLosslessMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN') }) -const tooLong = 'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes' +// The code, and the bound the message names. +function pastTheBound(result: Result): boolean { + return !result.ok && result.error.code === 'unsupported-node-shape' && result.error.message.includes('536870888') +} // A code block of 300 lines; two of these together fall some 1000 characters short of V8's longest string, so only what their spelling adds passes it. function nearlyHalfTheBound(): AdfNode { @@ -56,18 +59,18 @@ test('refuses a carry whose JSON would run past V8\'s longest string, on every e let value: JsonValue = new Array(560_000).fill(0) for (let level = 0; level < 495; level += 1) value = [value] const result = adfToLosslessMarkdown(document({ attrs: { value }, type: 'unknownBlock' })) - assert.equal(markdown(result), tooLong) + assert.equal(pastTheBound(result), true) }) test('refuses a quote whose prefixed lines would pass V8\'s longest string', () => { const code = nearlyHalfTheBound() - assert.equal(markdown(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), tooLong) + assert.equal(pastTheBound(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), true) }) test('refuses text past V8\'s longest string', () => { const long = 'a'.repeat(2 ** 28) const result = adfToLosslessMarkdown(document(paragraph({ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' }))) - assert.equal(markdown(result), tooLong) + assert.equal(pastTheBound(result), true) }) test('escapes more characters in a line than V8\'s Set holds', () => { diff --git a/src/markdown/emit/image.ts b/src/markdown/emit/image.ts index de4e010..3328466 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 { attrsDefinition } from '../mark-spellings.ts' import { plainImage } from '../../adf/plain-image.ts' +import { toLinkDefinition } from '../mark-spellings.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 : attrsDefinition(link)) + return tryImageLine(alt, url, flavour, link === undefined ? undefined : toLinkDefinition(link)) } diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 36e982a..c23a401 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -38,19 +38,19 @@ export function markSpelling(type: string): MarkSpelling | undefined { } export function linkMark(definition: LinkDefinition): AdfMark { - return linkMarkFrom(definitionAttrs(definition)) + return linkMarkFrom(toLinkAttrs(definition)) } -export function definitionAttrs({ destination, title }: LinkDefinition): LinkAttrs { +export function toLinkAttrs({ destination, title }: LinkDefinition): LinkAttrs { return title === undefined ? { href: destination } : { href: destination, title } } export function linkDefinition(attrs: AdfAttributes): LinkDefinition | undefined { const target = linkAttrs(attrs) - return target === undefined ? undefined : attrsDefinition(target) + return target === undefined ? undefined : toLinkDefinition(target) } -export function attrsDefinition({ href, title }: LinkAttrs): LinkDefinition { +export function toLinkDefinition({ href, title }: LinkAttrs): LinkDefinition { return title === undefined ? { destination: href } : { destination: href, title } } diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 9757388..e290f28 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -3,15 +3,15 @@ import type { DirectiveSpan } from './directive/syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' import type { JsonValue } from '../json-value.ts' import { carriedJson } from '../adf/carried-json.ts' -import { spellsCanonicalJson } from '../canonical-json.ts' import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive/syntax.ts' -import { success, type ConvertErrorPath, type Read, type Result } from '../result.ts' import { fencedCodeBlock } from './commonmark/backtick-runs.ts' import { infoStringCarries } from './commonmark/grammar.ts' import { isAdfNode } from '../adf/document.ts' -import { overNested } from '../json-value.ts' import { largestNesting } from '../nesting.ts' +import { overNested } from '../json-value.ts' import { parseJsonText } from '../json-text.ts' +import { spellsCanonicalJson } from '../canonical-json.ts' +import { success, type ConvertErrorPath, type Read, type Result } from '../result.ts' export const carryFencePrefix = 'adf:' diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 20ef481..df97265 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -6,17 +6,17 @@ 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/plain-image.ts' -import { imageAside } from '../image-aside.ts' -import { commonMarkLink, definitionAttrs, linkDefinition, linkHref, linkMark } from '../mark-spellings.ts' +import { commonMarkLink, linkDefinition, linkHref, linkMark, toLinkAttrs } 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' import { highlightDelimiter, inlineSource, readInlineToken } from '../inline-tokens.ts' import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts' +import { imageAside } from '../image-aside.ts' import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts' import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts' -import { matchEmphasis } from '../commonmark/emphasis-matching.ts' import { labelLimit, normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' +import { matchEmphasis } from '../commonmark/emphasis-matching.ts' import { noSpans, readInlineDirective } from '../directive/syntax.ts' import { openingLinkTakesDirective } from '../emit/spelling-questions.ts' import { readCarriedInline } from '../opaque-carry.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 : definitionAttrs(link)), ...caption]) + return centeredImage([externalMedia(alt, definition.destination, link === undefined ? undefined : toLinkAttrs(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/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index c152065..7c3ed19 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -362,10 +362,9 @@ test('refuses the directive form of a blockquote opening with a code span', () = // The input is some 1000 characters short of V8's longest string; the `> ` the quote adds to each of its 600 lines passes it. Reading this much outlasts Bun's 5 s default timeout. test('refuses a blockquote directive whose CommonMark spelling would pass V8\'s longest string', { timeout: 120_000 }, () => { const fence = `\`\`\`\n${`${'a'.repeat(894_782)}\n`.repeat(300)}\`\`\`\n` - assert.equal( - content(losslessMarkdownToAdf(`!adf:blockquote\n${fence}\n${fence}!adf:/blockquote\n`)), - 'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes', - ) + const refused = losslessMarkdownToAdf(`!adf:blockquote\n${fence}\n${fence}!adf:/blockquote\n`) + assert.equal(code(refused), 'unsupported-node-shape') + assert.equal(refused.ok ? '' : refused.error.message.includes('536870888'), true) }) test('names the directive name no node reads back to', () => { diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index d7120cc..e55eee3 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -4,8 +4,9 @@ import { centeredImage, externalMedia } from '../../adf/plain-image.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.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 { inlineLeaves, portableLink, reduceInline, writableHref } from './inline-reduction.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' +import { isBlockNodeType, oneLine } from '../../adf/shown-text.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { linkAttrs } from '../../adf/mark-attributes.ts' diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 6a6058e..47d4c06 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -1,14 +1,13 @@ -import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' +import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import type { MarkRun } from '../emit/line-escaping.ts' -import { blockNodeModel } from '../../adf/block-nodes.ts' -import { cardUrl, shownText, visibleText } from '../../adf/shown-text.ts' import { codeTextMarkTypes } from '../../adf/inline-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { imageAside } from '../image-aside.ts' import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { partsShownText, referenceName, shownText, shownUrl, visibleText } from '../../adf/shown-text.ts' import { portableLineFallback, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination } from '../commonmark/link-syntax.ts' @@ -23,10 +22,6 @@ export function reduceInline(nodes: readonly AdfNode[], container: LineContainer return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path) } -export function isBlockNodeType(type: string): boolean { - return blockNodeModel(type) !== undefined || type === 'blockCard' || type === 'embedCard' -} - export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) const leaves: AdfNode[] = [] @@ -35,7 +30,7 @@ export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer const held = nodeLeaves(node, container, [...path, 'content', index], depth) if (!held.ok) return held if (held.value.length === 0) continue - const block = isBlockNodeType(node.type) && node.type !== 'media' + const block = partsShownText(node.type) if (leaves.length > 0 && (joinsNext || block)) leaves.push(textLeaf(' ', [])) joinsNext = block for (const leaf of held.value) leaves.push(leaf) @@ -45,14 +40,12 @@ export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result { const marks = nodeMarks(node) - const attrs = nodeAttrs(node) if (node.type === 'text') return success(textLeaves(node.text, marks, container)) if (node.type === 'hardBreak') return success([lineBreak(container)]) if (['date', 'emoji', 'mention', 'status'].includes(node.type)) return success(textLeaves(shownText(node), marks, container)) if (node.type === 'placeholder') return success([]) - if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(shownText(node), marks, container, noteName(attrs['extensionKey']) ?? 'extension')) - if (node.type === 'syncBlock') return success(noteLeaves('synced block')) - if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container)) + if (['extension', 'inlineExtension', 'syncBlock'].includes(node.type)) return success(textLeaves(shownText(node), marks, container, referenceName(node))) + if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(node, marks, container)) if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(node, marks, container)) const own = textLeaves(node.text ?? shownText(node), marks, container) const held = inlineLeaves(nodeContent(node), container, path, depth + 1) @@ -61,8 +54,8 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP } function cardLeaves(card: AdfNode, marks: readonly AdfMark[], container: LineContainer): AdfNode[] { - const href = cardUrl(card) - if (href === undefined) return textLeaves(shownText(card), marks, container, 'link card') + const href = shownUrl(card) + if (href === undefined) return textLeaves(shownText(card), marks, container, referenceName(card)) return linkedLeaves(shownText(card) ?? href, href, marks, container) } @@ -71,10 +64,10 @@ function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], con } // An image standing inline is a link to it: the reader builds no node for an inline image, reading it as its alt text linked, and inside a link as the link's text, then (image). -function mediaLeaves(attrs: Readonly, marks: readonly AdfMark[], container: LineContainer): AdfNode[] { - const alt = visibleText(attrs['alt']) - const url = visibleText(attrs['url']) - if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image') +function mediaLeaves(media: AdfNode, marks: readonly AdfMark[], container: LineContainer): AdfNode[] { + const alt = visibleText(nodeAttrs(media)['alt']) + const url = shownUrl(media) + if (url === undefined) return textLeaves(alt, marks, container, referenceName(media)) const link = marks.find((mark) => mark.type === 'link') const href = link === undefined ? undefined : nodeAttrs(link)['href'] if (typeof href !== 'string') return linkedLeaves(alt ?? url, url, marks, container) @@ -86,14 +79,6 @@ function noteLeaves(name: string): AdfNode[] { return [textLeaf(`(${name} not included)`, [{ type: 'em' }])] } -function noteName(value: unknown): string | undefined { - return visibleText(value) === undefined ? undefined : oneLine(String(value)).trim() -} - -export function oneLine(text: string): string { - return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ') -} - function lineBreak(container: LineContainer): AdfNode { return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', []) }