From 12230b5e9c92498096da84e0daa9c7061d554232 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 28 Oct 2026 03:07:37 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20prose=20round=201:=200=20=F0=9F=94=B4,?= =?UTF-8?q?=208=20=F0=9F=9F=A1=20(0=20misread);=20refusals=20name=20the=20?= =?UTF-8?q?tag=20and=20what=20they=20found,=20the=20start-after-data-*=20m?= =?UTF-8?q?essage=20no=20longer=20false,=20README,=20spec=20and=20changelo?= =?UTF-8?q?g=20reworded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++-- README.md | 51 +++++++++++++++------------- docs/decisions.md | 2 +- spec/html.md | 14 ++++---- src/adf/carried-json.ts | 2 +- src/canonical-json.ts | 2 +- src/conformance/adf-property.test.ts | 6 ++-- src/html/carry-attributes.ts | 5 +++ src/html/carry-content.ts | 2 +- src/html/data-attributes.ts | 21 +++++++----- src/html/escaping.ts | 2 +- src/html/parse/carry-element.ts | 15 ++++---- src/html/parse/html-to-adf.test.ts | 23 +++++++++++-- src/html/parse/html-to-adf.ts | 33 ++++++++++-------- src/html/parse/inline.ts | 2 +- 15 files changed, 112 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 505fc89..ab268aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8a6c10f..875da08 100644 --- a/README.md +++ b/README.md @@ -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) //

Release notes

//

Read the spec, then ask @Mikael

+ +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 `(image)`; 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 ``, -`
` or `'`, 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 ``, `
` or `'`, 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 `

`, 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 | diff --git a/docs/decisions.md b/docs/decisions.md index 4e87ed2..3bb5134 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 diff --git a/spec/html.md b/spec/html.md index eb248b4..4b4640a 100644 --- a/spec/html.md +++ b/spec/html.md @@ -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 `

`, 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. diff --git a/src/adf/carried-json.ts b/src/adf/carried-json.ts index b856939..309070d 100644 --- a/src/adf/carried-json.ts +++ b/src/adf/carried-json.ts @@ -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 { const parsed = parseJsonText(json) if (parsed.refusal === undefined) return { value: parsed.value } diff --git a/src/canonical-json.ts b/src/canonical-json.ts index 55e9fdf..bd7179f 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -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)) { diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 59602c5..398aea7 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -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) => { diff --git a/src/html/carry-attributes.ts b/src/html/carry-attributes.ts index b8859fd..b78ebd1 100644 --- a/src/html/carry-attributes.ts +++ b/src/html/carry-attributes.ts @@ -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 diff --git a/src/html/carry-content.ts b/src/html/carry-content.ts index 36f9c29..1e2cd52 100644 --- a/src/html/carry-content.ts +++ b/src/html/carry-content.ts @@ -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 `
`, each block's content as a `

`, a code block's as `

`; in a ``, the blocks parted by `
`. +// 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, '
').map(({ html }) => html).join('
') return shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `

${html}

` : `
${html}
`)).join('') diff --git a/src/html/data-attributes.ts b/src/html/data-attributes.ts index e6ae1c3..3fb0e37 100644 --- a/src/html/data-attributes.ts +++ b/src/html/data-attributes.ts @@ -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 { +// 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 { 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 } } diff --git a/src/html/escaping.ts b/src/html/escaping.ts index 1dd6961..a39db0b 100644 --- a/src/html/escaping.ts +++ b/src/html/escaping.ts @@ -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 } diff --git a/src/html/parse/carry-element.ts b/src/html/parse/carry-element.ts index f42177d..3523bb5 100644 --- a/src/html/parse/carry-element.ts +++ b/src/html/parse/carry-element.ts @@ -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)}` 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 ; 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 ; 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 { +function heldNode(json: string, levels: number, html: string, jsonAt: number): Read { 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) } diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts index 3ab777c..7030c7d 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -290,10 +290,26 @@ const messages: readonly (readonly [string, string])[] = [ ['
    \n
  • see x
  • \n
\n', ' holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x\\n\\n"'], ['
    \n
  • a

    x
  • \n
\n', 'an item\'s line ends at , or at a line feed before the item\'s next block: found "x\\n"…'], ['
    \n
  • Bolt
      \n
    • a
    • \n
  • \n
\n', 'an item\'s line ends at , or at a line feed before the item\'s next block: found "
    \\n
  • a
  • \\n
x

\n', ' as written here is no mark element of the dialect, and a mark element holds one text, or one mark element holding it: found "x

\\n"'], - ['


x

\n', '
as written here is no mark element of the dialect, and a mark element holds one text, or one mark element holding it: found "
x

\\n"'], + ['

x

\n', ' as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "x

\\n"'], + ['


x

\n', '
as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "
x

\\n"'], + ['

\n', 'a mark element holds text, or one other mark element around text: found "

\\n"'], ['

x

\n', ' holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x

"…'], - [`

a

\n${carry('div', panel, '

Check the collation.

')}\n`, 'a carry element shows its node\'s text as adfToHtml writes it, then ends at
; an edit to it is no edit to the node: found "collation.

a

\n${carry('div', panel, '

Check the collation.

')}\n`, 'a carry element shows exactly what adfToHtml writes for its node, then ; to change the node, edit its ADF and write the HTML again: found "collation.

a

\n', 'a of the dialect is a carry element, holding class="adf-carry" and then data-adf alone: found "a

\\n"'], + ['
\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ "type":""…'], + [`${documentCarry}\n
\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n
\\n"'], + [`
\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "
\n
  • Bolt

  • \n\n', 'an item\'s first paragraph holding content and no attribute stands bare on the item\'s line, as in
  • Bolt
  • : found "

    Bolt

    \\n\\n"'], + ['
      \n
    • \n
    \n', 'an item\'s first paragraph holding neither content nor an attribute stands on a line of its own: found "

    \\n\\n"'], + ['
      \n
    • \n

      \n
    • \n
    \n', 'an item\'s first paragraph holding an attribute stands as its

    on the item\'s line, straight after

  • : found "

    b

    \n', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside where a link marks it: found "b

    \\n"'], + ['

    \n', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside
    where a link marks it: found "\\"\\"

    "…'], + ['
      \n
    1. a
    2. \n
    \n', 'an ordered list starts at an integer, written as JavaScript writes it: found "03\\">\\n
  • a
  • \\n\\n"'], + ['

    a

    \n', '

    takes no class attribute'], + ['

    a

    \n', '

    takes no data-color attribute'], + ['

      \n
    1. a
    2. \n
    \n', '
      takes start once, before its data-* attributes: found start after data-local-id'], + ['
        \n
      1. a
      2. \n
      \n', '
        takes start once, before its data-* attributes: found start after start'], + ['
        x
        \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 => `${'
        \n'.repeat(levels)}

        x

        ${'\n
        '.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 => `${'
          \n
        • x\n'.repeat(levels)}
          ${'\n
        • \n
        '.repeat(levels)}\n` assert.equal(refusal(htmlToAdf(lists(500))), 'read') assert.equal(refusal(htmlToAdf(lists(501))).startsWith('unsupported-nesting-depth'), true) diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index fc1af5d..08e02b8 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -46,12 +46,12 @@ const documentCarryAlone = 'a carry element holding the document stands alone, a const documentCarryEnd = `${carryContent({ type: 'doc' }, 'div')}
    ` const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact') const formRefusals: Readonly> = { - bare: "an item's first paragraph holding content and no attribute stands bare on the item's line:
  • Bolt
  • ", + bare: "an item's first paragraph holding content and no attribute stands bare on the item's line, as in
  • Bolt
  • ", 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

    on the item's line, straight after

  • ", } -// 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 { return positioned( withinEngineLimits(() => readHtml(html)), @@ -71,7 +71,7 @@ function readDocumentCarry(html: string): Result | 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 { 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 ? '

    ' : '

    ' if (node === undefined || !html.startsWith(closer, image.end)) { - return refused('unmappable-html', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside 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 …, alt left out where it holds none, inside 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 { - 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 { - 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 { - 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 { - 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 = `` @@ -234,7 +237,7 @@ function readItem(html: string, index: number, place: Place, depth: number): Res const stop = misread(html, index, 'a list holds
  • 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('
  • ', tag.end)) return success({ end: tag.end + ''.length, kind: 'node', node: item }) diff --git a/src/html/parse/inline.ts b/src/html/parse/inline.ts index dd15125..039768e 100644 --- a/src/html/parse/inline.ts +++ b/src/html/parse/inline.ts @@ -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)