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

Answer prose round 1: 0 🔴, 8 🟡 (0 misread); refusals name the tag and what they found, the start-after-data-* message no longer false, README, spec and changelog reworded

This commit is contained in:
2026-10-28 03:07:37 +01:00
committed by Lillem4n
parent 4fa16b62e5
commit 12230b5e9c
15 changed files with 112 additions and 74 deletions
+3 -3
View File
@@ -9,9 +9,9 @@
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`, and refuses any other HTML, an edit to what a carry element shows included,
with the codes README §The calls lists.
- `htmlToAdf` reads `adfToHtml`'s HTML back to the document it came from, a carry element from its
`data-adf`. It refuses any other HTML, including an edit to what a carry element shows, with the
codes README §The calls lists.
- **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
+28 -23
View File
@@ -19,11 +19,11 @@ products store as ADF.
and Obsidian render — GitHub Flavored Markdown's alerts, task lists and pipe tables, Obsidian
Flavored Markdown's callouts and highlights. It keeps the text, images by URL and link targets and
drops the rest.
- **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. `htmlToAdf` reads that HTML back to the document it came from, and refuses any other
HTML.
- **HTML out and back:** `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. `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.
@@ -132,7 +132,7 @@ does not know included.
Full reference: [HTML](#html).
```ts
import { adfToHtml, type AdfDocument } from '@larvit/adf-codec'
import { adfToHtml, htmlToAdf, type AdfDocument } from '@larvit/adf-codec'
const page: AdfDocument = {
content: [
@@ -156,6 +156,9 @@ const html = adfToHtml(page)
if (html.ok) console.log(html.value)
// <h2 data-local-id="a1f3">Release notes</h2>
// <p>Read the <a href="https://spec.commonmark.org/">spec</a>, then ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>
const back = html.ok ? htmlToAdf(html.value) : undefined
if (back?.ok) console.log(back.value) // deep-equals page
```
### Handle a result's error
@@ -503,13 +506,13 @@ 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`, and what it shows must be what `adfToHtml` shows for that node:
an edit there would be lost, so it is refused.
Other HTML is refused as `unmappable-html`: another element, attribute or escape, such as `<b>`,
`<br/>` or `&#39;`, and HTML missing its final line feed or ending a line in `\r\n`. A `data-*`
attribute or a carry's JSON that no node holds is `unsupported-node-shape`, and a carry's JSON
nesting too deep is `unsupported-nesting-depth`.
`htmlToAdf` reads exactly that HTML back. It reads a carry element's node from its `data-adf`, and
what the element shows must be what `adfToHtml` shows for that node: an edit there would be lost, so
it is refused. Other HTML is refused as `unmappable-html`: another element, attribute or escape,
such as `<b>`, `<br/>` or `&#39;`, and HTML missing its final line feed or ending a line in `\r\n`.
A few causes, such as a `data-*` attribute its element does not take, are `unsupported-node-shape`
instead, and nesting past 500 levels is `unsupported-nesting-depth`. [Either
direction](#either-direction) lists every cause of both.
## The errors
@@ -532,11 +535,12 @@ carry](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#unknown-n
- `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 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 }`.
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 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.
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.
A call reports the first refusal in document order and stops, so a document with several surfaces
them one per call. Every refusal but `engine-limit` is deterministic — there is no I/O anywhere —
@@ -557,7 +561,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 markdown holds raw HTML, a comment and a processing instruction among them; the HTML handed to `htmlToAdf` holds HTML `adfToHtml` does not write, such as an edit to what a carry element shows | 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 |
| `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, such as an edit to what a carry element shows, unless a code under [Either direction](#either-direction) names the cause | 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
@@ -596,10 +600,11 @@ Every call can raise these:
- **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 a `data-*` attribute its element does not take, such as `data-level` on `<h1>`, or one
holding a value outside its attribute's kind or not written as compact JSON, such as
`data-wrap="yes"`;
- holds a carry element whose `data-adf` holds JSON that is no ADF node, 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
@@ -716,7 +721,7 @@ Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/do
| `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 |
| `htmlToAdf` | function | `adfToHtml`'s HTML, read back to ADF |
| `isAdfDocument` | function | type guard for `AdfDocument` |
| `losslessMarkdownToAdf` | function | the lossless flavour to ADF |
| `portableMarkdownToAdf` | function | portable markdown to ADF |
+1 -1
View File
@@ -1021,7 +1021,7 @@ spelling the emitter refuses gives its own error rather than a second name for i
is the only import `parse/` takes from `emit/`, through `emit/spelling-questions.ts`.
`.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `html/parse/` from importing
`html/emit/`, keeps `parse/` and `emit/` from importing `markdown/portable/`, keeps
`html/emit/`, keeps markdown's `parse/` and `emit/` from importing `portable/`, keeps
`markdown/directive/` from importing `emit/` or `parse/`, keeps `markdown/` and `html/` from
importing each other, and keeps `adf/` from importing either, as `no-restricted-imports` overrides.
A later override replaces an earlier one's rule, so each subdirectory's override repeats its
+8 -6
View File
@@ -119,14 +119,16 @@ later release writes it as its own element.
## Reading
`htmlToAdf` reads exactly the HTML above, so `htmlToAdf(adfToHtml(doc))` deep-equals `doc`. A carry
element reads from its `data-adf`, and what it shows, through its closing tag, must be what
`adfToHtml` shows for that node: an edit there would be lost, so it is `unmappable-html`.
`htmlToAdf` reads exactly the HTML above, so `htmlToAdf(adfToHtml(doc))` deep-equals `doc`. It
reads a carry element's node from its `data-adf`, and what the element shows, through its closing
tag, must be what `adfToHtml` shows for that node: an edit there would be lost, so it is
`unmappable-html`.
- Any other HTML is `unmappable-html`: another element, attribute or escape, attributes in another
order, a block on the line of another, a carriage return anywhere, 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`.
order, a block on the line of another, a carriage return anywhere, or non-empty HTML not ending
in a line feed.
- A `data-*` attribute that its element does not take, such as `data-level` on `<h1>`, or that holds
a value outside its 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.
+1 -1
View File
@@ -20,7 +20,7 @@ export function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelli
return json.ok ? success({ headroom, json: json.value }) : json
}
// The read inverse of `carriedJson` takes three steps, value, nesting and node, so each format's own refusals keep their place between them; `invalid` is the format's refusal of text that is no JSON, and `holder` names the carry in a message.
// The read inverse of `carriedJson` takes three steps, value, nesting and node, so each format's own refusals keep their place between them.
export function carriedValue(json: string, holder: string, invalid: ConvertFault): Read<JsonValue> {
const parsed = parseJsonText(json)
if (parsed.refusal === undefined) return { value: parsed.value }
+1 -1
View File
@@ -10,7 +10,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling)
return [...canonicalJsonPieces(value, spelling)].join('')
}
// Stops at the first piece, passed through `respell`, that does not match `text`: re-spelled with two-space indentation, a short compact text can pass the engine's longest string.
// Stops at the first piece, passed through `respell`, that does not match `text`: written again with two-space indentation, a short compact text can pass the engine's longest string.
export function spellsCanonicalJson(value: JsonValue, spelling: JsonSpelling, text: string, respell = (canonical: string): string => canonical): boolean {
let offset = 0
for (const canonical of canonicalJsonPieces(value, spelling)) {
+3 -3
View File
@@ -56,7 +56,7 @@ function element(type: string, attrs: AdfAttributes, content: AdfNode[]): AdfNod
return { ...(Object.keys(attrs).length === 0 ? {} : { attrs }), ...(content.length === 0 ? {} : { content }), type }
}
// An attribute goes inside a start tag, a line at the start of one, and any other piece between two of the HTML's tags, text runs and line feeds, in place of up to `removed` of them.
// An attribute goes inside a start tag, a line at the start of any line, and any other piece between two of the HTML's tags, text runs and line feeds, in place of up to `removed` of them.
function edited(html: string, [at, removed, piece]: readonly [number, number, string]): string {
if (piece.startsWith(' ')) {
const tags = [...html.matchAll(/<[a-z][^>]*>/g)]
@@ -171,7 +171,7 @@ test('a generated document writes HTML that reads back to it', { timeout: proper
)
})
test('HTML a generated document writes, edited, reads to a result, and every document read writes HTML reading back to it, the very HTML read where it holds no carry element', { timeout: propertyTimeout }, () => {
test('HTML edited anywhere reads, if at all, to a document whose HTML reads 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) => {
@@ -185,7 +185,7 @@ test('HTML a generated document writes, edited, reads to a result, and every doc
)
})
test('HTML a document of the dialect\'s own elements writes, edited between its tags, reads only as the very HTML its document read writes', { timeout: propertyTimeout }, () => {
test('HTML of the dialect\'s own elements, edited, reads only if adfToHtml writes it', { timeout: propertyTimeout }, () => {
const edits = fc.array(fc.tuple(fc.nat(), fc.nat({ max: 1 }), fc.constantFrom(...elementAttributes, ...elementInlinePieces, ...elementLines)), { maxLength: 2, minLength: 1 })
fc.assert(
fc.property(elementDocument, edits, (document, cuts) => {
+5
View File
@@ -7,6 +7,11 @@ export function carryAttributes(json: string): string {
return ` class="${carryClass}" ${jsonAttribute}="${jsonValue(json)}"`
}
// Where the JSON stands in the start tag of a carry element named `element`, counted from its `<`.
export function carryJsonOffset(element: string): number {
return `<${element} class="${carryClass}" ${jsonAttribute}="`.length
}
// The JSON a carry element's attributes hold, as HTML can hold it; `undefined` where `carryAttributes` writes no such attributes.
export function readCarryAttributes(attributes: readonly (readonly [string, string])[]): string | undefined {
const [kind, held, ...others] = attributes
+1 -1
View File
@@ -9,7 +9,7 @@ import { shownValue } from './escaping.ts'
// The content a carry element shows of one block; `code` the code block holding it, if any.
type ShownBlock = { code: AdfNode | undefined; html: string }
// What a carry element shows of the node it holds, and all `htmlToAdf` accepts there, so a change here breaks reading stored HTML (docs/decisions.md §The formats are API): in a `<div>`, each block's content as a `<p>`, a code block's as `<pre><code>`; in a `<span>`, the blocks parted by `<br>`.
// What a carry element shows of the node it holds, and all `htmlToAdf` accepts there, so a change here breaks reading stored HTML (docs/decisions.md §The formats are API).
export function carryContent(node: AdfNode, tag: 'div' | 'span'): string {
if (tag === 'span') return shownBlocks(node, '<br>').map(({ html }) => html).join('<br>')
return shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `<p>${html}</p>` : `<pre><code${languageClass(nodeAttrs(code)['language']) ?? ''}>${html}</code></pre>`)).join('')
+13 -8
View File
@@ -2,11 +2,11 @@ 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 { holdsKind, vocabularyPairs } from '../adf/attribute-vocabulary.ts'
import { holdsOnlyAttributes, nodeAttrs } from '../adf/document.ts'
import { optionalAttribute } from './escaping.ts'
import { parseJsonText } from '../json-text.ts'
import { serializeCanonicalJson, spellsCanonicalJson } from '../canonical-json.ts'
import { holdsKind, 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 {
@@ -22,18 +22,16 @@ export function dataAttributes(node: AdfNode, vocabulary: AttributeVocabulary, w
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> {
// What `dataAttributes` wrote, its pairs' values unescaped; the caller reads `leading`, an attribute the element writes ahead of them, where it stands first. 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])[], element: string, vocabulary: AttributeVocabulary, written: readonly string[], leading?: string): Read<AdfAttributes> {
const attrs: AdfAttributes = {}
let previous = ''
for (const [name, spelled] of pairs) {
for (const [name, spelled] of leading !== undefined && pairs[0]?.[0] === leading ? pairs.slice(1) : pairs) {
const [key, kind] = dataKey(name, vocabulary, written) ?? []
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 === undefined || kind === undefined) return { fault: nameFault(name, element, leading, previous) }
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` } }
if (value === undefined) return { fault: { code: 'unsupported-node-shape', message: `${name} holds no ${kind} written as compact JSON` } }
attrs[key] = value
previous = key
}
@@ -55,6 +53,13 @@ function dataValue(spelled: string, kind: AttributeKind): JsonValue | undefined
return value !== undefined && holdsKind(value, kind) && spellsCanonicalJson(value, 'compact', spelled) ? value : undefined
}
// At a `leading`, `previous` is empty only where another `leading` stood first.
function nameFault(name: string, element: string, leading: string | undefined, previous: string): ConvertFault {
if (name.startsWith('data-')) return { code: 'unsupported-node-shape', message: `<${element}> takes no ${name} attribute` }
if (name !== leading) return unmappable(`<${element}> takes no ${name} attribute`)
return unmappable(`<${element}> takes ${name} once, before its data-* attributes: found ${name} after ${previous === '' ? name : dataName(previous)}`)
}
function unmappable(message: string): ConvertFault {
return { code: 'unmappable-html', message }
}
+1 -1
View File
@@ -38,7 +38,7 @@ 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.
// The value an escaped HTML text or attribute stands for, `undefined` where `writableValue` would not write it so.
export function unescapedValue(html: string): string | undefined {
return misspelledAt(html) < 0 ? html.replace(entity, (spelled) => entities.get(spelled) ?? spelled) : undefined
}
+8 -7
View File
@@ -2,9 +2,9 @@ import type { AdfNode } from '../../adf/document.ts'
import type { Read } from '../../result.ts'
import { carriedNesting, carriedNode, carriedValue } from '../../adf/carried-json.ts'
import { carryContent } from '../carry-content.ts'
import { carryJsonOffset, readCarryAttributes } from '../carry-attributes.ts'
import { foundAt, refused, type Place, type ReadNode, type StartTag } from './source.ts'
import { holdableJson } from '../escaping.ts'
import { readCarryAttributes } from '../carry-attributes.ts'
import { spellsCanonicalJson } from '../../canonical-json.ts'
import { success } from '../../result.ts'
@@ -13,23 +13,24 @@ const holder = "a carry element's data-adf"
// What the element shows must read back as `adfToHtml` shows its node, so an edit to it is refused rather than dropped (README goal 7).
export function readCarry(html: string, tag: StartTag, element: 'div' | 'span', levels: number, place: Place): ReadNode {
const json = readCarryAttributes(tag.attributes)
if (json === undefined) 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 node = heldNode(json, levels)
if (node.fault !== undefined) return refused(node.fault.code, node.fault.message, html, tag.end, place)
if (json === undefined) return refused('unmappable-html', `a <${tag.name}> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone: ${foundAt(html, tag.start)}`, html, tag.start, place)
const jsonAt = tag.start + carryJsonOffset(tag.name)
const node = heldNode(json, levels, html, jsonAt)
if (node.fault !== undefined) return refused(node.fault.code, node.fault.message, html, jsonAt, place)
const shown = `${carryContent(node.value, element)}</${element}>`
if (html.startsWith(shown, tag.end)) return success({ end: tag.end + shown.length, node: node.value })
let at = tag.end
while (html[at] === shown[at - tag.end]) at += 1
return refused('unmappable-html', `a carry element shows its node's text as adfToHtml writes it, then ends at </${element}>; an edit to it is no edit to the node: ${foundAt(html, at)}`, html, at, place)
return refused('unmappable-html', `a carry element shows exactly what adfToHtml writes for its node, then </${element}>; to change the node, edit its ADF and write the HTML again: ${foundAt(html, at)}`, html, at, place)
}
function heldNode(json: string, levels: number): Read<AdfNode> {
function heldNode(json: string, levels: number, html: string, jsonAt: number): Read<AdfNode> {
const held = carriedValue(json, holder, { code: 'unmappable-html', message: `${holder} holds invalid JSON` })
if (held.fault !== undefined) return held
const nested = carriedNesting(held.value, levels)
if (nested.fault !== undefined) return nested
if (!spellsCanonicalJson(held.value, 'compact', json, holdableJson)) {
return { fault: { code: 'unsupported-node-shape', message: "a carry element spells its node's JSON canonically: compact, keys sorted, a character HTML cannot hold as a JSON escape" } }
return { fault: { code: 'unsupported-node-shape', message: `a carry element spells its node's JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: ${foundAt(html, jsonAt)}` } }
}
return carriedNode(nested.value, holder)
}
+20 -3
View File
@@ -290,10 +290,26 @@ const messages: readonly (readonly [string, string])[] = [
['<ul>\n<li>see <a href="https://x.test/?a=1&b=2">x</a></li>\n</ul>\n', '<a> holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x</a></li>\\n</ul>\\n"'],
['<ul>\n<li><p>a</p><strong>x</strong></li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<strong>x</strong></li>\\n"…'],
['<ul>\n<li>Bolt<ul>\n<li>a</li>\n</ul></li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<ul>\\n<li>a</li>\\n</ul></l"…'],
['<p><strong><b>x</b></strong></p>\n', '<b> as written here is no mark element of the dialect, and a mark element holds one text, or one mark element holding it: found "<b>x</b></strong></p>\\n"'],
['<p><strong><br>x</strong></p>\n', '<br> as written here is no mark element of the dialect, and a mark element holds one text, or one mark element holding it: found "<br>x</strong></p>\\n"'],
['<p><strong><b>x</b></strong></p>\n', '<b> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<b>x</b></strong></p>\\n"'],
['<p><strong><br>x</strong></p>\n', '<br> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<br>x</strong></p>\\n"'],
['<p><em></em></p>\n', 'a mark element holds text, or one other mark element around text: found "</em></p>\\n"'],
['<p><strong><a href="https://x.test/?a=1&b=2">x</a></strong></p>\n', '<a> holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x</a></strong></p>"…'],
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, 'a carry element shows its node\'s text as adfToHtml writes it, then ends at </div>; an edit to it is no edit to the node: found "<b>collation</b>.</p></d"…'],
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, 'a carry element shows exactly what adfToHtml writes for its node, then </div>; to change the node, edit its ADF and write the HTML again: found "<b>collation</b>.</p></d"…'],
['<p><span>a</span></p>\n', 'a <span> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone: found "<span>a</span></p>\\n"'],
['<div class="adf-carry" data-adf="{ &quot;type&quot;:&quot;x&quot;}"></div>\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ &quot;type&quot;:&quot"…'],
[`${documentCarry}\n<hr>\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n<hr>\\n"'],
[`<hr>\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "<div class=\\"adf-carry\\" d"…'],
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', 'an item\'s first paragraph holding content and no attribute stands bare on the item\'s line, as in <li>Bolt</li>: found "<p>Bolt</p></li>\\n</ul>\\n"'],
['<ul>\n<li><p></p></li>\n</ul>\n', 'an item\'s first paragraph holding neither content nor an attribute stands on a line of its own: found "<p></p></li>\\n</ul>\\n"'],
['<ul>\n<li>\n<p data-local-id="x"></p>\n</li>\n</ul>\n', 'an item\'s first paragraph holding an attribute stands as its <p> on the item\'s line, straight after <li>: found "<p data-local-id=\\"x\\"></p"…'],
['<p><img src="a">b</p>\n', '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: found "b</p>\\n"'],
['<p><img src="a" alt=""></p>\n', '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: found "<img src=\\"a\\" alt=\\"\\"></p>"…'],
['<ol start="03">\n<li>a</li>\n</ol>\n', 'an ordered list starts at an integer, written as JavaScript writes it: found "03\\">\\n<li>a</li>\\n</ol>\\n"'],
['<p class="x">a</p>\n', '<p> takes no class attribute'],
['<p data-color="x">a</p>\n', '<p> takes no data-color attribute'],
['<ol data-local-id="x" start="2">\n<li>a</li>\n</ol>\n', '<ol> takes start once, before its data-* attributes: found start after data-local-id'],
['<ol start="2" start="3">\n<li>a</li>\n</ol>\n', '<ol> takes start once, before its data-* attributes: found start after start'],
['<pre data-wrap="yes"><code>x</code></pre>\n', 'data-wrap holds no boolean written as compact JSON'],
]
for (const [html, expected] of messages) test(`names what it found where reading stopped, a character no one sees as its escape: ${JSON.stringify(html)}`, () => assert.equal(message(htmlToAdf(html)), expected))
@@ -302,6 +318,7 @@ test('reads blocks nested 500 levels, and refuses the next level, a list\'s item
const quotes = (levels: number): string => `${'<blockquote>\n'.repeat(levels)}<p>x</p>${'\n</blockquote>'.repeat(levels)}\n`
assert.equal(refusal(htmlToAdf(quotes(500))), 'read')
assert.equal(refusal(htmlToAdf(quotes(501))).startsWith(`unsupported-nesting-depth at /${'content/0/'.repeat(501)}content/0 line ${502}`), true)
assert.equal(message(htmlToAdf(quotes(501))), 'blocks nest deeper than 500 levels')
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(500))), 'read')
assert.equal(refusal(htmlToAdf(lists(501))).startsWith('unsupported-nesting-depth'), true)
+18 -15
View File
@@ -46,12 +46,12 @@ const documentCarryAlone = 'a carry element holding the document stands alone, a
const documentCarryEnd = `${carryContent({ type: 'doc' }, 'div')}</div>`
const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact')
const formRefusals: Readonly<Record<FirstParagraphForm, string>> = {
bare: "an item's first paragraph holding content and no attribute stands bare on the item's line: <li>Bolt</li>",
bare: "an item's first paragraph holding content and no attribute stands bare on the item's line, as in <li>Bolt</li>",
block: "an item's first paragraph holding neither content nor an attribute stands on a line of its own",
element: "an item's first paragraph holding an attribute stands as its <p> on the item's line, straight after <li>",
}
// 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).
// Reads back exactly what `adfToHtml` writes.
export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
return positioned(
withinEngineLimits(() => readHtml(html)),
@@ -71,7 +71,7 @@ function readDocumentCarry(html: string): Result<AdfDocument, ParseError> | unde
if (tag?.name !== 'div' || readCarryAttributes(tag.attributes) !== emptyDocument) return undefined
if (!html.startsWith(documentCarryEnd, tag.end)) return refused('unmappable-html', `a carry element holding the document shows nothing: ${foundAt(html, tag.end)}`, html, tag.end, undefined)
const end = tag.end + documentCarryEnd.length
if (html.startsWith('\n', end) && isBlockStart(html, end + 1)) return refused('unsupported-node-shape', documentCarryAlone, html, 0, { index: 0, parent: undefined })
if (html.startsWith('\n', end) && isBlockStart(html, end + 1)) return refused('unsupported-node-shape', `${documentCarryAlone}: ${foundAt(html, end)}`, html, end, { index: 0, parent: undefined })
return end === html.length - 1 && html.endsWith('\n') ? success({ type: 'doc', version: 1 }) : partedRefusal(html, end, undefined)
}
@@ -83,7 +83,7 @@ function readBlocks(html: string): Result<AdfDocument, ParseError> {
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)
if (open.depth > largestNesting) return refused('unsupported-nesting-depth', `blocks nest deeper than ${largestNesting} levels`, html, index, place)
const step = open.kind === 'list' ? readItem(html, index, place, open.depth) : readBlock(html, index, place, open)
if (!step.ok) return step
index = step.value.end
@@ -137,12 +137,12 @@ function readParagraph(html: string, tag: StartTag, place: Place, open: Open): R
function asFirstParagraph(read: { end: number; node: AdfNode }, found: FirstParagraphForm, html: string, index: number, place: Place): ReadNode {
const form = firstParagraphForm(read.node)
return form === found ? success(read) : refused('unmappable-html', formRefusals[form], html, index, place)
return form === found ? success(read) : refused('unmappable-html', `${formRefusals[form]}: ${foundAt(html, index)}`, html, index, 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))
const data = readDataAttributes(tag.attributes, tag.name, 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
@@ -164,7 +164,8 @@ function tryImage(html: string, index: number, place: Place): ReadNode | undefin
const node = imageNode(image, anchor)
const closer = anchor === undefined ? '</p>' : '</a></p>'
if (node === undefined || !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)
const at = node === undefined ? index : 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: ${foundAt(html, at)}`, html, at, place)
}
return success({ end: image.end + closer.length, node })
}
@@ -179,7 +180,7 @@ function imageNode(image: StartTag, anchor: StartTag | undefined): AdfNode | und
}
function readCodeBlock(html: string, tag: StartTag, place: Place): Result<BlockStep, ParseError> {
const data = readDataAttributes(tag.attributes, 'codeBlock', blockNodes.codeBlock.attributes, ['language'])
const data = readDataAttributes(tag.attributes, tag.name, 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 named = code?.name === 'code' ? readLanguageClass(code.attributes) : undefined
@@ -198,12 +199,12 @@ function readCodeBlock(html: string, tag: StartTag, place: Place): Result<BlockS
}
function readRule(html: string, tag: StartTag, place: Place): Result<BlockStep, ParseError> {
const data = readDataAttributes(tag.attributes, 'rule', blockNodes.rule.attributes, [])
const data = readDataAttributes(tag.attributes, tag.name, blockNodes.rule.attributes, [])
return data.fault === undefined ? success({ end: tag.end, kind: 'node', node: built('rule', data.value, []) }) : refused(data.fault.code, data.fault.message, html, tag.start, place)
}
function readBlockCarry(html: string, tag: StartTag, place: Place, open: Open): Result<BlockStep, ParseError> {
if (readCarryAttributes(tag.attributes) === emptyDocument) return refused('unsupported-node-shape', documentCarryAlone, html, tag.start, place)
if (readCarryAttributes(tag.attributes) === emptyDocument) return refused('unsupported-node-shape', `${documentCarryAlone}: ${foundAt(html, tag.start)}`, html, tag.start, place)
return asStep(readCarry(html, tag, 'div', largestNesting - open.depth, place))
}
@@ -213,13 +214,15 @@ function containerReader(type: ContainerType): BlockReader {
// 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, type: ContainerType, place: Place, open: Open): Result<BlockStep, ParseError> {
const [first, ...rest] = tag.attributes
const start = type === 'orderedList' && first?.[0] === 'start' ? first[1] : undefined
const ordered = type === 'orderedList'
const [first] = tag.attributes
const start = ordered && first?.[0] === 'start' ? first[1] : undefined
const order = start === undefined ? undefined : readListStart(start)
if (start !== undefined && order === undefined) {
return refused('unmappable-html', `an ordered list starts at an integer, written as JavaScript writes it: found start="${start}"`, html, tag.start, place)
const at = tag.start + `<${tag.name} start="`.length
return refused('unmappable-html', `an ordered list starts at an integer, written as JavaScript writes it: ${foundAt(html, at)}`, html, at, place)
}
const data = readDataAttributes(start === undefined ? tag.attributes : rest, type, blockNodes[type].attributes, type === 'orderedList' ? ['order'] : [])
const data = readDataAttributes(tag.attributes, tag.name, blockNodes[type].attributes, ordered ? ['order'] : [], ordered ? 'start' : undefined)
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}>`
@@ -234,7 +237,7 @@ function readItem(html: string, index: number, place: Place, depth: number): Res
const stop = misread(html, index, 'a list holds <li> items, one to a line', 'no list item of the dialect')
return refused('unmappable-html', stop.message, html, stop.at, place)
}
const data = readDataAttributes(tag.attributes, 'listItem', blockNodes.listItem.attributes, [])
const data = readDataAttributes(tag.attributes, tag.name, 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 })
+1 -1
View File
@@ -47,7 +47,7 @@ function readMarked(html: string, index: number, place: Place): ReadNode {
at = tag.end
}
if (at === html.length || html.startsWith('<', at)) {
const stop = misread(html, at, 'a mark element holds one text, or one mark element holding it', 'no mark element of the dialect, and a mark element holds one text, or one mark element holding it')
const stop = misread(html, at, 'a mark element holds text, or one other mark element around text', 'no mark element of the dialect, and a mark element holds text, or one other mark element around text')
return refused('unmappable-html', stop.message, html, stop.at, place)
}
const text = readText(html, at, marks, place)