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:
+3
-3
@@ -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
|
||||
|
||||
@@ -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="{"attrs":{"id":"5b10a2","text":"@Mikael"},"type":"mention"}">@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 `'`, 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 `'`, 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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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="{ "type":"x"}"></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 "{ "type":""…'],
|
||||
[`${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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user