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:
+5
-2
@@ -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
|
||||
|
||||
@@ -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="{"attrs":{"panelType":"warning"},…}">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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@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.
|
||||
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 `&`, `<`, `>` and `"`,
|
||||
and a carriage return as ` `, which an HTML parser would otherwise read as a line feed.
|
||||
Text and attribute values escape `&`, `<`, `>` and `"` as `&`, `<`, `>` and `"`.
|
||||
Attribute values are double-quoted. A link's `href` and an image's `src` are the URL as the ADF
|
||||
holds it, whatever its scheme (`docs/decisions.md` §HTML writes a URL as the ADF holds it, whatever
|
||||
its scheme).
|
||||
|
||||
HTML text and attribute values cannot hold a carriage return, which a parser reads as a line feed,
|
||||
a control other than a tab, line feed or form feed, a noncharacter, or a lone surrogate, which
|
||||
UTF-8 cannot encode (WHATWG HTML §13.1.3). A node holding one in a value its element writes rides
|
||||
the carry.
|
||||
|
||||
## The carry element
|
||||
|
||||
A node the sections above cannot write where it stands is the carry element: `<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="{"attrs":{"panelType":"info"},…,"type":"panel"}">Check the collation.</div>
|
||||
<div class="adf-carry" data-adf="{"attrs":{"panelType":"info"},…,"type":"panel"}"><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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -12,6 +12,19 @@ import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to
|
||||
import { toEditorNormal } from '../markdown/portable/editor-normal.ts'
|
||||
|
||||
const gateRuns = 1600
|
||||
// What HTML text cannot hold (WHATWG HTML §13.1.3), and the line feed a carry element shows as a line break.
|
||||
const unshown = /\n|(?![\t\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/gu
|
||||
|
||||
// Every text node's text in document order.
|
||||
function documentTexts(nodes: readonly AdfNode[]): string[] {
|
||||
const texts: string[] = []
|
||||
const pending = [...nodes].reverse()
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
if (node.text !== undefined) texts.push(node.text)
|
||||
for (const child of [...(node.content ?? [])].reverse()) pending.push(child)
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// Each block's text, an expand's title and an image's alt and url, in document order: what the portable pair keeps.
|
||||
function shownText(nodes: readonly AdfNode[]): string[] {
|
||||
@@ -56,12 +69,20 @@ test('a generated document writes portable markdown refusing only what the guard
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes HTML holding no null character', { timeout: propertyTimeout }, () => {
|
||||
test('a generated document writes HTML holding no character HTML cannot hold, and showing every text node\'s text in document order', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const written = adfToHtml(document)
|
||||
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||
assert.ok(!written.value.includes('\0'), `writing ${JSON.stringify(document)}`)
|
||||
assert.ok(!/(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u.test(written.value), `writing ${JSON.stringify(document)}`)
|
||||
const page = written.value.replace(/<[^>]*>/g, '').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('&', '&').replace(unshown, '')
|
||||
let from = 0
|
||||
for (const text of documentTexts(document.content ?? [])) {
|
||||
const shown = text.replace(unshown, '')
|
||||
const at = page.indexOf(shown, from)
|
||||
assert.ok(at >= 0, `writing ${JSON.stringify(document)}: ${JSON.stringify(shown)} is not shown after ${from}`)
|
||||
from = at + shown.length
|
||||
}
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 < b && c > "d" \'e\' \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 < b && c > "d" \'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', 'ab', 'ab', 'ab', 'a\ud800b', 'a\udc00b']) {
|
||||
assert.equal(carriedSpans(text(value)), 1, JSON.stringify(value))
|
||||
}
|
||||
assert.equal(carriedSpans(text('😄 a b')), 0)
|
||||
assert.equal(html(adfToHtml(document(paragraph(text('a\u0085b', { type: 'underline' }))))).includes('"text":"a\\u0085\\ufdd0b"'), true)
|
||||
})
|
||||
|
||||
test('writes lists tight, an item holding more than one paragraph or a list in elements after the first', () => {
|
||||
assert.equal(html(adfToHtml(document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))), '<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"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 < 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="{"attrs":{"panelType":"info"},"content":[{"content":[{"text":"Check the collation.","type":"text"}],"type":"paragraph"},{"content":[{"text":"Then import.","type":"text"}],"type":"paragraph"}],"type":"panel"}">Check the collation.\nThen import.</div>\n',
|
||||
'<div class="adf-carry" data-adf="{"attrs":{"panelType":"info"},"content":[{"content":[{"text":"Check the collation.","type":"text"}],"type":"paragraph"}],"type":"panel"}"><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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@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/<card>')
|
||||
assert.equal(shown(text('underlined', { type: 'underline' })), 'underlined')
|
||||
assert.equal(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' }))).endsWith('">Details\nInside</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/<card>">https://example.com/<card></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="{"type":"doc","version":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)
|
||||
})
|
||||
|
||||
@@ -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}>`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('\r', ' ')
|
||||
// `undefined` where HTML cannot hold the value, so its node rides the carry element.
|
||||
export function writableValue(value: string): string | undefined {
|
||||
return unwritable.test(value) ? undefined : escapeHtml(value)
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
}
|
||||
|
||||
// An astral noncharacter spells as its two surrogates' escapes.
|
||||
function jsonEscape(character: string): string {
|
||||
let escaped = ''
|
||||
for (let index = 0; index < character.length; index += 1) escaped += `\\u${character.charCodeAt(index).toString(16).padStart(4, '0')}`
|
||||
return escaped
|
||||
}
|
||||
|
||||
+11
-13
@@ -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}>`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,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:'
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(' ', [])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user