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) //
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 ``, -``, 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)}${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 {
+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"'],
['\na
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"'],
['\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 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}\nBolt
Bolt
\\n\\n"'], + ['on the item\'s line, straight after
a
\n', 'takes no class attribute'], + ['
a
\n', 'takes no data-color attribute'], + ['
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)}'.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 => `${'x
${'\n
on the item's line, straight after