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

Answer principles round 1: 3 🔴, 8 🟡 (0 misread); attributes as data-*, carried content as paragraphs, links and notes, no character HTML cannot hold, mutants killed

This commit is contained in:
2026-10-23 19:19:14 +02:00
parent 25aa975c91
commit 53201f65eb
27 changed files with 612 additions and 295 deletions
+5 -2
View File
@@ -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
+33 -11
View File
@@ -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)
// <div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;warning&quot;},…}">Check the collation before importing.</div>
// <h2 data-local-id="a1f3">Release notes</h2>
// <p>Read the <a href="https://spec.commonmark.org/">spec</a>, then ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>
```
### 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` | `<p>` |
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<code>`, `<em>`, `<strong>`, the first mark outermost |
| any other node, or a node holding what its element cannot | `<div class="adf-carry" data-adf="{…}">its text</div>`, a `<span>` inline |
| any other attribute the node's type holds | `data-*` named as HTML's `dataset` names it: `<p data-local-id="a1f3">` |
| any other node, or a node holding what its element cannot | `<div class="adf-carry" data-adf="{…}"><p>its content</p></div>`, a `<span>` 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 `<p>`, 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, `<em>(image)</em>`.
- 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.
+2 -2
View File
@@ -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)
+3
View File
@@ -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')
})
}
+22 -18
View File
@@ -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
+9 -1
View File
@@ -405,6 +405,13 @@ bare `<img>`, a list item's first paragraph bare, `<li>Bolt</li>`, over `<li><p>
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
`<a>` over `text (url)` and over its text alone, a carried node's blocks as `<p>` over `<br>` and a
line feed, and media it cannot show as `<em>(image)</em>` over `<em>(image not shown)</em>` 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()`.
+43 -22
View File
@@ -15,8 +15,8 @@ document whose `content` is an empty array is the empty string.
| `heading`, `level` 1 to 6 | `<h1>…</h1>` to `<h6>…</h6>` |
| `blockquote` | `<blockquote>`, a line feed, its blocks, a line feed, `</blockquote>` |
| `bulletList` | `<ul>`, a line feed, its items a line each, a line feed, `</ul>` |
| `orderedList` | as `bulletList` with `<ol>`; `<ol start="3">` for an `order` of 3, a safe integer; no `start` where the list holds no `order` |
| `listItem` | `<li>…</li>`: a first paragraph holding content bare, its inline content straight after `<li>`, then each other block after a line feed and a line feed before `</li>`; with no bare paragraph, `<li>`, a line feed, its blocks, a line feed, `</li>` |
| `orderedList` | as `bulletList` with `<ol>`; `<ol start="3">` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` |
| `listItem` | `<li>…</li>`: a first paragraph holding content and no attribute bare, its inline content straight after `<li>`, then each other block after a line feed and a line feed before `</li>`; with no bare paragraph, `<li>`, a line feed, its blocks, a line feed, `</li>` |
| `codeBlock` | `<pre><code class="language-js">…</code></pre>`, the language one token holding no whitespace; no `class` where it holds no `language` |
| `rule` | `<hr>` |
| `mediaSingle`, centered, holding one external `media` | `<p><img src="…" alt="…"></p>`, no `alt` where the media holds none; `<p><a href="…"><img …></a></p>` 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: `<blockquote></blockquote>`,
`<ul></ul>`, `<li></li>`; so is a paragraph or heading, `<p></p>`.
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:
`<ol start="3" data-local-id="91177a27-b0ff">`, `<pre data-wrap="true"><code>`. A code block's
`data-*` attributes stand on its `<pre>`.
## Inline
| ADF | HTML |
@@ -39,43 +45,58 @@ marks are two `<a>` elements.
## Escaping
Text and attribute values escape `&`, `<`, `>` and `"` as `&amp;`, `&lt;`, `&gt;` and `&quot;`,
and a carriage return as `&#13;`, which an HTML parser would otherwise read as a line feed.
Text and attribute values escape `&`, `<`, `>` and `"` as `&amp;`, `&lt;`, `&gt;` and `&quot;`.
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: `<div>` in block
position, `<span>` 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
<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},…,&quot;type&quot;:&quot;panel&quot;}">Check the collation.</div>
<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},…,&quot;type&quot;:&quot;panel&quot;}"><p>Read the <a href="https://spec.commonmark.org/">CommonMark spec</a> first.</p><p>Then import.</p></div>
<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>
```
- `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 `<div>`, each block's content is a `<p>`; in a `<span>`, `<br>` parts two blocks'.
- A hard break and a line feed in text are `<br>`.
- Text a link marks is `<a href="…" title="…">` 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, `<em>(image)</em>`: `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.
+16 -1
View File
@@ -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')
})
+55 -6
View File
@@ -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.
+23 -2
View File
@@ -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('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&quot;', '"').replaceAll('&amp;', '&').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),
)
+18
View File
@@ -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<Record<string, string>> = { blockquote: '<blockquote', bulletList: '<ul', heading: '<h', orderedList: '<ol', paragraph: '<p', rule: '<hr' }
for (const name of names(realPayloadsRoot, '.json')) {
test(`real-payloads/${name} writes each block holding only its id as its element`, () => {
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)
+2 -2
View File
@@ -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<AdfNode[]>): Arbitrary<AdfNode[]
}
function externalImage({ alt, link, url }: { alt: string | undefined; link: LinkDefinition | undefined; url: string }): AdfNode {
return centeredImage([externalMedia(alt ?? '', url, link === undefined ? undefined : definitionAttrs(link))])
return centeredImage([externalMedia(alt ?? '', url, link === undefined ? undefined : toLinkAttrs(link))])
}
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
+158 -90
View File
@@ -6,6 +6,13 @@ import type { Result } from '../../result.ts'
import { adfToHtml } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
const ids = {
item: '0192f1a2-6b3c-7d4e-8f50-6a7b8c9d0e1f',
list: '0192f1a2-6b3c-7d4e-9a51-6a7b8c9d0e20',
paragraph: '0192f1a2-6b3c-7d4e-ab52-6a7b8c9d0e21',
quote: '0192f1a2-6b3c-7d4e-b853-6a7b8c9d0e22',
}
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
@@ -14,6 +21,10 @@ function html(result: Result<string>): string {
return result.ok ? result.value : `${result.error.code}: ${result.error.message}`
}
function refusal(result: Result<string>): 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('<div class="adf-carry"')
}
function carriedSpans(...content: AdfNode[]): number {
return html(adfToHtml(document(paragraph(...content)))).split('<span class="adf-carry"').length - 1
}
// What the one carry element written shows: its content after the opening tag, whose `data-adf` holds no `"`.
function shown(written: string): string {
return written.slice(written.indexOf('">') + 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)))), '<h1>Level 1</h1>\n<p>Read the manual.</p>\n<hr>\n<h6>Level 6</h6>\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'))))), '<p>Line one<br>Line two</p>\n')
})
test('escapes text and attribute values, and a carriage return so an HTML parser keeps it', () => {
assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\r\nf'))))), '<p>a &lt; b &amp;&amp; c &gt; &quot;d&quot; \'e\'&#13;\nf</p>\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'))))), '<p>a &lt; b &amp;&amp; c &gt; &quot;d&quot; \'e\'\tf\ng\fh</p>\n')
})
test('carries text holding a character HTML text cannot hold, escaping it in the JSON', () => {
for (const value of ['a\r\nb', 'a\0b', 'a\u0001b', 'a\u007fb', 'a\u0085b', 'a﷐b', 'a￿b', 'a🿾b', 'a\ud800b', 'a\udc00b']) {
assert.equal(carriedSpans(text(value)), 1, JSON.stringify(value))
}
assert.equal(carriedSpans(text('😄 a b')), 0)
assert.equal(html(adfToHtml(document(paragraph(text('a\u0085﷐b', { type: 'underline' }))))).includes('&quot;text&quot;:&quot;a\\u0085\\ufdd0b&quot;'), 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' }))), '<ul>\n<li>Bolt</li>\n<li>Nut</li>\n</ul>\n')
const nested: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' }
assert.equal(html(adfToHtml(document(nested))), '<ol start="3">\n<li>Bolt\n<ul>\n<li>M8</li>\n</ul>\n</li>\n<li>a\n<p>b</p>\n</li>\n</ol>\n')
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))), '<ol start="3">\n<li>Bolt\n<ul>\n<li>M8</li>\n</ul>\n</li>\n<li>a\n<p>b</p>\n</li>\n</ol>\n')
assert.equal(html(adfToHtml(document({ content: [item({ type: 'rule' }), { type: 'listItem' }], type: 'orderedList' }))), '<ol>\n<li>\n<hr>\n</li>\n<li></li>\n</ol>\n')
assert.equal(html(adfToHtml(document({ content: [item(paragraph())], type: 'bulletList' }))), '<ul>\n<li>\n<p></p>\n</li>\n</ul>\n')
})
test('writes 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' }), `<ul>\n<li>\n<p data-local-id="${ids.paragraph}">Bolt</p>\n</li>\n</ul>\n`)
assert.equal(first({ content: [text('Bolt')], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }).startsWith('<ul>\n<li>\n<div class="adf-carry"'), true)
})
test('writes the attributes an element cannot express as data-* attributes on it, sorted, a value that is no string as JSON', () => {
const localId = { localId: ids.paragraph }
assert.equal(html(adfToHtml(document({ attrs: localId, content: [text('x')], type: 'paragraph' }))), `<p data-local-id="${ids.paragraph}">x</p>\n`)
assert.equal(html(adfToHtml(document({ attrs: { level: 2, ...localId }, content: [text('x')], type: 'heading' }))), `<h2 data-local-id="${ids.paragraph}">x</h2>\n`)
assert.equal(html(adfToHtml(document({ attrs: { localId: ids.quote }, content: [paragraph(text('x'))], type: 'blockquote' }))), `<blockquote data-local-id="${ids.quote}">\n<p>x</p>\n</blockquote>\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))), `<ol start="3" data-local-id="${ids.list}">\n<li data-local-id="${ids.item}">\n<p data-local-id="${ids.paragraph}">Bolt</p>\n</li>\n</ol>\n`)
const code: AdfNode = { attrs: { wrap: true, language: 'js', hideLineNumbers: false, ...localId }, content: [text('x')], type: 'codeBlock' }
assert.equal(html(adfToHtml(document(code))), `<pre data-hide-line-numbers="false" data-local-id="${ids.paragraph}" data-wrap="true"><code class="language-js">x</code></pre>\n`)
assert.equal(html(adfToHtml(document({ attrs: { weight: -0, color: '#ccc', style: 'a"b' }, type: 'rule' }))), '<hr data-color="#ccc" data-style="a&quot;b" data-weight="-0">\n')
})
test('writes quotes and code blocks, the language a class', () => {
assert.equal(html(adfToHtml(document({ content: [paragraph(text('Quoted'))], type: 'blockquote' }))), '<blockquote>\n<p>Quoted</p>\n</blockquote>\n')
assert.equal(html(adfToHtml(document({ attrs: { language: 'javascript' }, content: [text('if (a < b)\n run()')], type: 'codeBlock' }))), '<pre><code class="language-javascript">if (a &lt; b)\n run()</code></pre>\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))), '<p><a href="https://example.com/"><img src="https://example.com/moon.jpg"></a></p>\n')
})
test('carries every other node in an adf-carry element holding its JSON and showing its text', () => {
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.')), paragraph(text('Then import.'))], type: 'panel' }
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))),
'<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},&quot;content&quot;:[{&quot;content&quot;:[{&quot;text&quot;:&quot;Check the collation.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;},{&quot;content&quot;:[{&quot;text&quot;:&quot;Then import.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;}],&quot;type&quot;:&quot;panel&quot;}">Check the collation.\nThen import.</div>\n',
'<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},&quot;content&quot;:[{&quot;content&quot;:[{&quot;text&quot;:&quot;Check the collation.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;}],&quot;type&quot;:&quot;panel&quot;}"><p>Check the collation.</p></div>\n',
)
const mention: AdfNode = { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }
assert.equal(html(adfToHtml(document(paragraph(text('Ask '), mention)))), '<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>\n')
})
test('shows the content a carried node holds in an attribute', () => {
const shown = (node: AdfNode): string => {
const written = html(adfToHtml(document(paragraph(node))))
return written.slice(written.indexOf('">') + 2, written.indexOf('</span>'))
}
assert.equal(shown({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14')
assert.equal(shown({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄')
assert.equal(shown({ attrs: { id: '5b10a2' }, type: 'mention' }), '@5b10a2')
assert.equal(shown({ attrs: { color: 'green', text: 'Done' }, type: 'status' }), 'Done')
assert.equal(shown({ attrs: { url: 'https://example.com/<card>' }, type: 'inlineCard' }), 'https://example.com/&lt;card&gt;')
assert.equal(shown(text('underlined', { type: 'underline' })), 'underlined')
assert.equal(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' }))).endsWith('">Details\nInside</div>\n'), true)
test('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)))), '<p>Read the <a href="https://spec.commonmark.org/" title="The spec">CommonMark spec</a> first.</p><p>Then<br>import.</p>')
assert.equal(shown(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' })))), '<p>Details</p><p>Inside</p>')
assert.equal(shown(html(adfToHtml(document({ content: [text('a\nb'), text('c')], type: 'codeBlock' })))), '<p>a<br>bc</p>')
assert.equal(shown(html(adfToHtml(document(paragraph(text('x', { type: 'underline' }, link)))))), '<a href="https://spec.commonmark.org/" title="The spec">x</a>')
assert.equal(shown(html(adfToHtml(document(paragraph({ content: [paragraph(text('a')), paragraph(text('b'))], type: 'panel' }))))), 'a<br>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/<card>' }, type: 'inlineCard' }), '<a href="https://example.com/&lt;card&gt;">https://example.com/&lt;card&gt;</a>')
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' }), '<p><a href="https://example.com/moon.jpg">The moon</a></p>')
assert.equal(image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' }), '<p><a href="https://example.com/moon.jpg">https://example.com/moon.jpg</a></p>')
})
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' }), '<em>(image)</em>')
assert.equal(inline({ attrs: {}, type: 'inlineCard' }), '<em>(link card)</em>')
assert.equal(inline({ attrs: { extensionKey: 'jira-issues' }, type: 'inlineExtension' }), '<em>(jira-issues)</em>')
assert.equal(inline({ attrs: { extensionType: 'com.example' }, type: 'inlineExtension' }), '<em>(extension)</em>')
assert.equal(shown(html(adfToHtml(document({ attrs: { resourceId: 'r1' }, type: 'syncBlock' })))), '<p><em>(synced block)</em></p>')
})
test('carries a node holding what its element cannot', () => {
const carried = (node: AdfNode): boolean => html(adfToHtml(document(node))).startsWith('<div class="adf-carry"')
assert.equal(carried({ attrs: { localId: 'p1' }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carried({ content: [], type: 'paragraph' }), true)
assert.equal(carried({ attrs: {}, type: 'rule' }), true)
assert.equal(carried({ attrs: { level: 7 }, content: [text('x')], type: 'heading' }), true)
assert.equal(carried({ attrs: { localId: 'q1' }, content: [paragraph(text('x'))], type: 'blockquote' }), true)
assert.equal(carried({ attrs: { order: 1.5 }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true)
assert.equal(carried({ content: [item(paragraph(text('x'))), paragraph(text('y'))], type: 'bulletList' }), true)
assert.equal(carried({ content: [], type: 'bulletList' }), true)
assert.equal(carried({ attrs: { language: 'c sharp' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carried({ attrs: { language: 'a\0b' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('a'), text('b')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('x\0')], type: 'codeBlock' }), true)
assert.equal(carried(item(paragraph(text('x')))), true)
assert.equal(carried(text('x')), true)
assert.equal(carried({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
assert.equal(carried({ attrs: { layout: 'center' }, content: [{ attrs: { alt: '', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
assert.equal(carriedBlock({ attrs: { localId: 7 }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carriedBlock({ content: [], type: 'paragraph' }), true)
assert.equal(carriedBlock({ attrs: {}, type: 'rule' }), true)
assert.equal(carriedBlock({ content: [text('x')], type: 'rule' }), true)
assert.equal(carriedBlock({ attrs: { color: 'a\u0001' }, type: 'rule' }), true)
for (const level of [0, 1.5, 7]) assert.equal(carriedBlock({ attrs: { level }, content: [text('x')], type: 'heading' }), true, String(level))
assert.equal(carriedBlock({ marks: [{ attrs: { level: 1 }, type: 'indentation' }], type: 'blockquote' }), true)
for (const order of [1.5, -0]) assert.equal(carriedBlock({ attrs: { order }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true, String(order))
assert.equal(carriedBlock({ content: [item(paragraph(text('x'))), paragraph(text('y'))], type: 'bulletList' }), true)
assert.equal(carriedBlock({ content: [{ attrs: {}, content: [paragraph(text('x'))], type: 'listItem' }], type: 'bulletList' }), true)
assert.equal(carriedBlock({ content: [{ content: [paragraph(text('x'))], text: 'y', type: 'listItem' }], type: 'bulletList' }), true)
assert.equal(carriedBlock({ content: [], type: 'bulletList' }), true)
assert.equal(carriedBlock({ attrs: { language: 'c sharp' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ attrs: { language: 'a\0b' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ content: [text('a'), text('b')], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ content: [text('x', { type: 'code' })], type: 'codeBlock' }), true)
assert.equal(carriedBlock({ content: [text('x\0')], type: 'codeBlock' }), true)
assert.equal(carriedBlock(item(paragraph(text('x')))), true)
assert.equal(carriedBlock(text('x')), true)
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
assert.equal(carriedBlock({ attrs: { layout: 'center' }, content: [{ attrs: { alt: '', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
})
test('carries inline what its element cannot hold, and a text node an unmarked one before it would join', () => {
const carriedSpans = (...content: AdfNode[]): number => html(adfToHtml(document(paragraph(...content)))).split('<span class="adf-carry"').length - 1
assert.equal(carriedSpans(text('a'), text('b')), 1)
assert.equal(carriedSpans(text('a'), text('b', { type: 'em' }), text('c')), 0)
assert.equal(carriedSpans(text('')), 1)
assert.equal(carriedSpans(text('x\0y')), 1)
assert.equal(carriedSpans(text('x', { type: 'em' }, { type: 'em' })), 1)
assert.equal(carriedSpans({ marks: [], text: 'x', type: 'text' }), 1)
assert.equal(carriedSpans(text('x', { attrs: {}, type: 'em' })), 1)
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/', id: 'a1' }, type: 'link' })), 1)
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/', id: ids.item }, type: 'link' })), 1)
assert.equal(carriedSpans({ attrs: { text: '\n' }, type: 'hardBreak' }), 1)
assert.equal(carriedSpans({ content: [text('x')], type: 'hardBreak' }), 1)
assert.equal(carriedSpans(paragraph(text('x'))), 1)
})
@@ -142,40 +222,12 @@ test('carries a link or image holding a value its element cannot', () => {
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('<div class="adf-carry"')
const linked = (attrs: AdfAttributes): AdfNode => ({ attrs: { type: 'external', url: 'https://example.com/a.png' }, marks: [{ attrs, type: 'link' }], type: 'media' })
assert.equal(imageCarried(linked({ href: 'https://example.com/\0' })), true)
assert.equal(imageCarried(linked({ href: 'https://example.com/', title: 'x\0' })), true)
assert.equal(imageCarried({ attrs: { type: 'external', url: 'https://example.com/\0.png' }, type: 'media' }), true)
assert.equal(imageCarried({ attrs: { alt: 'The\0moon', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }), true)
})
test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading and a list item, 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 })), '<div class="adf-carry" data-adf="{&quot;type&quot;:&quot;doc&quot;,&quot;version&quot;:1}"></div>\n')
})
test('refuses what is no document, a version HTML cannot carry, and nesting past the levels a reader reads', () => {
assert.equal(html(adfToHtml({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN')
assert.equal(html(adfToHtml({ type: 'doc', version: 2 })), 'unsupported-document-version: no 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<string>): 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)
})
+88 -45
View File
@@ -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<string> {
return withinEngineLimits(() => writeHtml(document))
@@ -23,7 +25,7 @@ function writeHtml(document: AdfDocument): Result<string> {
const content = writableContent(document)
if (!content.ok) return content
if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`)
const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0, false), '\n'].reverse()
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<string> {
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<Pending[]> {
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<Pending[]> {
if (node.content === undefined) return success(['<li></li>'])
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<Pending[]> {
if (node.content === undefined) return success([`<li${attributes}></li>`])
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(['<li>\n', ...blocks, '\n</li>'])
if (first === undefined || first.type !== 'paragraph' || dataAttributes(first, []) !== '' || nodeContent(first).length === 0) return success([`<li${attributes}>\n`, ...blocks, '\n</li>'])
const bare = inlineHtml(nodeContent(first), [...path, 'content', 0])
if (!bare.ok) return bare
// The bare paragraph's inline content stands in for its block.
return success(blocks.length === 1 ? [`<li>${bare.value}</li>`] : [`<li>${bare.value}`, ...blocks.slice(1), '\n</li>'])
return success(blocks.length === 1 ? [`<li${attributes}>${bare.value}</li>`] : [`<li${attributes}>${bare.value}`, ...blocks.slice(1), '\n</li>'])
}
function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
if (node.type === 'paragraph' && holdsOnlyAttributes(node, [])) return inlineElement('p', nodeContent(node), path)
if (node.type === '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 ? '<hr>' : 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<string> {
// `undefined` where the attributes are, so the node rides the carry.
function inlineElement(tag: string, attributes: string | undefined, nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> | 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 `<pre><code${className}></code></pre>`
if (node.content === undefined) return `<pre${attributes}><code${className}></code></pre>`
const [only, ...others] = node.content
const code = only === undefined || others.length > 0 || !isUnmarkedSpellableText(only) ? undefined : writableText(only.text)
return code === undefined ? undefined : `<pre><code${className}>${code}</code></pre>`
return code === undefined ? undefined : `<pre${attributes}><code${className}>${code}</code></pre>`
}
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 = `<img src="${src}"${alt}>`
@@ -128,3 +166,8 @@ function tryImage(node: AdfNode): string | undefined {
const anchor = anchorOpener(image.link)
return anchor === undefined ? undefined : `<p>${anchor}${element}</a></p>`
}
function tryRule(node: AdfNode): string | undefined {
const attributes = node.type === 'rule' && nodeContent(node).length === 0 ? dataAttributes(node, []) : undefined
return attributes === undefined ? undefined : `<hr${attributes}>`
}
+45 -15
View File
@@ -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 `<div>` shows each block as a paragraph, a `<span>` parts them with line breaks.
type Position = 'block' | 'inline'
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting - depth)
return json.ok ? success(carryElement('div', json.value.json, carriedText(node))) : json
return json.ok ? success(carryElement('div', json.value.json, shownContent(node, 'block'))) : json
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting)
return json.ok ? success(carryElement('span', json.value.json, carriedText(node))) : json
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) => `<p>${shown}</p>`).join('') : groups.join('<br>')
}
// 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 '<br>'
const text = node.text ?? shownText(node)
if (text === undefined) {
const name = referenceName(node)
return name === undefined ? '' : `<em>(${shownValue(name)})</em>`
}
const link = shownLink(node)
const anchor = link === undefined ? undefined : anchorOpener(link)
return anchor === undefined ? shownValue(text) : `${anchor}${shownValue(text)}</a>`
}
// 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 }
}
+33 -12
View File
@@ -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 : `<a href="${url}"${titled}>`
}
// ` 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', '<br>')
}
// A carriage return is a character reference, since an HTML parser reads a raw one as a line feed.
function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('\r', '&#13;')
// `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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
// 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
}
+11 -13
View File
@@ -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<string, string> = new Map([
['code', 'code'],
['em', 'em'],
['strong', 'strong'],
])
export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
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: '</a>', opener }
}
export function anchorOpener({ href, title }: LinkAttrs): string | undefined {
const url = htmlValue(href)
const titled = optionalAttribute('title', title)
return url === undefined || titled === undefined ? undefined : `<a href="${url}"${titled}>`
}
+9 -4
View File
@@ -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<string>): 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<string> {
return () => {
const error = new Error(message)
@@ -16,7 +21,7 @@ function thrown(name: string, message: string): () => Result<string> {
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,
+1 -1
View File
@@ -15,7 +15,7 @@ const pastEngineLimit = [
]
export function tooLong<T>(): Result<T> {
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<string> {
+7 -4
View File
@@ -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<string>): 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<JsonValue>(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', () => {
+2 -2
View File
@@ -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))
}
+4 -4
View File
@@ -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 }
}
+3 -3
View File
@@ -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:'
+4 -4
View File
@@ -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.
+3 -4
View File
@@ -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', () => {
@@ -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'
+11 -26
View File
@@ -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<AdfNode[]> {
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<AdfNode[]> {
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<AdfAttributes>, 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(' ', [])
}