diff --git a/src/html/escaping.ts b/src/html/escaping.ts index 75e91c9..bc25b2d 100644 --- a/src/html/escaping.ts +++ b/src/html/escaping.ts @@ -1,14 +1,5 @@ export type Spelling = 'attribute' | 'text' -const escapable = /[&<>"]/g -const escapes: ReadonlyMap = new Map([ - ['"', '"'], - ['&', '&'], - ['<', '<'], - ['>', '>'], -]) -const writtenEntities = [...escapes.values()] -const entity = /&(?:amp|gt|lt|nbsp|quot);/g const entityValues: ReadonlyMap = new Map([ ['&', '&'], ['>', '>'], @@ -16,16 +7,22 @@ const entityValues: ReadonlyMap = new Map([ [' ', '\u00a0'], ['"', '"'], ]) +const entityNames = [...entityValues.keys()].map((spelled) => spelled.slice(1, -1)).join('|') +const entity = new RegExp(`&(?:${entityNames});`, 'g') +// `adfToHtml` writes every character of `entityValues` as its entity but the no-break space. +const escapes: ReadonlyMap = new Map([...entityValues].filter(([, character]) => character !== '\u00a0').map(([spelled, character]): [string, string] => [character, spelled])) +const escapable = new RegExp(`[${[...escapes.keys()].join('')}]`, 'g') +const writtenEntities = [...escapes.values()] // What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): any control but a tab, line feed or form feed, a carriage return among them, since a parser reads it as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode. const unwritable = /(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u const unwritables = new RegExp(unwritable.source, 'gu') -const strayAmpersand = '&(?!(?:amp|gt|lt|nbsp|quot);)' -// What reads wrongly in a value: `&` outside an entity, a character HTML cannot hold, and in text a `<` or `>`. An attribute value may hold `<` and `>` raw, as older browsers write it, and text may hold `"`. +const strayAmpersand = `&(?!(?:${entityNames});)` +// An attribute value may hold a raw `<` or `>`, as older browsers write them; text may hold a raw `"`. const misspellings: Readonly> = { attribute: new RegExp(`${strayAmpersand}|${unwritable.source}`, 'u'), text: new RegExp(`${strayAmpersand}|[<>]|${unwritable.source}`, 'u'), } -// What `adfToHtml` spells with an entity, a browser (WHATWG HTML §13.3) spells raw in text, or in an attribute; a no-break space it spells ` ` in both. +// For each spelling, the other way a browser writes what `adfToHtml` writes as the key (WHATWG HTML §13.3). const browserSpellings: Readonly>> = { attribute: new Map([['>', '>'], ['<', '<'], ['\u00a0', ' ']]), text: new Map([['"', '"'], ['\u00a0', ' ']]), @@ -56,14 +53,14 @@ export function shownValue(value: string, lineFeed: string): string { return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', lineFeed) } -// Where `html` reads as `shown` from `index`: `whole`, and the index after it, or where it first reads otherwise. `shown` is spelled as `adfToHtml` writes it, `html` as a browser may too. +// Whether `html` holds `shown` from `index` (`whole`), and the index where the match ends or first fails. `shown` is as `adfToHtml` writes it; `html` may spell it as a browser does. export function readsAsShown(html: string, index: number, shown: string): { at: number; whole: boolean } { if (html.startsWith(shown, index)) return { at: index + shown.length, whole: true } let at = index let spelling: Spelling = 'text' for (let next = 0; next < shown.length; ) { const token = writtenEntities.find((spelled) => shown.startsWith(spelled, next)) ?? shown.charAt(next) - // Only a tag holds a raw `<` or `>`, and only its attribute values an escape. + // A shown `<` opens a tag, whose attribute values may spell `<` and `>` raw; a shown `>` ends it. if (token === '<') spelling = 'attribute' if (token === '>') spelling = 'text' const read = [token, browserSpellings[spelling].get(token)].find((spelled) => spelled !== undefined && html.startsWith(spelled, at)) diff --git a/src/html/parse/carry-element.ts b/src/html/parse/carry-element.ts index ee9fc17..5995d49 100644 --- a/src/html/parse/carry-element.ts +++ b/src/html/parse/carry-element.ts @@ -20,7 +20,7 @@ export function readCarry(html: string, tag: StartTag, element: 'div' | 'span', const shown = `${carryContent(node.value, element)}` const read = readsAsShown(html, tag.end, shown) if (read.whole) return success({ end: read.at, node: node.value }) - 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, read.at)}`, html, read.at, place) + return refused('unmappable-html', `a carry element shows what adfToHtml writes for its node, as it or a browser spells it, then ; to change the node, edit its ADF and write the HTML again: ${foundAt(html, read.at)}`, html, read.at, place) } function heldNode(json: string, levels: number, html: string, jsonAt: number): Read { diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts index 488e58e..9f1da35 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -327,14 +327,16 @@ test('refuses a tag of three million attributes, past what one pattern over them const messages: readonly (readonly [string, string])[] = [ ['

ab

\n', ' as written here is no inline element of the dialect: found "b

\\n"'], ['

a > b

\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "> b

\\n"'], - ['

a

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

\\n"'], + ['

a

\n', ' holds its attributes as name="value", each value escaping & as & and " as ", then >: found "b\\">a

\\n"'], + ['

&b

\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "&b

\\n"'], + ['
a&b
\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "&b\\n"'], ['

a 

\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found " 

\\n"'], ['
a
: found "x\n', ' as written here is no code element of the dialect, which writes or : found "\n
  • a\n', 'an item\'s line ends at
  • , or at a line feed before the item\'s next block, and its text spells < as <: found "\\n"'], ['
      \n
    • ab
    • \n
    \n', ' as written here is no inline element of the dialect: found "b\\n\\n"'], ['
      \n
    • if a < b
    • \n
    \n', 'an item\'s line ends at , or at a line feed before the item\'s next block, and its text spells < as <: found "< b\\n\\n"'], - ['
      \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
    • see x
    • \n
    \n', ' holds its attributes as name="value", each value escaping & as & and " as ", 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
    \n
  • a
  • b
  • \n\n', 'an item\'s line ends at , or at a line feed before the item\'s next block: found "
  • b
  • \\n\\n"'], @@ -342,8 +344,8 @@ const messages: readonly (readonly [string, string])[] = [ ['

    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 exactly what adfToHtml writes for its node, then ; to change the node, edit its ADF and write the HTML again: found "collation.

    x

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

    "…'], + [`

    a

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

    Check the collation.

    ')}\n`, 'a carry element shows what adfToHtml writes for its node, as it or a browser spells it, 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"'], diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index ad59120..102894a 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -51,7 +51,7 @@ const formRefusals: Readonly> = { 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. +// Reads back what `adfToHtml` writes, as it or a browser spells it. export function htmlToAdf(html: string): Result { return positioned( withinEngineLimits(() => readHtml(html)), @@ -59,15 +59,14 @@ export function htmlToAdf(html: string): Result { ) } -// A form post ends each line in `\r\n`, which reads as the line feed `adfToHtml` writes; a refusal's position still counts the HTML as handed in. +// A form post ends each line in `\r\n`, which reads as the line feed `adfToHtml` writes. function readHtml(html: string): Result { if (typeof html !== 'string') return notAString(html, 'HTML') const read = readLineFeeds(html.replaceAll('\r\n', '\n')) - if (read.ok || read.error.position === undefined) return read - return { error: { ...read.error, position: positionIn(html, read.error.position) }, ok: false } + return read.ok ? read : { error: { ...read.error, position: positionIn(html, read.error.position) }, ok: false } } -function readLineFeeds(html: string): Result { +function readLineFeeds(html: string): Result { if (html === '') return success({ content: [], type: 'doc', version: 1 }) return readDocumentCarry(html) ?? readBlocks(html) } diff --git a/src/html/parse/source.ts b/src/html/parse/source.ts index 20d7607..da72ec2 100644 --- a/src/html/parse/source.ts +++ b/src/html/parse/source.ts @@ -7,7 +7,7 @@ export type Place = { index: number; parent: Place | undefined } export type ReadNode = Result<{ end: number; node: AdfNode }, ParseError> -// A start tag as `adfToHtml` writes it, its attributes' values unescaped. +// A start tag as `adfToHtml` or a browser writes it, its attributes' values unescaped. export type StartTag = { attributes: (readonly [string, string])[]; end: number; name: string; start: number } // A tag whose name reads, its attributes or its `>` not: `stopped` is where reading them stopped. @@ -34,7 +34,7 @@ export function misread(html: string, index: number, expected: string, unexpecte const tag = scanStartTag(html, index) if (tag === undefined) return { at: index, message: `${expected}: ${foundAt(html, index)}` } if ('end' in tag) return { at: index, message: `<${tag.name}> as written here is ${unexpected}: ${foundAt(html, index)}` } - return { at: tag.stopped, message: `<${tag.name}> holds its attributes as name="value", each value escaped as text is, then >: ${foundAt(html, tag.stopped)}` } + return { at: tag.stopped, message: `<${tag.name}> holds its attributes as name="value", each value escaping & as & and " as ", then >: ${foundAt(html, tag.stopped)}` } } // What stands at `index`, for a refusal's message, a character no one sees spelled as its escape. @@ -45,7 +45,7 @@ export function foundAt(html: string, index: number): string { return `found ${spelled}${index + found.length < html.length ? '…' : ''}` } -// The start of the same line in `html`, where the position was counted after its `\r\n` became line feeds. +// The position of the start of `line` in `html` as handed in, for a line counted after each `\r\n` became a line feed. export function positionIn(html: string, { line }: SourcePosition): SourcePosition { let offset = 0 for (let passed = 1; passed < line; passed += 1) offset = html.indexOf('\n', offset) + 1