mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Ship htmlToAdf: read back exactly the HTML adfToHtml writes, a carry element from its data-adf alone, any other HTML refused
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
node is an element of class `adf-carry` holding the node's JSON in `data-adf` and showing its
|
||||
content, links included. Every URL is written as the ADF holds it,
|
||||
`javascript:` included: sanitize HTML from an author you do not trust before showing it.
|
||||
- `htmlToAdf` reads the HTML `adfToHtml` writes back to the document it came from, a carry element
|
||||
from its `data-adf` alone, and refuses any other HTML as `unmappable-html`.
|
||||
- **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
|
||||
|
||||
@@ -22,7 +22,8 @@ products store as ADF.
|
||||
- **HTML out:** `adfToHtml` writes paragraphs, headings, lists, quotes, code blocks, rules, line
|
||||
breaks, emphasis, inline code, links and a plain external image as their HTML elements. Every other
|
||||
node, an image Atlassian's editor wrote among them, becomes an element holding its JSON and showing
|
||||
its content.
|
||||
its content. `htmlToAdf` reads that HTML back to the document it came from, and refuses any other
|
||||
HTML.
|
||||
- **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.
|
||||
@@ -253,7 +254,7 @@ same marks and link.
|
||||
|
||||
| Package | Converts | ADF to markdown to ADF | A node type it does not know | Formats | Direct runtime dependencies |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `@larvit/adf-codec` | both ways | the same document | kept | markdown: lossless, portable; CommonMark in; HTML out | 0 |
|
||||
| `@larvit/adf-codec` | both ways | the same document | kept | markdown: lossless, portable; CommonMark in; HTML out; its own HTML in | 0 |
|
||||
| `@atlaskit/editor-markdown-transformer` 6.3.1 | markdown to ADF, through `@atlaskit/editor-json-transformer` 10.0.0; ADF to markdown throws "This is not implemented yet" | – | – | markdown | 5 |
|
||||
| `adf-to-markdown` 1.0.1 | ADF to markdown | – | dropped with its text | markdown | 0 |
|
||||
| `adf-to-md` 1.2.1 | ADF to markdown | – | dropped with its text, as are mentions, statuses and panels | markdown | 1 |
|
||||
@@ -278,6 +279,7 @@ adfToPortableMarkdown(doc: AdfDocument): Result<string>
|
||||
portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
|
||||
adfToHtml(doc: AdfDocument): Result<string>
|
||||
htmlToAdf(html: string): Result<AdfDocument, ParseError>
|
||||
```
|
||||
|
||||
Each call's name says which format it reads or writes:
|
||||
@@ -288,6 +290,7 @@ Each call's name says which format it reads or writes:
|
||||
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `htmlToAdf` | reads the HTML `adfToHtml` writes, a carry element from its `data-adf` alone; refuses any other HTML | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` |
|
||||
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `engine-limit`, `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||
|
||||
@@ -500,6 +503,11 @@ content. No stylesheet ships. The complete dialect:
|
||||
target shows that link, then `(image)` linking to the image. A node it cannot show leaves a note
|
||||
instead, such as `<em>(image)</em>`; a link marking the node goes around the note.
|
||||
|
||||
`htmlToAdf` reads exactly that HTML back, so `htmlToAdf(adfToHtml(doc))` deep-equals `doc`. A carry
|
||||
element reads from its `data-adf` alone, so editing what it shows doesn't change the document read.
|
||||
Any other HTML is refused as `unmappable-html`: another element, attribute or escape, such as `<b>`,
|
||||
`<br/>` or `'`.
|
||||
|
||||
## The errors
|
||||
|
||||
Serves [Goals](#goals) 1. An ADF node type this version does not know is not an error: the lossless
|
||||
@@ -520,10 +528,10 @@ carry](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#unknown-n
|
||||
itself.
|
||||
- `position` is `{ line, offset }` into the string passed in: `line` counted from 1, `offset` a
|
||||
UTF-16 code unit, which is a JavaScript string index. It points at or before the refusal —
|
||||
currently the start of the line the enclosing block begins on; a later minor may narrow that,
|
||||
never widen it. For `not-a-string` it is `{ line: 1, offset: 0 }`.
|
||||
currently the start of the line the enclosing block begins on in markdown, and of the line the
|
||||
refusal stands on in HTML; a later minor may narrow that, never widen it. For `not-a-string` it is `{ line: 1, offset: 0 }`.
|
||||
|
||||
A parse always names a position, so the three markdown readers return `ParseError`, whose
|
||||
A parse always names a position, so the three markdown readers and `htmlToAdf` return `ParseError`, whose
|
||||
`position` reads without a guard. An emit reads no source and carries `path` alone. One handler
|
||||
typed on `ConvertError` takes both.
|
||||
|
||||
@@ -546,7 +554,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r
|
||||
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing `---` delimiter row, or a delimiter or body row whose cell count differs from the header's; in `losslessMarkdownToAdf` also an alignment colon, and a header or delimiter row not opening with a pipe | add a `---` row under the header and give every row the header's cell count; for `losslessMarkdownToAdf`, also open every row with a pipe and drop the colons. To keep the lines literal text, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way — or, for a table without leading pipes, write one `\|` of its delimiter row as `\\|` |
|
||||
| `not-a-string` | the value handed in is not a string, such as `null`, a number or a `String` object | pass the input as a string, `String(value)` for a `String` object; the message names the value's type |
|
||||
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md), or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
|
||||
| `unmappable-html` | the input holds raw HTML, a comment and a processing instruction among them | remove the construct, or write what it holds as markdown |
|
||||
| `unmappable-html` | the markdown holds raw HTML, a comment and a processing instruction among them; the HTML handed to `htmlToAdf` holds HTML `adfToHtml` does not write | remove the construct, or write what it holds as markdown; for `htmlToAdf`, write the HTML as [`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md) spells it |
|
||||
|
||||
### Emitting
|
||||
|
||||
@@ -582,6 +590,14 @@ Every call can raise these:
|
||||
- writes as a directive a node or mark the lossless flavour spells as CommonMark;
|
||||
- puts `!adf:textBreak{}` where it parts nothing, or `!adf:doc` anywhere but as the whole
|
||||
document.
|
||||
- **Reading, in `htmlToAdf`.** Fix what the message names;
|
||||
[`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md) lists each element's
|
||||
attributes. The HTML:
|
||||
- holds a `data-*` attribute naming no attribute its node holds, or holding a value outside that
|
||||
attribute's kind or not written as compact JSON, such as `data-wrap="yes"`;
|
||||
- holds a carry element whose `data-adf` is no ADF node's JSON, is not written compact with its
|
||||
keys sorted, or holds a number JSON cannot spell;
|
||||
- holds the document's own carry element beside another block.
|
||||
- **Writing, in `adfToLosslessMarkdown`**: a node not riding the carry that carries one mark type
|
||||
twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node
|
||||
holding `content`. Keep the ADF and show the document read-only; where you built the ADF, fix
|
||||
@@ -607,6 +623,7 @@ Serves [Goals](#goals) 1, 4 and 5.
|
||||
nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried
|
||||
opaquely
|
||||
([Unknown nodes ride the carry](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
|
||||
- `htmlToAdf(adfToHtml(doc))` deep-equals `doc` on the same terms.
|
||||
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says. The
|
||||
bullets below name every exception.
|
||||
- CommonMark is valid input to `commonMarkToAdf`, apart from the raw HTML `unmappable-html` names,
|
||||
@@ -689,13 +706,14 @@ Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/do
|
||||
| `ConvertErrorCode` | type | the closed list of codes in [The errors](#the-errors) |
|
||||
| `ConvertErrorPath` | type | an error's place in the document |
|
||||
| `JsonValue` | type | an attribute value |
|
||||
| `ParseError` | type | the error a markdown reader returns, its `position` always set |
|
||||
| `ParseError` | type | the error a markdown reader or `htmlToAdf` returns, its `position` always set |
|
||||
| `Result` | type | every call's return value |
|
||||
| `SourcePosition` | type | an error's place in the markdown |
|
||||
| `adfToHtml` | function | ADF to HTML |
|
||||
| `adfToLosslessMarkdown` | function | ADF to the lossless flavour |
|
||||
| `adfToPortableMarkdown` | function | ADF to portable markdown, lossy |
|
||||
| `commonMarkToAdf` | function | strict CommonMark to ADF |
|
||||
| `htmlToAdf` | function | the HTML `adfToHtml` writes to ADF |
|
||||
| `isAdfDocument` | function | type guard for `AdfDocument` |
|
||||
| `losslessMarkdownToAdf` | function | the lossless flavour to ADF |
|
||||
| `portableMarkdownToAdf` | function | portable markdown to ADF |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
try {
|
||||
const { adfToHtml, adfToLosslessMarkdown, isAdfDocument, losslessMarkdownToAdf } = await import('/dist/index.js')
|
||||
const { adfToHtml, adfToLosslessMarkdown, htmlToAdf, 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,8 @@ try {
|
||||
realPayloads: corpus.realPayloads.map(({ json, name }) => {
|
||||
const adf = JSON.parse(json)
|
||||
const emitted = adfToLosslessMarkdown(adf)
|
||||
return { emitted, html: adfToHtml(adf), isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined }
|
||||
const html = adfToHtml(adf)
|
||||
return { emitted, html, htmlParsed: html.ok ? spelled(htmlToAdf(html.value)) : undefined, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined }
|
||||
}),
|
||||
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
|
||||
const adf = JSON.parse(json)
|
||||
|
||||
@@ -133,6 +133,8 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) {
|
||||
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')
|
||||
assert.ok(result.htmlParsed.ok, `its HTML did not read back — ${refusal(result.htmlParsed)}`)
|
||||
assert.deepEqual(revived(result.htmlParsed.value), JSON.parse(json))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ One directory per contract kind:
|
||||
markdown is not canonical.
|
||||
- `errors/` — `<name>.md`: markdown input that must not convert. A `<name>.error` beside it
|
||||
pins which error.
|
||||
- `real-payloads/` — `<name>.json`: ADF Atlassian's editor wrote, round-tripped ADF→markdown→ADF.
|
||||
No expected markdown.
|
||||
- `real-payloads/` — `<name>.json`: ADF Atlassian's editor wrote, round-tripped ADF→markdown→ADF
|
||||
and ADF→HTML→ADF. No expected markdown or HTML.
|
||||
- `commonmark-spec/` — the CommonMark suite run against `commonMarkToAdf` by three checks.
|
||||
`spec.json` is the suite; `refusals.json` pins each refusing example to its error `code`;
|
||||
`exceptions.json` pins each known divergence by `check`, `example`, `kind` and the exact
|
||||
|
||||
@@ -486,3 +486,7 @@ Read most (opens and searches):
|
||||
## Reading cost, chunk d0fc10fb69af, finished 2026-10-26T12:21:13Z
|
||||
|
||||
504845 new input tokens before the first commit.
|
||||
|
||||
## Reading cost, chunk 62dfe3cc2720, finished 2026-10-26T20:21:53Z
|
||||
|
||||
90793 new input tokens before the first commit.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, isAdfDocument, losslessMarkdownToAdf, portableMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
|
||||
import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, htmlToAdf, isAdfDocument, losslessMarkdownToAdf, portableMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
|
||||
|
||||
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
|
||||
|
||||
@@ -9,7 +9,8 @@ const portableEmitted: Result<string> = adfToPortableMarkdown(document)
|
||||
const portableParsed: Result<AdfDocument, ParseError> = portableMarkdownToAdf('x\n')
|
||||
const commonMarkParsed: Result<AdfDocument, ParseError> = commonMarkToAdf('x\n')
|
||||
const guarded: boolean = isAdfDocument(document)
|
||||
const htmlParsed: Result<AdfDocument, ParseError> = htmlToAdf('<p>x</p>\n')
|
||||
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
||||
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
|
||||
|
||||
export const surface = { code, commonMarkParsed, guarded, htmlEmitted, line, portableEmitted, portableParsed }
|
||||
export const surface = { code, commonMarkParsed, guarded, htmlEmitted, htmlParsed, line, portableEmitted, portableParsed }
|
||||
|
||||
+18
-3
@@ -1,8 +1,8 @@
|
||||
# The HTML dialect
|
||||
|
||||
The HTML `adfToHtml` writes: semantic elements for the nodes and marks below, and the carry element
|
||||
for every other node (`docs/decisions.md` §The HTML dialect). The written form is contract
|
||||
(`docs/decisions.md` §The formats are API).
|
||||
The HTML `adfToHtml` writes and `htmlToAdf` reads: semantic elements for the nodes and marks below,
|
||||
and the carry element for every other node (`docs/decisions.md` §The HTML dialect). The written form
|
||||
is contract (`docs/decisions.md` §The formats are API).
|
||||
|
||||
## Blocks
|
||||
|
||||
@@ -116,3 +116,18 @@ later release writes it as its own element.
|
||||
- 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.
|
||||
|
||||
## Reading
|
||||
|
||||
`htmlToAdf` reads exactly the HTML above, so `htmlToAdf(adfToHtml(doc))` deep-equals `doc`. A carry
|
||||
element reads from its `data-adf` alone, so editing what it shows doesn't change the document read.
|
||||
|
||||
- Any other HTML is `unmappable-html`: another element, attribute or escape, attributes in another
|
||||
order, a block on the line of another, or no line feed at the end.
|
||||
- A `data-*` attribute naming no attribute its node holds apart from its element, or holding a value
|
||||
outside that attribute's kind or not written as compact JSON, is `unsupported-node-shape`.
|
||||
- A carry element's `data-adf` holding invalid JSON is `unmappable-html`. JSON that is no ADF node,
|
||||
is not compact with its keys sorted, or holds a number JSON cannot spell is
|
||||
`unsupported-node-shape`, and so is the document's own carry element beside another block.
|
||||
- Blocks nest at most 500 levels, and a carried node's JSON at most as many as its position leaves,
|
||||
as in writing; past that is `unsupported-nesting-depth`.
|
||||
|
||||
@@ -33,7 +33,7 @@ export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocab
|
||||
return pairs
|
||||
}
|
||||
|
||||
function vocabularyPair(key: string, value: JsonValue, kind: AttributeKind): VocabularyPair | undefined {
|
||||
export function vocabularyPair(key: string, value: JsonValue, kind: AttributeKind): VocabularyPair | undefined {
|
||||
if (kind === 'boolean') return typeof value === 'boolean' ? { key, kind, value } : undefined
|
||||
if (kind === 'number') return typeof value === 'number' ? { key, kind, value } : undefined
|
||||
if (kind === 'numbers') return isNumberList(value) ? { key, kind, value } : undefined
|
||||
|
||||
@@ -7,11 +7,13 @@ import { adfDocument, blockTaskItemChildren, propertyRuns, propertyTimeout } fro
|
||||
import { adfToHtml } from '../html/emit/adf-to-html.ts'
|
||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { adfToPortableMarkdown, reduceToPortable } from '../markdown/portable/adf-to-portable-markdown.ts'
|
||||
import { htmlToAdf } from '../html/parse/html-to-adf.ts'
|
||||
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to-adf.ts'
|
||||
import { toEditorNormal } from '../markdown/portable/editor-normal.ts'
|
||||
|
||||
const gateRuns = 1600
|
||||
const htmlPieces = fc.constantFrom('<', '>', '&', '"', '\n', '\r', '\0', '&', '"', ''', '<p>', '</p>', '<li>', '</li>', '<ul>\n', '\n</ul>', '<br>', '<em>', '</em>', '<a href="x">', '</span>', '</div>', ' data-local-id="x"', ' start="2"', '<div class="adf-carry" data-adf="{"type":"x"}">', '<img src="x">', 'é🎉')
|
||||
// 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
|
||||
|
||||
@@ -88,6 +90,37 @@ test('a generated document writes HTML holding no character HTML cannot hold, an
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes HTML that reads back to it', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const written = adfToHtml(document)
|
||||
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||
const read = htmlToAdf(written.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
|
||||
assert.deepEqual(read.value, document, `reading ${JSON.stringify(written.value)}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
|
||||
test('HTML a generated document writes, edited, reads to a result, and every document read writes HTML reading back to it', { timeout: propertyTimeout }, () => {
|
||||
const edits = fc.array(fc.tuple(fc.nat(), fc.nat({ max: 3 }), htmlPieces), { maxLength: 4, minLength: 1 })
|
||||
fc.assert(
|
||||
fc.property(adfDocument, edits, (document, cuts) => {
|
||||
const written = adfToHtml(document)
|
||||
assert.ok(written.ok)
|
||||
let html = written.value
|
||||
for (const [at, removed, piece] of cuts) html = `${html.slice(0, at % (html.length + 1))}${piece}${html.slice((at % (html.length + 1)) + removed)}`
|
||||
const read = htmlToAdf(html)
|
||||
if (!read.ok) return
|
||||
const rewritten = adfToHtml(read.value)
|
||||
assert.ok(rewritten.ok, rewritten.ok ? '' : `${rewritten.error.code}: ${rewritten.error.message} — reading ${JSON.stringify(html)}`)
|
||||
assert.deepEqual(htmlToAdf(rewritten.value), { ok: true, value: read.value }, `reading ${JSON.stringify(html)}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes the portable markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from 'node:test'
|
||||
|
||||
import { adfToHtml } from '../html/emit/adf-to-html.ts'
|
||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { htmlToAdf } from '../html/parse/html-to-adf.ts'
|
||||
import { isAdfDocument } from '../adf/document.ts'
|
||||
import { isJsonValue } from '../json-value.ts'
|
||||
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
@@ -150,6 +151,16 @@ for (const name of names(realPayloadsRoot, '.json')) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of names(realPayloadsRoot, '.json')) {
|
||||
test(`real-payloads/${name} writes HTML that reads back to it`, () => {
|
||||
const payload: unknown = JSON.parse(readFileSync(join(realPayloadsRoot, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
|
||||
const written = adfToHtml(payload)
|
||||
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||
assert.deepEqual(htmlToAdf(written.value), { ok: true, value: payload })
|
||||
})
|
||||
}
|
||||
|
||||
// The opening tag of each block type whose only attributes are `localId`, `level` and `order`, as the editor writes them.
|
||||
const blockElements: Readonly<Record<string, string>> = { blockquote: '<blockquote', bulletList: '<ul', heading: '<h', orderedList: '<ol', paragraph: '<p', rule: '<hr' }
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AdfAttributes, AdfNode } from '../adf/document.ts'
|
||||
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||
import type { ConvertFault, Read } from '../result.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { holdsOnlyAttributes, nodeAttrs } from '../adf/document.ts'
|
||||
import { optionalAttribute } from './escaping.ts'
|
||||
import { parseJsonText } from '../json-text.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { vocabularyPair, vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||
|
||||
// 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 the vocabulary does not hold as that kind (docs/decisions.md §The HTML dialect).
|
||||
export function dataAttributes(node: AdfNode, vocabulary: AttributeVocabulary, written: readonly string[]): string | undefined {
|
||||
if (!holdsOnlyAttributes(node, Object.keys(vocabulary))) return undefined
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), vocabulary, 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(dataName(key), kind === 'string' ? value : serializeCanonicalJson(value, 'compact'))
|
||||
if (attribute === undefined) return undefined
|
||||
attributes += attribute
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
// What `dataAttributes` wrote, its pairs' values unescaped. A pair it does not write in that order is no HTML of the dialect; a name or value no attribute of the node spells is the node's shape, as a directive's is (docs/decisions.md §Which code a cause takes).
|
||||
export function readDataAttributes(pairs: readonly (readonly [string, string])[], type: string, vocabulary: AttributeVocabulary, written: readonly string[]): Read<AdfAttributes> {
|
||||
const attrs: AdfAttributes = {}
|
||||
let previous = ''
|
||||
for (const [name, spelled] of pairs) {
|
||||
const [key, kind] = Object.entries(vocabulary).find(([candidate]) => !written.includes(candidate) && dataName(candidate) === name) ?? []
|
||||
if (key === undefined || kind === undefined) {
|
||||
return { fault: name.startsWith('data-') ? { code: 'unsupported-node-shape', message: `${name} names no attribute ${type} holds apart from its element` } : unmappable(`${name} is no attribute the ${type} element takes`) }
|
||||
}
|
||||
if (key <= previous) return { fault: unmappable(`the data-* attributes stand once each, sorted by the attribute they name: found ${name} after ${dataName(previous)}`) }
|
||||
const value = dataValue(spelled, kind)
|
||||
if (value === undefined) return { fault: { code: 'unsupported-node-shape', message: `${name} holds a value ${type}'s ${key} does not take, or does not spell it as compact JSON` } }
|
||||
attrs[key] = value
|
||||
previous = key
|
||||
}
|
||||
return { value: attrs }
|
||||
}
|
||||
|
||||
function dataName(key: string): string {
|
||||
return `data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`
|
||||
}
|
||||
|
||||
function dataValue(spelled: string, kind: AttributeKind): JsonValue | undefined {
|
||||
if (kind === 'string') return spelled
|
||||
const { value } = parseJsonText(spelled)
|
||||
return value !== undefined && serializeCanonicalJson(value, 'compact') === spelled && vocabularyPair('', value, kind) !== undefined ? value : undefined
|
||||
}
|
||||
|
||||
function unmappable(message: string): ConvertFault {
|
||||
return { code: 'unmappable-html', message }
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { anchorOpener, languageClass, optionalAttribute, writableValue } from './escaping.ts'
|
||||
import { anchorOpener, languageClass, optionalAttribute, writableValue } from '../escaping.ts'
|
||||
import { blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { carriedBlock, carryElement } from './carry-element.ts'
|
||||
import { dataAttributes } from './data-attributes.ts'
|
||||
import { dataAttributes } from '../data-attributes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineHtml, writableText } from './inline.ts'
|
||||
import { isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { LinkAttrs } from '../../adf/mark-attributes.ts'
|
||||
import { anchorOpener, jsonValue, languageClass, shownValue } from './escaping.ts'
|
||||
import { anchorOpener, jsonValue, languageClass, shownValue } from '../escaping.ts'
|
||||
import { carriedJson } from '../../adf/carried-json.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
|
||||
import { holdsOnlyAttributes, nodeAttrs } from '../../adf/document.ts'
|
||||
import { optionalAttribute } from './escaping.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||
|
||||
// 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 the vocabulary does not hold as that kind (docs/decisions.md §The HTML dialect).
|
||||
export function dataAttributes(node: AdfNode, vocabulary: AttributeVocabulary, written: readonly string[]): string | undefined {
|
||||
if (!holdsOnlyAttributes(node, Object.keys(vocabulary))) return undefined
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), vocabulary, 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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { anchorOpener, writableValue } from './escaping.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 { linkAttrs } from '../../adf/mark-attributes.ts'
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { JsonValue } from '../../json-value.ts'
|
||||
import type { LinkAttrs } from '../../adf/mark-attributes.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import type { LinkAttrs } from '../adf/mark-attributes.ts'
|
||||
|
||||
const entities: ReadonlyMap<string, string> = new Map([
|
||||
['&', '&'],
|
||||
['>', '>'],
|
||||
['<', '<'],
|
||||
['"', '"'],
|
||||
])
|
||||
const languageToken = /^[^\t\n\f\r ]+$/
|
||||
// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): any control but a tab, line feed or form feed, a carriage return among them, since a parser reads it 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')
|
||||
@@ -11,15 +18,25 @@ export function anchorOpener({ href, title }: LinkAttrs): string | undefined {
|
||||
return url === undefined || titled === undefined ? undefined : `<a href="${url}"${titled}>`
|
||||
}
|
||||
|
||||
// The language a `<code>`'s class names, `undefined` where `languageClass` writes no such class.
|
||||
export function classLanguage(className: string): string | undefined {
|
||||
const language = className.startsWith('language-') ? className.slice('language-'.length) : undefined
|
||||
return language !== undefined && languageToken.test(language) ? language : undefined
|
||||
}
|
||||
|
||||
// The carry's JSON, which spells each character HTML cannot hold as a JSON escape reading back to the same string.
|
||||
export function holdableJson(json: string): string {
|
||||
return json.replace(unwritables, (character) => jsonEscape(character))
|
||||
}
|
||||
|
||||
export function jsonValue(json: string): string {
|
||||
return escapeHtml(json.replace(unwritables, (character) => jsonEscape(character)))
|
||||
return escapeHtml(holdableJson(json))
|
||||
}
|
||||
|
||||
// A code block's ` class="language-…"`, `''` where it holds no language, `undefined` where a class cannot hold the language as one token.
|
||||
export function languageClass(language: JsonValue | undefined): string | undefined {
|
||||
if (language === undefined) return ''
|
||||
return typeof language === 'string' && /^[^\t\n\f\r ]+$/.test(language) ? optionalAttribute('class', `language-${language}`) : undefined
|
||||
return typeof language === 'string' && languageToken.test(language) ? optionalAttribute('class', `language-${language}`) : undefined
|
||||
}
|
||||
|
||||
// ` name="value"`, `''` where the value is undefined, `undefined` where HTML cannot hold it.
|
||||
@@ -33,6 +50,12 @@ export function shownValue(value: string, lineFeed: string): string {
|
||||
return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', lineFeed)
|
||||
}
|
||||
|
||||
// The text or attribute value escaped HTML spells, `undefined` where `writableValue` would not write it so.
|
||||
export function unescapedValue(html: string): string | undefined {
|
||||
const value = html.replace(/&(?:amp|gt|lt|quot);/g, (entity) => entities.get(entity) ?? entity)
|
||||
return writableValue(value) === html ? value : undefined
|
||||
}
|
||||
|
||||
// `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)
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { JsonValue } from '../../json-value.ts'
|
||||
import type { ParseError, Result } from '../../result.ts'
|
||||
import { holdableJson } from '../escaping.ts'
|
||||
import { isAdfNode } from '../../adf/document.ts'
|
||||
import { overNested } from '../../json-value.ts'
|
||||
import { parseJsonText } from '../../json-text.ts'
|
||||
import { refused, type Place, type StartTag } from './source.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { success } from '../../result.ts'
|
||||
|
||||
// The JSON a carry element holds, and the end of the element; what it shows is skipped unread (docs/decisions.md §The HTML dialect).
|
||||
export function readCarry(html: string, tag: StartTag, levels: number, place: Place): Result<{ end: number; held: JsonValue }, ParseError> {
|
||||
const [kind, held, ...others] = tag.attributes
|
||||
if (kind?.[0] !== 'class' || kind[1] !== 'adf-carry' || held?.[0] !== 'data-adf' || others.length > 0) {
|
||||
return refused('unmappable-html', `a <${tag.name}> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone`, html, tag.end, place)
|
||||
}
|
||||
const json = held[1]
|
||||
const parsed = parseJsonText(json)
|
||||
if (parsed.refusal === 'syntax') return refused('unmappable-html', "a carry element's data-adf holds invalid JSON", html, tag.end, place)
|
||||
if (parsed.refusal !== undefined) return refused('unsupported-node-shape', "a carry element's data-adf holds a number JSON cannot spell", html, tag.end, place)
|
||||
if (overNested(parsed.value, levels)) return refused('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, html, tag.end, place)
|
||||
if (holdableJson(serializeCanonicalJson(parsed.value, 'compact')) !== json) {
|
||||
return refused('unsupported-node-shape', "a carry element spells its node's JSON canonically: compact, keys sorted, a character HTML cannot hold as a JSON escape", html, tag.end, place)
|
||||
}
|
||||
const closer = `</${tag.name}>`
|
||||
const end = html.indexOf(closer, tag.end)
|
||||
return end < 0 ? refused('unmappable-html', `a carry element ends at ${closer}: found none`, html, tag.end, place) : success({ end: end + closer.length, held: parsed.value })
|
||||
}
|
||||
|
||||
export function heldNode(held: JsonValue, html: string, index: number, place: Place): Result<AdfNode, ParseError> {
|
||||
return isAdfNode(held) ? success(held) : refused('unsupported-node-shape', "a carry element's data-adf holds one ADF node's JSON: this JSON is no ADF node", html, index, place)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { ParseError, Result } from '../../result.ts'
|
||||
import { adfToHtml, htmlToAdf } from '../../index.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
|
||||
const ids = {
|
||||
item: '019ad5af-efd9-7f4e-a7c9-6aef16d4945f',
|
||||
list: '019d8e77-7df8-7a83-91ea-43a7ca2aae3b',
|
||||
paragraph: '01957ad9-e3f7-70aa-a5f3-4c013274f488',
|
||||
quote: '019d2fe4-3e67-7b76-b40a-a776a8f76aea',
|
||||
}
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
function paragraph(...content: AdfNode[]): AdfNode {
|
||||
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
|
||||
}
|
||||
|
||||
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function item(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'listItem' }
|
||||
}
|
||||
|
||||
function read(result: Result<AdfDocument, ParseError>): AdfDocument | string {
|
||||
return result.ok ? result.value : `${result.error.code}: ${result.error.message}`
|
||||
}
|
||||
|
||||
function refusal(result: Result<AdfDocument, ParseError>): string {
|
||||
return result.ok ? 'read' : `${result.error.code} at /${result.error.path.join('/')} line ${result.error.position.line} offset ${result.error.position.offset}`
|
||||
}
|
||||
|
||||
// The carry element of `node`, showing `shown`.
|
||||
function carry(tag: 'div' | 'span', node: unknown, shown: string): string {
|
||||
return `<${tag} class="adf-carry" data-adf="${JSON.stringify(node).replaceAll('"', '"')}">${shown}</${tag}>`
|
||||
}
|
||||
|
||||
test('reads an empty string as a document of no blocks, and the document\'s carry as one holding no content key', () => {
|
||||
assert.deepEqual(read(htmlToAdf('')), document())
|
||||
assert.deepEqual(read(htmlToAdf(`${carry('div', { type: 'doc', version: 1 }, '')}\n`)), { type: 'doc', version: 1 })
|
||||
})
|
||||
|
||||
test('reads paragraphs, headings and rules, a line feed after each', () => {
|
||||
const heading = (level: number, ...content: AdfNode[]): AdfNode => (content.length === 0 ? { attrs: { level }, type: 'heading' } : { attrs: { level }, content, type: 'heading' })
|
||||
assert.deepEqual(read(htmlToAdf('<h1>Level 1</h1>\n<p>Read the manual.</p>\n<hr>\n<h6>Level 6</h6>\n')), document(heading(1, text('Level 1')), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6, text('Level 6'))))
|
||||
assert.deepEqual(read(htmlToAdf('<p></p>\n<h2></h2>\n')), document(paragraph(), heading(2)))
|
||||
})
|
||||
|
||||
test('reads text\'s marks from its elements, the outermost first, and each run of text as one text node', () => {
|
||||
const link: AdfMark = { attrs: { href: 'https://example.com/a?b=1&c=2', title: 'The "A" page' }, type: 'link' }
|
||||
const html = '<p>Read <em>the</em> <strong><em>manual</em></strong>: <code><a href="https://example.com/a?b=1&c=2" title="The "A" page">npm ci</a></code> or <a href="https://example.com/">this</a><br>Line two</p>\n'
|
||||
const line = paragraph(text('Read '), text('the', { type: 'em' }), text(' '), text('manual', { type: 'strong' }, { type: 'em' }), text(': '), text('npm ci', { type: 'code' }, link), text(' or '), text('this', { attrs: { href: 'https://example.com/' }, type: 'link' }), { type: 'hardBreak' }, text('Line two'))
|
||||
assert.deepEqual(read(htmlToAdf(html)), document(line))
|
||||
assert.deepEqual(read(htmlToAdf('<p><a href="x" title="">y</a></p>\n')), document(paragraph(text('y', { attrs: { href: 'x', title: '' }, type: 'link' }))))
|
||||
})
|
||||
|
||||
test('reads escaped text, and keeps tabs, line feeds and form feeds', () => {
|
||||
assert.deepEqual(read(htmlToAdf('<p>a < b && c > "d" \'e\'\tf\ng\fh &lt;</p>\n')), document(paragraph(text('a < b && c > "d" \'e\'\tf\ng\fh <'))))
|
||||
})
|
||||
|
||||
test('reads lists, an item\'s first paragraph on its line and its other blocks a line each', () => {
|
||||
assert.deepEqual(read(htmlToAdf('<ul>\n<li>Bolt</li>\n<li>Nut</li>\n</ul>\n')), document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))
|
||||
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.deepEqual(read(htmlToAdf('<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')), document(listed))
|
||||
assert.deepEqual(read(htmlToAdf('<ol>\n<li>\n<hr>\n</li>\n<li></li>\n</ol>\n')), document({ content: [item({ type: 'rule' }), { type: 'listItem' }], type: 'orderedList' }))
|
||||
assert.deepEqual(read(htmlToAdf('<ul>\n<li>\n<p></p>\n</li>\n</ul>\n')), document({ content: [item(paragraph())], type: 'bulletList' }))
|
||||
assert.deepEqual(read(htmlToAdf('<ol start="-2">\n<li>x</li>\n</ol>\n')), document({ attrs: { order: -2 }, content: [item(paragraph(text('x')))], type: 'orderedList' }))
|
||||
})
|
||||
|
||||
test('reads the text ending an item\'s line up to the line feed before the item\'s next block', () => {
|
||||
const listed = (...blocks: AdfNode[]): AdfDocument => document({ content: [item(...blocks)], type: 'bulletList' })
|
||||
assert.deepEqual(read(htmlToAdf('<ul>\n<li>a\n\n<p>b</p>\n</li>\n</ul>\n')), listed(paragraph(text('a\n')), paragraph(text('b'))))
|
||||
assert.deepEqual(read(htmlToAdf('<ul>\n<li><br>\n<hr>\n</li>\n</ul>\n')), listed(paragraph({ type: 'hardBreak' }), { type: 'rule' }))
|
||||
assert.deepEqual(read(htmlToAdf('<ul>\n<li>\n</li>\n</ul>\n')), listed(paragraph(text('\n'))))
|
||||
assert.deepEqual(read(htmlToAdf('<ul>\n<li>\n<em>a</em></li>\n</ul>\n')), listed(paragraph(text('\n'), text('a', { type: 'em' }))))
|
||||
})
|
||||
|
||||
test('reads an item\'s first paragraph holding an attribute as its element on the item\'s line', () => {
|
||||
const listed = (...blocks: AdfNode[]): AdfDocument => document({ content: [item(...blocks)], type: 'bulletList' })
|
||||
const held: AdfNode = { attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' }
|
||||
assert.deepEqual(read(htmlToAdf(`<ul>\n<li><p data-local-id="${ids.paragraph}">Bolt</p></li>\n</ul>\n`)), listed(held))
|
||||
assert.deepEqual(read(htmlToAdf(`<ul>\n<li><p data-local-id="${ids.paragraph}">Bolt</p>\n<ul>\n<li>M8</li>\n</ul>\n</li>\n</ul>\n`)), listed(held, { content: [item(paragraph(text('M8')))], type: 'bulletList' }))
|
||||
assert.deepEqual(read(htmlToAdf(`<ul>\n<li><p data-local-id="${ids.paragraph}"></p></li>\n</ul>\n`)), listed({ attrs: { localId: ids.paragraph }, type: 'paragraph' }))
|
||||
})
|
||||
|
||||
test('reads data-* attributes as the attributes they name, a value that is no string from its JSON', () => {
|
||||
const localId = { localId: ids.paragraph }
|
||||
assert.deepEqual(read(htmlToAdf(`<h2 data-local-id="${ids.paragraph}">x</h2>\n`)), document({ attrs: { level: 2, ...localId }, content: [text('x')], type: 'heading' }))
|
||||
assert.deepEqual(read(htmlToAdf(`<blockquote data-local-id="${ids.quote}">\n<p>x</p>\n</blockquote>\n`)), document({ attrs: { localId: ids.quote }, content: [paragraph(text('x'))], type: 'blockquote' }))
|
||||
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.deepEqual(read(htmlToAdf(`<ol start="3" data-local-id="${ids.list}">\n<li data-local-id="${ids.item}"><p data-local-id="${ids.paragraph}">Bolt</p></li>\n</ol>\n`)), document(listed))
|
||||
const code: AdfNode = { attrs: { hideLineNumbers: false, language: 'js', wrap: true, ...localId }, content: [text('x')], type: 'codeBlock' }
|
||||
assert.deepEqual(read(htmlToAdf(`<pre data-hide-line-numbers="false" data-local-id="${ids.paragraph}" data-wrap="true"><code class="language-js">x</code></pre>\n`)), document(code))
|
||||
assert.deepEqual(read(htmlToAdf('<hr data-color="#ccc" data-style="a"b" data-weight="-0">\n')), document({ attrs: { color: '#ccc', style: 'a"b', weight: -0 }, type: 'rule' }))
|
||||
})
|
||||
|
||||
test('reads quotes, code blocks and containers holding no content key', () => {
|
||||
assert.deepEqual(read(htmlToAdf('<blockquote>\n<p>Quoted</p>\n<blockquote>\n<p>Twice</p>\n</blockquote>\n</blockquote>\n')), document({ content: [paragraph(text('Quoted')), { content: [paragraph(text('Twice'))], type: 'blockquote' }], type: 'blockquote' }))
|
||||
assert.deepEqual(read(htmlToAdf('<pre><code class="language-javascript">if (a < b)\n run()</code></pre>\n')), document({ attrs: { language: 'javascript' }, content: [text('if (a < b)\n run()')], type: 'codeBlock' }))
|
||||
assert.deepEqual(read(htmlToAdf('<pre><code>plain</code></pre>\n<pre><code></code></pre>\n')), document({ content: [text('plain')], type: 'codeBlock' }, { type: 'codeBlock' }))
|
||||
assert.deepEqual(read(htmlToAdf('<blockquote></blockquote>\n<ul></ul>\n<ol></ol>\n')), document({ type: 'blockquote' }, { type: 'bulletList' }, { type: 'orderedList' }))
|
||||
})
|
||||
|
||||
test('reads an image standing alone in a paragraph, a link around it where one marks it', () => {
|
||||
const image = (media: AdfNode): AdfNode => ({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' })
|
||||
assert.deepEqual(read(htmlToAdf('<p><img src="https://example.com/moon.jpg" alt="The moon"></p>\n')), document(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' })))
|
||||
const linked = image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { href: 'https://example.com/', title: 'Home' }, type: 'link' }], type: 'media' })
|
||||
assert.deepEqual(read(htmlToAdf('<p><a href="https://example.com/" title="Home"><img src="https://example.com/moon.jpg"></a></p>\n')), document(linked))
|
||||
})
|
||||
|
||||
test('reads a carry element from its data-adf alone, whatever it shows', () => {
|
||||
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' }
|
||||
const mention: AdfNode = { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }
|
||||
const expected = document(panel, paragraph(text('Ask '), mention))
|
||||
assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '<p>Check the collation.</p>')}\n<p>Ask ${carry('span', mention, '@Mikael')}</p>\n`)), expected)
|
||||
assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '<p>Edited <b>away</b> </p>')}\n<p>Ask ${carry('span', mention, '')}</p>\n`)), expected)
|
||||
assert.deepEqual(read(htmlToAdf(`<p>${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')}</p>\n`)), document(paragraph(text('a\u0085b', { type: 'underline' }))))
|
||||
})
|
||||
|
||||
test('reads a carry element holding a node HTML writes as its own element as that node', () => {
|
||||
assert.deepEqual(read(htmlToAdf(`${carry('div', paragraph(text('x')), '<p>x</p>')}\n`)), document(paragraph(text('x'))))
|
||||
assert.deepEqual(read(htmlToAdf(`<p>a${carry('span', text('b'), 'b')}</p>\n`)), document(paragraph(text('a'), text('b'))))
|
||||
assert.deepEqual(read(htmlToAdf(`<ul>\n<li>\n${carry('div', paragraph(text('x')), '<p>x</p>')}\n</li>\n</ul>\n`)), document({ content: [item(paragraph(text('x')))], type: 'bulletList' }))
|
||||
})
|
||||
|
||||
test('refuses a value that is not a string as not-a-string', () => {
|
||||
for (const value of [null, 5, undefined, {}]) {
|
||||
// @ts-expect-error -- a caller without types can pass anything.
|
||||
assert.equal(refusal(htmlToAdf(value)), 'not-a-string at / line 1 offset 0')
|
||||
}
|
||||
})
|
||||
|
||||
test('refuses HTML adfToHtml does not write as unmappable-html, at the start of the line it stands on', () => {
|
||||
const refused = (html: string): string => refusal(htmlToAdf(html))
|
||||
assert.equal(refused('<p>x</p>'), 'unmappable-html at / line 1 offset 0')
|
||||
assert.equal(refused('<p>x</p>\n\n'), 'unmappable-html at /content/1 line 2 offset 9')
|
||||
assert.equal(refused('\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a</p>\n<div>b</div>\n'), 'unmappable-html at /content/1 line 2 offset 9')
|
||||
assert.equal(refused('<p>a</p>\n<P>b</P>\n'), 'unmappable-html at /content/1 line 2 offset 9')
|
||||
assert.equal(refused('<h7>x</h7>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p class="x">a</p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a<b>b</b></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a<br/>b</p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>it's</p>\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a > b</p>\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a\rb</p>\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
const lines = `${carry('div', { type: 'unknownBlock' }, 'a\r\nb\rc')}\n<i>d</i>\n`
|
||||
assert.equal(refused(lines), `unmappable-html at /content/1 line 4 offset ${lines.indexOf('<i>')}`)
|
||||
assert.equal(refused('<p><a href="x" target="y">a</a></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><a title="t" href="x">a</a></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><a href="a"b">a</a></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('refuses marks adfToHtml would not write around text', () => {
|
||||
const refused = (inline: string): string => refusal(htmlToAdf(`<p>${inline}</p>\n`))
|
||||
assert.equal(refused('<em><em>a</em></em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em></em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em>a<strong>b</strong></em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em><strong>a</em></strong>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em>a &bogus; b</em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('a<a href="x"><img src="y"></a>'), 'unmappable-html at /content/0/content/1 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf('<p><em>a')), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf('<p>a')), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('refuses block shapes adfToHtml would not write', () => {
|
||||
const refused = (html: string): string => refusal(htmlToAdf(html))
|
||||
assert.equal(refused('<ul>\n<li><p>Bolt</p></li>\n</ul>\n'), 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n<li>\n<p>Bolt</p>\n</li>\n</ul>\n'), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10')
|
||||
assert.equal(refused(`<ul>\n<li>\n<p data-local-id="${ids.paragraph}"></p>\n</li>\n</ul>\n`), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10')
|
||||
assert.equal(refused('<ul>\n<li><hr></li>\n</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n<li>a</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused(`<ul>\n<li><p data-local-id="${ids.paragraph}">a</p></ul>\n`), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n<p>a</p>\n</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul><li>a</li></ul>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<blockquote>\n<p>a</p></blockquote>\n'), 'unmappable-html at /content/0 line 2 offset 13')
|
||||
assert.equal(refused('<p>a</p><p>b</p>\n'), 'unmappable-html at / line 1 offset 0')
|
||||
for (const start of ['03', '1.5', '-0', '', 'x', '9007199254740993']) assert.equal(refused(`<ol start="${start}">\n<li>a</li>\n</ol>\n`), 'unmappable-html at /content/0 line 1 offset 0', start)
|
||||
assert.equal(refused('<pre><code class="lang-js">x</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code class="language-">x</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code class="language-js" id="x">x</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre>x</pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code>a &bogus; b</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code>x</code></pre\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('refuses an image adfToHtml would not write', () => {
|
||||
const refused = (inner: string): string => refusal(htmlToAdf(`<p>${inner}</p>\n`))
|
||||
assert.equal(refused('<img src="a" alt="">'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<img alt="b" src="a">'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<img src="a" alt="b" title="c">'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<img src="a">b'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<a href="x" rel="y"><img src="a"></a>'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf('<p data-local-id="a"><img src="a"></p>\n')), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('refuses a data-* attribute out of the order adfToHtml writes as unmappable-html, and one no attribute of its node spells as unsupported-node-shape', () => {
|
||||
const refused = (html: string): string => refusal(htmlToAdf(html))
|
||||
assert.equal(refused('<hr data-style="a" data-color="b">\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<hr data-color="a" data-color="b">\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p data-color="x">a</p>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<h1 data-level="2">a</h1>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<ol data-order="2">\n<li>a</li>\n</ol>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre data-language="js"><code>x</code></pre>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p data-localid="x">a</p>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
for (const wrap of ['yes', ' true', '1', '"true"']) assert.equal(refused(`<pre data-wrap="${wrap.replaceAll('"', '"')}"><code>x</code></pre>\n`), 'unsupported-node-shape at /content/0 line 1 offset 0', wrap)
|
||||
assert.equal(refused('<hr data-weight="1.0">\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<hr data-weight="1e999">\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<ul>\n<li data-x="a">a</li>\n</ul>\n'), 'unsupported-node-shape at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<blockquote data-local-id="a" data-x="b"></blockquote>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('refuses a carry element adfToHtml would not write', () => {
|
||||
const refused = (html: string): string => refusal(htmlToAdf(html))
|
||||
const rule = JSON.stringify({ type: 'unknownBlock' }).replaceAll('"', '"')
|
||||
assert.equal(refused(`<div data-adf="${rule}" class="adf-carry"></div>\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused(`<div class="adf-carry" data-adf="${rule}" id="x"></div>\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused(`<div class="carry" data-adf="${rule}"></div>\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused(`<div class="adf-carry" data-adf="${rule}">\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{"></div>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{"type":1e999}"></div>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{ "type":"x"}"></div>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span class="adf-carry" data-adf="{"text":"a\u0085","type":"text"}"></span></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span class="adf-carry" data-adf="{"type":"text","text":"a"}"></span></p>\n'), 'unsupported-node-shape at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{"type":5}"></div>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span class="adf-carry" data-adf="[]"></span></p>\n'), 'unsupported-node-shape at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span>a</span></p>\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('refuses the document\'s carry anywhere but as the whole document', () => {
|
||||
const documentCarry = carry('div', { type: 'doc', version: 1 }, '')
|
||||
assert.equal(refusal(htmlToAdf(`${documentCarry}\n<hr>\n`)), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf(`<hr>\n${documentCarry}\n`)), 'unsupported-node-shape at /content/1 line 2 offset 5')
|
||||
assert.equal(refusal(htmlToAdf(`<blockquote>\n${documentCarry}\n</blockquote>\n`)), 'unsupported-node-shape at /content/0/content/0 line 2 offset 13')
|
||||
assert.equal(refusal(htmlToAdf(`${carry('div', { type: 'doc', version: 2 }, '')}\n`)), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('reads blocks nested 500 levels, and refuses the next level, a list\'s items one level below it', () => {
|
||||
const quotes = (levels: number): string => `${'<blockquote>\n'.repeat(levels)}<p>x</p>${'\n</blockquote>'.repeat(levels)}\n`
|
||||
assert.equal(refusal(htmlToAdf(quotes(largestNesting))), 'read')
|
||||
assert.equal(refusal(htmlToAdf(quotes(largestNesting + 1))).startsWith(`unsupported-nesting-depth at /${'content/0/'.repeat(largestNesting + 1)}content/0 line ${largestNesting + 2}`), true)
|
||||
const lists = (levels: number): string => `${'<ul>\n<li>x\n'.repeat(levels)}<hr>${'\n</li>\n</ul>'.repeat(levels)}\n`
|
||||
assert.equal(refusal(htmlToAdf(lists(largestNesting))), 'read')
|
||||
assert.equal(refusal(htmlToAdf(lists(largestNesting + 1))).startsWith('unsupported-nesting-depth'), true)
|
||||
assert.equal(refusal(htmlToAdf('<blockquote>\n'.repeat(1_000_000))).startsWith('unsupported-nesting-depth'), true)
|
||||
})
|
||||
|
||||
test('refuses a carried node whose JSON nests past the levels its depth leaves, and an inline carry past 500', () => {
|
||||
let value: unknown = 1
|
||||
for (let level = 0; level < largestNesting - 2; level += 1) value = [value]
|
||||
const carried = carry('div', { attrs: { a: value }, type: 'unknownBlock' }, '')
|
||||
assert.equal(refusal(htmlToAdf(`${carried}\n`)), 'read')
|
||||
assert.equal(refusal(htmlToAdf(`<blockquote>\n${carried}\n</blockquote>\n`)), 'unsupported-nesting-depth at /content/0/content/0 line 2 offset 13')
|
||||
let inline: unknown = { text: 'x', type: 'text' }
|
||||
for (let level = 0; level < largestNesting; level += 1) inline = { content: [inline], type: 'unknownInline' }
|
||||
assert.equal(refusal(htmlToAdf(`<p>${carry('span', inline, '')}</p>\n`)), 'unsupported-nesting-depth at /content/0/content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('reads back what adfToHtml writes for the documents its own tests write', () => {
|
||||
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Run this:')), { attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }], type: 'panel' }
|
||||
for (const written of [document(panel), document(paragraph(text('a'), text('b'), text('c', { type: 'em' }), text('d'), text('e'))), { type: 'doc' as const, version: 1 }]) {
|
||||
const html = adfToHtml(written)
|
||||
assert.ok(html.ok)
|
||||
assert.deepEqual(read(htmlToAdf(html.value)), written)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { AdfAttributes, AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
|
||||
import type { ParseError, Result } from '../../result.ts'
|
||||
import { blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
|
||||
import { classLanguage, unescapedValue } from '../escaping.ts'
|
||||
import { foundAt, readStartTag, refused, type Place, type StartTag } from './source.ts'
|
||||
import { heldNode, readCarry } from './carry-element.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { notAString } from '../../not-a-string.ts'
|
||||
import { positioned, success } from '../../result.ts'
|
||||
import { readDataAttributes } from '../data-attributes.ts'
|
||||
import { readInline, readLink } from './inline.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { withinEngineLimits } from '../../length-bound.ts'
|
||||
|
||||
// A container whose blocks or items are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it.
|
||||
type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'items'; node: AdfNode; place: Place | undefined }
|
||||
|
||||
type Read = { end: number; kind: 'document' } | { end: number; kind: 'node'; node: AdfNode } | { end: number; kind: 'opened'; open: Open }
|
||||
|
||||
type ReadNode = Result<{ end: number; node: AdfNode }, ParseError>
|
||||
|
||||
const blockElements: ReadonlySet<string> = new Set(['blockquote', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'ol', 'p', 'pre', 'ul'])
|
||||
const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact')
|
||||
|
||||
// Reads back exactly what `adfToHtml` writes. Other HTML is `unmappable-html`; a `data-*` name or value no attribute of its node spells is `unsupported-node-shape`, as a directive's attribute is (docs/decisions.md §Which code a cause takes).
|
||||
export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
|
||||
return positioned(
|
||||
withinEngineLimits(() => readHtml(html)),
|
||||
{ line: 1, offset: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
function readHtml(html: string): Result<AdfDocument> {
|
||||
if (typeof html !== 'string') return notAString(html, 'HTML')
|
||||
return html === '' ? success({ content: [], type: 'doc', version: 1 }) : readBlocks(html)
|
||||
}
|
||||
|
||||
// A stack rather than recursion: only this loop opens and closes containers.
|
||||
function readBlocks(html: string): Result<AdfDocument, ParseError> {
|
||||
const root: Open = { closer: '', content: [], depth: 0, kind: 'blocks', node: { type: 'doc' }, place: undefined }
|
||||
const opened: Open[] = []
|
||||
let open = root
|
||||
let index = 0
|
||||
for (;;) {
|
||||
const place = { index: open.content.length, parent: open.place }
|
||||
if (open.depth > largestNesting) return refused('unsupported-nesting-depth', `the HTML nests deeper than the ${largestNesting} levels the reader carries`, html, index, place)
|
||||
const read = open.kind === 'items' ? readItem(html, index, place, open.depth) : readBlock(html, index, place, open)
|
||||
if (!read.ok) return read
|
||||
index = read.value.end
|
||||
if (read.value.kind === 'document') {
|
||||
if (open === root && open.content.length === 0 && index === html.length - 1 && html.endsWith('\n')) return success({ type: 'doc', version: 1 })
|
||||
return refused('unsupported-node-shape', 'a carry element holding the document stands alone, as the whole document', html, index, place)
|
||||
}
|
||||
if (read.value.kind === 'opened') {
|
||||
opened.push(open)
|
||||
open = read.value.open
|
||||
continue
|
||||
}
|
||||
open.content.push(read.value.node)
|
||||
while (open !== root && html.startsWith(open.closer, index)) {
|
||||
index += open.closer.length
|
||||
const closed: AdfNode = { ...open.node, content: open.content }
|
||||
open = opened.pop() ?? root
|
||||
open.content.push(closed)
|
||||
}
|
||||
if (open === root && index === html.length - 1 && html.endsWith('\n')) return success({ content: root.content, type: 'doc', version: 1 })
|
||||
if (!html.startsWith('\n', index)) return refused('unmappable-html', `a line feed parts each block from the next, and ends the HTML: ${foundAt(html, index)}`, html, index, open.place)
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
function readBlock(html: string, index: number, place: Place, open: Open): Result<Read, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag === undefined) return refused('unmappable-html', `a block element of the dialect stands here: ${foundAt(html, index)}`, html, index, place)
|
||||
const level = /^h([1-6])$/.exec(tag.name)?.[1]
|
||||
if (level !== undefined) return asNode(readInlineBlock(html, tag, 'heading', blockNodes.heading.attributes, { level: Number(level) }, place))
|
||||
if (tag.name === 'p') return asNode(readParagraph(html, tag, place, open.kind === 'item' && open.content.length === 0))
|
||||
if (tag.name === 'pre') return asNode(readCodeBlock(html, tag, place))
|
||||
if (tag.name === 'hr') return asNode(readRule(html, tag, place))
|
||||
if (tag.name === 'div') return readBlockCarry(html, tag, place, open.depth)
|
||||
if (['blockquote', 'ol', 'ul'].includes(tag.name)) return readContainer(html, tag, place, open.depth)
|
||||
return refused('unmappable-html', `<${tag.name}> is no block element of the dialect`, html, index, place)
|
||||
}
|
||||
|
||||
function asNode(read: ReadNode): Result<Read, ParseError> {
|
||||
return read.ok ? success({ ...read.value, kind: 'node' }) : read
|
||||
}
|
||||
|
||||
// `firstInItem`: the paragraph opens an item read block by block, which the writer does only where the paragraph holds neither content nor an attribute.
|
||||
function readParagraph(html: string, tag: StartTag, place: Place, firstInItem: boolean): ReadNode {
|
||||
const image = tag.attributes.length === 0 ? tryImage(html, tag.end, place) : undefined
|
||||
if (image !== undefined) return image
|
||||
const paragraph = readInlineBlock(html, tag, 'paragraph', blockNodes.paragraph.attributes, {}, place)
|
||||
if (!firstInItem || !paragraph.ok || (paragraph.value.node.attrs === undefined && paragraph.value.node.content === undefined)) return paragraph
|
||||
return refused('unmappable-html', "an item's first paragraph holding content or an attribute stands on the item's line, straight after <li>", html, tag.start, place)
|
||||
}
|
||||
|
||||
// `own`: the attributes the element itself writes, such as a heading's level.
|
||||
function readInlineBlock(html: string, tag: StartTag, type: string, vocabulary: AttributeVocabulary, own: AdfAttributes, place: Place): ReadNode {
|
||||
const data = readDataAttributes(tag.attributes, type, vocabulary, Object.keys(own))
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const inline = readInline(html, tag.end, place)
|
||||
if (!inline.ok) return inline
|
||||
const { end, nodes } = inline.value
|
||||
const closer = `</${tag.name}>`
|
||||
if (!html.startsWith(closer, end)) return refused('unmappable-html', `${closer} ends the ${type}, which holds inline elements alone: ${foundAt(html, end)}`, html, end, place)
|
||||
return success({ end: end + closer.length, node: built(type, { ...own, ...data.value }, nodes) })
|
||||
}
|
||||
|
||||
// `undefined` where no image follows the `<p>`, so it opens a paragraph.
|
||||
function tryImage(html: string, index: number, place: Place): ReadNode | undefined {
|
||||
const anchor = readStartTag(html, index)
|
||||
const linked = anchor?.name === 'a'
|
||||
const image = readStartTag(html, linked ? anchor.end : index)
|
||||
if (image?.name !== 'img') return undefined
|
||||
const link = linked ? readLink(anchor) : undefined
|
||||
const [src, alt, ...others] = image.attributes
|
||||
const closer = linked ? '</a></p>' : '</p>'
|
||||
if ((linked && link === undefined) || src?.[0] !== 'src' || (alt !== undefined && (alt[0] !== 'alt' || alt[1] === '')) || others.length > 0 || !html.startsWith(closer, image.end)) {
|
||||
return refused('unmappable-html', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it', html, image.start, place)
|
||||
}
|
||||
return success({ end: image.end + closer.length, node: centeredImage([externalMedia(alt?.[1] ?? '', src[1], link)]) })
|
||||
}
|
||||
|
||||
function readCodeBlock(html: string, tag: StartTag, place: Place): ReadNode {
|
||||
const data = readDataAttributes(tag.attributes, 'codeBlock', blockNodes.codeBlock.attributes, ['language'])
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const code = readStartTag(html, tag.end)
|
||||
const [className, ...others] = code?.attributes ?? []
|
||||
const language = className?.[0] === 'class' ? classLanguage(className[1]) : undefined
|
||||
const next = code === undefined ? -1 : html.indexOf('<', code.end)
|
||||
const end = next < 0 ? html.length : next
|
||||
const text = code === undefined ? undefined : unescapedValue(html.slice(code.end, end))
|
||||
if (code?.name !== 'code' || (className !== undefined && language === undefined) || others.length > 0 || text === undefined || !html.startsWith('</code></pre>', end)) {
|
||||
return refused('unmappable-html', 'a code block is <pre><code class="language-…">, its text escaped, then </code></pre>', html, tag.end, place)
|
||||
}
|
||||
const attrs = language === undefined ? data.value : { ...data.value, language }
|
||||
return success({ end: end + '</code></pre>'.length, node: built('codeBlock', attrs, text === '' ? [] : [{ text, type: 'text' }]) })
|
||||
}
|
||||
|
||||
function readRule(html: string, tag: StartTag, place: Place): ReadNode {
|
||||
const data = readDataAttributes(tag.attributes, 'rule', blockNodes.rule.attributes, [])
|
||||
return data.fault === undefined ? success({ end: tag.end, node: built('rule', data.value, []) }) : refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
}
|
||||
|
||||
function readBlockCarry(html: string, tag: StartTag, place: Place, depth: number): Result<Read, ParseError> {
|
||||
const carry = readCarry(html, tag, largestNesting - depth, place)
|
||||
if (!carry.ok) return carry
|
||||
if (tag.attributes[1]?.[1] === emptyDocument) return success({ end: carry.value.end, kind: 'document' })
|
||||
const node = heldNode(carry.value.held, html, tag.start, place)
|
||||
return node.ok ? success({ end: carry.value.end, kind: 'node', node: node.value }) : node
|
||||
}
|
||||
|
||||
// A quote's blocks, or a list's items, stand a line each between its tags; a container holding no content key holds nothing.
|
||||
function readContainer(html: string, tag: StartTag, place: Place, depth: number): Result<Read, ParseError> {
|
||||
const type = tag.name === 'blockquote' ? 'blockquote' : tag.name === 'ul' ? 'bulletList' : 'orderedList'
|
||||
const [first, ...rest] = tag.attributes
|
||||
const start = type === 'orderedList' && first?.[0] === 'start' ? first[1] : undefined
|
||||
const order = start === undefined ? undefined : Number(start)
|
||||
if (order !== undefined && (!Number.isSafeInteger(order) || String(order) !== start)) {
|
||||
return refused('unmappable-html', `an ordered list starts at an integer, written as JavaScript writes it: found start="${start}"`, html, tag.start, place)
|
||||
}
|
||||
const data = readDataAttributes(start === undefined ? tag.attributes : rest, type, blockNodes[type].attributes, type === 'orderedList' ? ['order'] : [])
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const node = built(type, order === undefined ? data.value : { order, ...data.value }, [])
|
||||
const closer = `</${tag.name}>`
|
||||
if (html.startsWith(closer, tag.end)) return success({ end: tag.end + closer.length, kind: 'node', node })
|
||||
if (!html.startsWith('\n', tag.end)) return refused('unmappable-html', `<${tag.name}> holds ${closer} straight after it, or a line feed and its first ${type === 'blockquote' ? 'block' : 'item'}: ${foundAt(html, tag.end)}`, html, tag.end, place)
|
||||
return success({ end: tag.end + 1, kind: 'opened', open: { closer: `\n${closer}`, content: [], depth: depth + 1, kind: type === 'blockquote' ? 'blocks' : 'items', node, place } })
|
||||
}
|
||||
|
||||
// An item's first paragraph stands on its line, as the writer panels picked (docs/decisions.md §The HTML dialect); where it does not, the item's blocks follow a line each.
|
||||
function readItem(html: string, index: number, place: Place, depth: number): Result<Read, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name !== 'li') return refused('unmappable-html', `a list holds <li> items, one to a line: ${foundAt(html, index)}`, html, index, place)
|
||||
const data = readDataAttributes(tag.attributes, 'listItem', blockNodes.listItem.attributes, [])
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const item = built('listItem', data.value, [])
|
||||
if (html.startsWith('</li>', tag.end)) return success({ end: tag.end + '</li>'.length, kind: 'node', node: item })
|
||||
const open: Open = { closer: '\n</li>', content: [], depth, kind: 'item', node: item, place }
|
||||
if (html.startsWith('\n', tag.end) && isBlockStart(html, tag.end + 1)) return success({ end: tag.end + 1, kind: 'opened', open })
|
||||
const line = readItemLine(html, tag.end, { index: 0, parent: place })
|
||||
if (!line.ok) return line
|
||||
const { end, node } = line.value
|
||||
if (html.startsWith('</li>', end)) return success({ end: end + '</li>'.length, kind: 'node', node: { ...item, content: [node] } })
|
||||
if (!html.startsWith('\n', end)) return refused('unmappable-html', `an item's line ends at </li>, or at a line feed before the item's next block: ${foundAt(html, end)}`, html, end, place)
|
||||
return success({ end: end + 1, kind: 'opened', open: { ...open, content: [node] } })
|
||||
}
|
||||
|
||||
// The paragraph on an item's line, up to `</li>` or to the line feed before the item's next block, which its caller reads.
|
||||
function readItemLine(html: string, index: number, place: Place): ReadNode {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name === 'p') {
|
||||
if (tag.attributes.length > 0) return readInlineBlock(html, tag, 'paragraph', blockNodes.paragraph.attributes, {}, place)
|
||||
return refused('unmappable-html', "an item's first paragraph holding no attribute stands bare on the item's line: <li>Bolt</li>", html, index, place)
|
||||
}
|
||||
const inline = readInline(html, index, place)
|
||||
if (!inline.ok) return inline
|
||||
const { end, nodes } = inline.value
|
||||
const last = nodes[nodes.length - 1]
|
||||
if (!isBlockStart(html, end) || !html.endsWith('\n', end) || last?.text === undefined) return success({ end, node: { content: nodes, type: 'paragraph' } })
|
||||
// Only text ends in a line feed, so the text read last holds the one parting the line from the next block.
|
||||
const text = last.text.slice(0, -1)
|
||||
return success({ end: end - 1, node: { content: [...nodes.slice(0, -1), ...(text === '' ? [] : [{ ...last, text }])], type: 'paragraph' } })
|
||||
}
|
||||
|
||||
function isBlockStart(html: string, index: number): boolean {
|
||||
const tag = readStartTag(html, index)
|
||||
return tag !== undefined && blockElements.has(tag.name)
|
||||
}
|
||||
|
||||
// A node holding `attrs` and `content` only where it holds any, as `adfToHtml` reads an element holding none.
|
||||
function built(type: string, attrs: AdfAttributes, content: AdfNode[]): AdfNode {
|
||||
return { ...(Object.keys(attrs).length === 0 ? {} : { attrs }), ...(content.length === 0 ? {} : { content }), type }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { ParseError, Result } from '../../result.ts'
|
||||
import { heldNode, readCarry } from './carry-element.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { linkMarkFrom, type LinkAttrs } from '../../adf/mark-attributes.ts'
|
||||
import { readStartTag, refused, type Place, type StartTag } from './source.ts'
|
||||
import { success } from '../../result.ts'
|
||||
import { unescapedValue } from '../escaping.ts'
|
||||
|
||||
type ReadNode = Result<{ end: number; node: AdfNode }, ParseError>
|
||||
|
||||
const markElements: ReadonlySet<string> = new Set(['code', 'em', 'strong'])
|
||||
|
||||
// The inline nodes from `index` up to the first tag opening none, which the caller reads.
|
||||
export function readInline(html: string, index: number, place: Place): Result<{ end: number; nodes: AdfNode[] }, ParseError> {
|
||||
const nodes: AdfNode[] = []
|
||||
let at = index
|
||||
while (at < html.length) {
|
||||
const nodePlace = { index: nodes.length, parent: place }
|
||||
const read = html.startsWith('<', at) ? readTagged(html, at, nodePlace) : readText(html, at, [], nodePlace)
|
||||
if (read === undefined) break
|
||||
if (!read.ok) return read
|
||||
nodes.push(read.value.node)
|
||||
at = read.value.end
|
||||
}
|
||||
return success({ end: at, nodes })
|
||||
}
|
||||
|
||||
// `undefined` where the tag at `index` opens no inline node.
|
||||
function readTagged(html: string, index: number, place: Place): ReadNode | undefined {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name === 'br' && tag.attributes.length === 0) return success({ end: tag.end, node: { type: 'hardBreak' } })
|
||||
if (tag?.name !== 'span') return tag !== undefined && markOf(tag) !== undefined ? readMarked(html, index, place) : undefined
|
||||
const carry = readCarry(html, tag, largestNesting, place)
|
||||
if (!carry.ok) return carry
|
||||
const node = heldNode(carry.value.held, html, index, place)
|
||||
return node.ok ? success({ end: carry.value.end, node: node.value }) : node
|
||||
}
|
||||
|
||||
// Each text node writes its own elements, one inside the other around its text, the first mark outermost.
|
||||
function readMarked(html: string, index: number, place: Place): ReadNode {
|
||||
const marks: AdfMark[] = []
|
||||
const closers: string[] = []
|
||||
let at = index
|
||||
for (;;) {
|
||||
const tag = readStartTag(html, at)
|
||||
const mark = tag === undefined ? undefined : markOf(tag)
|
||||
if (tag === undefined || mark === undefined) break
|
||||
if (marks.some(({ type }) => type === mark.type)) return refused('unmappable-html', `text holds each mark once: found a second <${tag.name}> inside one`, html, at, place)
|
||||
marks.push(mark)
|
||||
closers.unshift(`</${tag.name}>`)
|
||||
at = tag.end
|
||||
}
|
||||
if (at === html.length || html.startsWith('<', at)) return refused('unmappable-html', 'a mark element holds one text, or one mark element holding it', html, at, place)
|
||||
const text = readText(html, at, marks, place)
|
||||
if (!text.ok) return text
|
||||
at = text.value.end
|
||||
for (const closer of closers) {
|
||||
if (!html.startsWith(closer, at)) return refused('unmappable-html', `marked text ends at ${closer}: found something else`, html, at, place)
|
||||
at += closer.length
|
||||
}
|
||||
return success({ end: at, node: text.value.node })
|
||||
}
|
||||
|
||||
function readText(html: string, index: number, marks: AdfMark[], place: Place): ReadNode {
|
||||
const next = html.indexOf('<', index)
|
||||
const end = next < 0 ? html.length : next
|
||||
const text = unescapedValue(html.slice(index, end))
|
||||
if (text === undefined) return refused('unmappable-html', 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold', html, index, place)
|
||||
return success({ end, node: marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' } })
|
||||
}
|
||||
|
||||
export function readLink({ attributes }: StartTag): LinkAttrs | undefined {
|
||||
const [href, title, ...others] = attributes
|
||||
if (href?.[0] !== 'href' || (title !== undefined && title[0] !== 'title') || others.length > 0) return undefined
|
||||
return title === undefined ? { href: href[1] } : { href: href[1], title: title[1] }
|
||||
}
|
||||
|
||||
function markOf(tag: StartTag): AdfMark | undefined {
|
||||
if (tag.name !== 'a') return tag.attributes.length === 0 && markElements.has(tag.name) ? { type: tag.name } : undefined
|
||||
const link = readLink(tag)
|
||||
return link === undefined ? undefined : linkMarkFrom(link)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
|
||||
import { unescapedValue } from '../escaping.ts'
|
||||
|
||||
// A node's place among its parent's content, the document's own content where `parent` is undefined; a path is built only for a refusal.
|
||||
export type Place = { index: number; parent: Place | undefined }
|
||||
|
||||
// A start tag as `adfToHtml` writes it, its attributes' values unescaped.
|
||||
export type StartTag = { attributes: (readonly [string, string])[]; end: number; name: string; start: number }
|
||||
|
||||
const attribute = / ([a-z][a-z0-9-]*)="([^"]*)"/g
|
||||
const lineEnding = /\r\n?|\n/g
|
||||
const startTag = /<([a-z][a-z0-9]*)((?: [a-z][a-z0-9-]*="[^"]*")*)>/y
|
||||
|
||||
// `undefined` where no start tag stands at `index`, or one of its values is not escaped as the dialect escapes it.
|
||||
export function readStartTag(html: string, index: number): StartTag | undefined {
|
||||
startTag.lastIndex = index
|
||||
const match = startTag.exec(html)
|
||||
if (match === null) return undefined
|
||||
const attributes: (readonly [string, string])[] = []
|
||||
for (const [, name = '', spelled = ''] of (match[2] ?? '').matchAll(attribute)) {
|
||||
const value = unescapedValue(spelled)
|
||||
if (value === undefined) return undefined
|
||||
attributes.push([name, value])
|
||||
}
|
||||
return { attributes, end: index + match[0].length, name: match[1] ?? '', start: index }
|
||||
}
|
||||
|
||||
// What stands at `index`, for a refusal's message.
|
||||
export function foundAt(html: string, index: number): string {
|
||||
if (index >= html.length) return 'found the end of the HTML'
|
||||
const found = html.slice(index, index + 24)
|
||||
return `found ${JSON.stringify(found)}${index + found.length < html.length ? '…' : ''}`
|
||||
}
|
||||
|
||||
// At the start of the line `index` stands on.
|
||||
export function refused<T>(code: ConvertErrorCode, message: string, html: string, index: number, place: Place | undefined): Result<T, ParseError> {
|
||||
return { error: { code, message, path: pathOf(place), position: lineStart(html, index) }, ok: false }
|
||||
}
|
||||
|
||||
// A line ends at a line feed, a carriage return or both, as an HTML parser reads it.
|
||||
function lineStart(html: string, index: number): SourcePosition {
|
||||
let line = 1
|
||||
let offset = 0
|
||||
for (const ending of html.slice(0, index).matchAll(lineEnding)) {
|
||||
line += 1
|
||||
offset = ending.index + ending[0].length
|
||||
}
|
||||
return { line, offset }
|
||||
}
|
||||
|
||||
function pathOf(place: Place | undefined): ConvertErrorPath {
|
||||
const steps: (number | string)[] = []
|
||||
for (let at = place; at !== undefined; at = at.parent) steps.push(at.index, 'content')
|
||||
return steps.reverse()
|
||||
}
|
||||
@@ -6,5 +6,6 @@ export { adfToHtml } from './html/emit/adf-to-html.ts'
|
||||
export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
|
||||
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
export { htmlToAdf } from './html/parse/html-to-adf.ts'
|
||||
export { isAdfDocument } from './adf/document.ts'
|
||||
export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { failure, faulted, positioned, success, type ConvertErrorPath, type Pars
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { notAString } from '../../not-a-string.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
import { readBlockDirectiveNode } from '../directive/node-reading.ts'
|
||||
@@ -47,7 +48,7 @@ export function readDocument(markdown: string, readAs: ReadAs): Result<AdfDocume
|
||||
}
|
||||
|
||||
function readMarkdown(markdown: string, readAs: ReadAs): Result<AdfDocument> {
|
||||
if (typeof markdown !== 'string') return failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, [])
|
||||
if (typeof markdown !== 'string') return notAString(markdown, 'markdown')
|
||||
const parsed = parseBlocks(markdown, readAs.flavour)
|
||||
const [only] = parsed.blocks
|
||||
if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) {
|
||||
@@ -59,14 +60,6 @@ function readMarkdown(markdown: string, readAs: ReadAs): Result<AdfDocument> {
|
||||
return content.ok ? success({ content: content.value, type: 'doc', version: 1 }) : content
|
||||
}
|
||||
|
||||
// `typeof` alone, since anything more throws on a revoked Proxy.
|
||||
function typeName(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
const type = typeof value
|
||||
if (type === 'undefined') return type
|
||||
return type === 'object' ? 'an object' : `a ${type}`
|
||||
}
|
||||
|
||||
export function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
if (depth > largestNesting) return tooDeep(path)
|
||||
const content: AdfNode[] = []
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { failure, type Result } from './result.ts'
|
||||
|
||||
// Each reader's refusal of input that is no string, `format` naming what it reads.
|
||||
export function notAString<T>(value: unknown, format: string): Result<T> {
|
||||
return failure('not-a-string', `${format} is a string: found ${typeName(value)}`, [])
|
||||
}
|
||||
|
||||
// `typeof` alone, since anything more throws on a revoked Proxy.
|
||||
function typeName(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
const type = typeof value
|
||||
if (type === 'undefined') return type
|
||||
return type === 'object' ? 'an object' : `a ${type}`
|
||||
}
|
||||
@@ -20,11 +20,10 @@
|
||||
| 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 |
|
||||
| 145 | 0.3.0 | | | **Widen the README's "Other ADF converters" table to every ADF converter a developer might pick: npm packages, other languages' libraries, Atlassian's REST APIs and MCP servers, `mdast-util-from-adf` among them.** | 2 | 2 | 1 | 3 | 4 | 3 by 4 | 4.2 |
|
||||
| 91 | 0.3.0 | 95 | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
|
||||
| 131 | 0.3.0 | | | **Ship `htmlToAdf`, reading back exactly what `adfToHtml` writes and refusing any other HTML as `unmappable-html`.** | 4 | 1 | 2 | 5 | 7 | 3 by 3 | 0.6 |
|
||||
| 133 | 0.3.0 | 131 | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
|
||||
| 132 | 0.3.0 | 131 | | **Write tables as `<table>` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 |
|
||||
| 73 | 0.3.0 | 33, 91, 95, 109, 115, 131, 132, 133, 140, 145, 146 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
|
||||
| 72 | 0.3.0 | 33, 91, 95, 109, 115, 131, 132, 133, 140, 145, 146 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
|
||||
| 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
|
||||
| 132 | 0.3.0 | | | **Write tables as `<table>` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 |
|
||||
| 73 | 0.3.0 | 33, 91, 95, 109, 115, 132, 133, 140, 145, 146 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
|
||||
| 72 | 0.3.0 | 33, 91, 95, 109, 115, 132, 133, 140, 145, 146 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
|
||||
| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
|
||||
| 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 |
|
||||
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
|
||||
@@ -36,14 +35,14 @@
|
||||
| 80 | 0.4.0 | | | **Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.** | 2 | 1 | 2 | 3 | 3 | 6 by 4 | 6.0 |
|
||||
| 81 | 0.4.0 | | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 1 | 1 | 1 | 2 | 3 by 2 | 4.2 |
|
||||
| 147 | 0.4.0 | | | **Ship `adfToPlainText`, writing a document's content with no markup, under a README goal for plain text.** | 2 | 2 | 1 | 3 | 4 | 3 by 4 | 4.2 |
|
||||
| 141 | 0.4.0 | 131 | | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 4.0 |
|
||||
| 135 | 0.4.0 | 131 | | **Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 |
|
||||
| 136 | 0.4.0 | 131 | | **Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 |
|
||||
| 134 | 0.4.0 | 131 | | **Write panels, expands, task lists and decision lists as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 3 | 1.8 |
|
||||
| 141 | 0.4.0 | | | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 4.0 |
|
||||
| 135 | 0.4.0 | | | **Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 |
|
||||
| 136 | 0.4.0 | | | **Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 |
|
||||
| 134 | 0.4.0 | | | **Write panels, expands, task lists and decision lists as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 3 | 1.8 |
|
||||
| 46 | 0.4.0 | 134, 135, 136 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 2 | 1 | 3 | 4 | 3 by 3 | 3.2 |
|
||||
| 51 | 0.4.0 | | | **Match a reference label to its definition under Unicode case folding.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 |
|
||||
| 148 | 0.4.0 | | | **Ship conversion between ADF and Jira wiki markup both ways, under a README goal for wiki markup.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 |
|
||||
| 149 | 0.4.0 | 131 | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 |
|
||||
| 149 | 0.4.0 | | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 |
|
||||
| 77 | 0.4.0 | | decision | **Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.** | 3 | 1 | 3 | 5 | 4 | | 0.0 |
|
||||
| 107 | 0.4.0 | | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 3 | 1 | 3 | 5 | 5 | | 0.0 |
|
||||
| 42 | 0.5.0 | | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 1 | 2 | 2 | 2 | 9 by 3 | 26.2 |
|
||||
@@ -59,8 +58,8 @@
|
||||
| 34 | 0.5.0 | | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 1 | 2 | 3 | 3 | 4 by 1 | 1.5 |
|
||||
| 74 | 0.5.0 | | owner | **Keep the release path publishing after npm retires bypass-2FA tokens.** | 3 | 2 | 4 | 7 | 4 | | 0.0 |
|
||||
| 8 | 0.6.0 | | owner | **Ship a CLI.** | 3 | 2 | 1 | 4 | 7 | | 0.0 |
|
||||
| 139 | 0.7.0 | 131 | decision | **Ship the calls between markdown and HTML, composed through ADF and each named by its flavour.** | 2 | 1 | 2 | 3 | 4 | 3 by 3 | 3.2 |
|
||||
| 137 | 0.7.0 | 131 | decision | **Read foreign HTML in `htmlToAdf` the three ways `docs/decisions.md` §Foreign HTML sorts three ways states.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 |
|
||||
| 139 | 0.7.0 | | decision | **Ship the calls between markdown and HTML, composed through ADF and each named by its flavour.** | 2 | 1 | 2 | 3 | 4 | 3 by 3 | 3.2 |
|
||||
| 137 | 0.7.0 | | decision | **Read foreign HTML in `htmlToAdf` the three ways `docs/decisions.md` §Foreign HTML sorts three ways states.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 |
|
||||
| 138 | 0.7.0 | 137 | | **Read raw HTML in every markdown reader through `htmlToAdf`'s foreign-element mapping, in place of refusing it as `unmappable-html`.** | 3 | 1 | 3 | 5 | 5 | 4 by 4 | 2.5 |
|
||||
|
||||
## Renamed
|
||||
@@ -143,14 +142,6 @@ mention or code spelled `!adf:text{text=" "}`. Joining the run across the break
|
||||
`inline-line.ts` ruled worse with two comprehension panels (PR #22): a joined run that cannot be spelled needs its own fallback,
|
||||
first as a set of parted breaks, then as a flag the first fallback clears.
|
||||
|
||||
### 131. Ship `htmlToAdf`, reading back exactly what `adfToHtml` writes and refusing any other HTML as `unmappable-html`.
|
||||
|
||||
A hand-written HTML tokenizer, linear in the input, its nesting bounded (`docs/decisions.md`
|
||||
§Nothing recurses unbounded). A carry element reads from its `data-*` attribute; the text it shows
|
||||
is ignored. A property checks that `htmlToAdf(adfToHtml(doc))` deep-equals `doc`. `htmlToAdf`
|
||||
refuses a value that is not a string as `not-a-string` (`docs/decisions.md` §Which code a cause
|
||||
takes).
|
||||
|
||||
### 133. Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.
|
||||
|
||||
A writer panel picks the elements, classes and `data-*` attributes. Where a link marks a card, both
|
||||
|
||||
Reference in New Issue
Block a user