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) -//
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` | `
`, ``, ``, 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` | `- …
`: a first paragraph holding content bare, its inline content straight after `- `, then each other block after a line feed and a line feed before `
`; with no bare paragraph, `- `, a line feed, its blocks, a line feed, `
` |
+| `orderedList` | as `bulletList` with ``; `` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` |
+| `listItem` | `- …
`: a first paragraph holding content and no attribute bare, its inline content straight after `- `, then each other block after a line feed and a line feed before `
`; with no bare paragraph, `- `, a line feed, its blocks, a line feed, `
` |
| `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
\nRead the manual.
\n
\nLevel 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', 'ab', 'ab', 'ab', '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\u0085b', { 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- Bolt\n
\n- M8
\n
\n \n- a\n
b
\n \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- Bolt\n
\n- M8
\n
\n \n- a\n
b
\n \n
\n')
assert.equal(html(adfToHtml(document({ content: [item({ type: 'rule' }), { type: 'listItem' }], type: 'orderedList' }))), '\n- \n
\n \n\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' }))), `\nx
\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- \n
Bolt
\n \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' }))), '\nQuoted
\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' }), '')
+ assert.equal(image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '')
+})
+
+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}>${tag}>`]
+ return [`<${tag}${attributes}>\n`, ...children, `\n${tag}>`]
}
-function containerPieces({ depth, node, path }: Block, { attributes, isList, tag }: Container): Pending[] {
- if (node.content === undefined) return [`<${tag}${attributes}>${tag}>`]
- return [`<${tag}${attributes}>\n`, ...blockPieces(node.content, path, depth + 1, isList), `\n${tag}>`]
-}
-
-// An item's blocks sit at its own level, one below its list, as a markdown list's do; its first paragraph stands bare where it holds content, panel pick, docs/decisions.md §The HTML dialect.
-function writeItem({ depth, node, path }: Block): Result {
- 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}${tag}>`) : inline
+ return inline.ok ? success(`<${tag}${attributes}>${inline.value}${tag}>`) : inline
}
// A class holds the language as one token, so a language holding whitespace rides the carry.
function tryCodeBlock(node: AdfNode): string | undefined {
- if (node.type !== 'codeBlock' || !holdsOnlyAttributes(node, ['language'])) return undefined
+ 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)}${tag}>`
+export function carryElement(tag: string, json: string, content: string): string {
+ return `<${tag} class="adf-carry" data-adf="${jsonValue(json)}">${content}${tag}>`
}
-// The text a carried node shows, a line break between its blocks and for each hard break; a reader of the element reads the JSON alone.
-function carriedText(node: AdfNode): string {
- let text = ''
- const pending: AdfNode[] = [node]
+// 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: `${type}>`, opener: `<${type}>` } : undefined
+ const element = markElements.get(type)
+ if (element !== undefined) return mark.attrs === undefined ? { closer: `${element}>`, 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(' ', [])
}