diff --git a/src/adf/attribute-vocabulary.ts b/src/adf/attribute-vocabulary.ts index 0697ba8..f44b785 100644 --- a/src/adf/attribute-vocabulary.ts +++ b/src/adf/attribute-vocabulary.ts @@ -20,6 +20,10 @@ export function isNumberList(value: unknown): value is NumberList { return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'number') } +export function holdsKind(value: JsonValue, kind: AttributeKind): boolean { + return vocabularyPair('', value, kind) !== undefined +} + export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): VocabularyPair[] | undefined { const pairs: VocabularyPair[] = [] for (const [key, value] of Object.entries(attrs)) { @@ -33,7 +37,7 @@ export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocab return pairs } -export function vocabularyPair(key: string, value: JsonValue, kind: AttributeKind): VocabularyPair | undefined { +function vocabularyPair(key: string, value: JsonValue, kind: AttributeKind): VocabularyPair | undefined { if (kind === 'boolean') return typeof value === 'boolean' ? { key, kind, value } : undefined if (kind === 'number') return typeof value === 'number' ? { key, kind, value } : undefined if (kind === 'numbers') return isNumberList(value) ? { key, kind, value } : undefined diff --git a/src/adf/carried-json.ts b/src/adf/carried-json.ts index 96bd59f..b856939 100644 --- a/src/adf/carried-json.ts +++ b/src/adf/carried-json.ts @@ -8,6 +8,9 @@ import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { joinedWithin } from '../length-bound.ts' import { parseJsonText } from '../json-text.ts' +// JSON `carriedNesting` found within the levels its position leaves, the one value `carriedNode` reads. +export type BoundedJson = { readonly bounded: JsonValue } + // `spelled` is the node as the carry holds it, `type` dropped where the format names it apart; `levels` the nesting its position leaves. export function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> { const headroom = levels - nestingDepth(node) @@ -24,12 +27,12 @@ export function carriedValue(json: string, holder: string, invalid: ConvertFault return { fault: parsed.refusal === 'syntax' ? invalid : { code: 'unsupported-node-shape', message: `${holder} holds a number JSON cannot spell` } } } -export function carriedNesting(held: JsonValue, levels: number): ConvertFault | undefined { - return overNested(held, levels) ? nestedPast(levels) : undefined +export function carriedNesting(held: JsonValue, levels: number): Read { + return overNested(held, levels) ? { fault: nestedPast(levels) } : { value: { bounded: held } } } -export function carriedNode(held: JsonValue, holder: string): Read { - return isAdfNode(held) ? { value: held } : { fault: { code: 'unsupported-node-shape', message: `${holder} holds one ADF node's JSON: this JSON is no ADF node` } } +export function carriedNode({ bounded }: BoundedJson, holder: string): Read { + return isAdfNode(bounded) ? { value: bounded } : { fault: { code: 'unsupported-node-shape', message: `${holder} holds one ADF node's JSON: this JSON is no ADF node` } } } function nestedPast(levels: number): ConvertFault { diff --git a/src/html/block-elements.ts b/src/html/block-elements.ts new file mode 100644 index 0000000..1d166f3 --- /dev/null +++ b/src/html/block-elements.ts @@ -0,0 +1,30 @@ +import type { JsonValue } from '../json-value.ts' + +export type ContainerType = 'blockquote' | 'bulletList' | 'orderedList' + +// The element of each node whose blocks, or list items, stand a line each inside it. +export const containerElements: Readonly> = { blockquote: 'blockquote', bulletList: 'ul', orderedList: 'ol' } +const headingLevels: readonly unknown[] = [1, 2, 3, 4, 5, 6] +export const headingElements: readonly string[] = headingLevels.map((level) => `h${level}`) + +// `undefined` where the level is none of 1 to 6, so the heading rides the carry. +export function headingElement(level: JsonValue | undefined): string | undefined { + return headingLevels.includes(level) ? `h${level}` : undefined +} + +// The level of `

` to `

`, 0 for any other element. +export function headingLevel(name: string): number { + return headingElements.indexOf(name) + 1 +} + +// An ordered list's ` start="…"`, `''` where it holds no order; `undefined` where `start` cannot spell the order: one that is no safe integer, or -0. +export function listStart(order: JsonValue | undefined): string | undefined { + if (order === undefined) return '' + return typeof order === 'number' && Number.isSafeInteger(order) && !Object.is(order, -0) ? ` start="${order}"` : undefined +} + +// The order a `start` value spells; `undefined` where `listStart` writes no such value. +export function readListStart(value: string): number | undefined { + const order = Number(value) + return listStart(order) === ` start="${value}"` ? order : undefined +} diff --git a/src/html/carry-content.ts b/src/html/carry-content.ts new file mode 100644 index 0000000..558233d --- /dev/null +++ b/src/html/carry-content.ts @@ -0,0 +1,66 @@ +import type { AdfNode } from '../adf/document.ts' +import type { LinkAttrs } from '../adf/mark-attributes.ts' +import { anchorOpener } from './anchor-element.ts' +import { languageClass } from './language-class.ts' +import { nodeAttrs, nodeContent } from '../adf/document.ts' +import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../adf/shown-text.ts' +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: in a `
`, each block's content as a `

`, a code block's as `

`; in a ``, the blocks parted by `
`. +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('') +} + +// What a carry element shows, block by block, as the writer panels picked it (docs/decisions.md §The HTML dialect). +function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] { + const shown: ShownBlock[] = [] + let current: ShownBlock = { code: undefined, html: '' } + // Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, and what follows stands in that entry's `code`, the code block around the block that ended. + const pending: { code: AdfNode | undefined; node: AdfNode | null }[] = [{ code: undefined, node: root }] + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const { node } = next + const code = node?.type === 'codeBlock' ? node : node !== null && partsShownText(node.type) ? undefined : next.code + if (node === null || partsShownText(node.type)) { + if (current.html !== '') shown.push(current) + current = { code, html: '' } + } + if (node === null) continue + current = { ...current, html: current.html + shownPiece(node, current.code === undefined ? '
' : codeLineFeed) } + if (partsShownText(node.type)) pending.push({ code: next.code, node: null }) + for (const child of [...nodeContent(node)].reverse()) pending.push({ code, node: child }) + } + if (current.html !== '') shown.push(current) + return shown +} + +// The node's own part of what its carry element shows. +function shownPiece(node: AdfNode, lineFeed: string): string { + if (node.type === 'hardBreak') return '
' + if (['media', 'mediaInline'].includes(node.type)) return linkedImage(node, lineFeed) + return linkedText(node, shownText(node), shownUrl(node), lineFeed) +} + +// As portable markdown writes it inline, except that a link around a note stays and an image's own `text`, outside ADF's schema, shows first (docs/decisions.md §The HTML dialect). +function linkedImage(media: AdfNode, lineFeed: string): string { + const image = shownImage(media, shownLink(media)?.href) + const shown = linkedText(media, image?.text, image?.link, lineFeed) + return image?.after === undefined ? shown : `${shown} (${linked('image', { href: image.after })})` +} + +function linkedText(node: AdfNode, standIn: string | undefined, url: string | undefined, lineFeed: string): string { + const link = shownLink(node) ?? (url === undefined ? undefined : { href: url }) + const text = node.text ?? standIn + if (text !== undefined) return linked(shownValue(text, lineFeed), link) + const name = referenceName(node) + return name === undefined ? '' : linked(`(${shownValue(name, lineFeed)})`, link) +} + +// The shown HTML inside an `` where it links and HTML can hold the link. +function linked(shown: string, link: LinkAttrs | undefined): string { + const anchor = link === undefined ? undefined : anchorOpener(link) + return anchor === undefined ? shown : `${anchor}${shown}` +} diff --git a/src/html/data-attributes.ts b/src/html/data-attributes.ts index 519ce09..e6ae1c3 100644 --- a/src/html/data-attributes.ts +++ b/src/html/data-attributes.ts @@ -5,8 +5,8 @@ import type { JsonValue } from '../json-value.ts' import { holdsOnlyAttributes, nodeAttrs } from '../adf/document.ts' import { optionalAttribute } from './escaping.ts' import { parseJsonText } from '../json-text.ts' -import { serializeCanonicalJson } from '../canonical-json.ts' -import { vocabularyPair, vocabularyPairs } from '../adf/attribute-vocabulary.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 { @@ -27,7 +27,7 @@ export function readDataAttributes(pairs: readonly (readonly [string, string])[] const attrs: AdfAttributes = {} let previous = '' for (const [name, spelled] of pairs) { - const [key, kind] = Object.entries(vocabulary).find(([candidate]) => !written.includes(candidate) && dataName(candidate) === name) ?? [] + 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`) } } @@ -40,6 +40,11 @@ export function readDataAttributes(pairs: readonly (readonly [string, string])[] return { value: attrs } } +// The attribute `name` spells, among those the element itself does not write. +function dataKey(name: string, vocabulary: AttributeVocabulary, written: readonly string[]): [string, AttributeKind] | undefined { + return Object.entries(vocabulary).find(([candidate]) => !written.includes(candidate) && dataName(candidate) === name) +} + function dataName(key: string): string { return `data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}` } @@ -47,7 +52,7 @@ function dataName(key: string): string { function dataValue(spelled: string, kind: AttributeKind): JsonValue | undefined { if (kind === 'string') return spelled const { value } = parseJsonText(spelled) - return value !== undefined && serializeCanonicalJson(value, 'compact') === spelled && vocabularyPair('', value, kind) !== undefined ? value : undefined + return value !== undefined && holdsKind(value, kind) && spellsCanonicalJson(value, 'compact', spelled) ? value : undefined } function unmappable(message: string): ConvertFault { diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index d12645c..1ab2556 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import { anchorOpener } from '../anchor-element.ts' import { blockNodes } from '../../adf/block-nodes.ts' import { carriedBlock, carryElement } from './carry-element.ts' +import { containerElements, headingElement, listStart } from '../block-elements.ts' import { dataAttributes } from '../data-attributes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { firstParagraphForm } from '../first-paragraph.ts' @@ -19,8 +20,6 @@ type Block = { depth: number; kind: 'block'; node: AdfNode; path: ConvertErrorPa type Item = { attributes: string; depth: number; kind: 'item'; node: AdfNode; path: ConvertErrorPath } type Pending = Block | Item | string -const headingLevels: readonly unknown[] = [1, 2, 3, 4, 5, 6] - export function adfToHtml(document: AdfDocument): Result { return withinEngineLimits(() => writeHtml(document)) } @@ -66,7 +65,7 @@ function writeBlock(block: Block): Result { function quotePieces({ depth, node, path }: Block): Pending[] | undefined { const attributes = dataAttributes(node, blockNodes.blockquote.attributes, []) - return attributes === undefined ? undefined : containerPieces('blockquote', attributes, node.content === undefined ? undefined : blockPieces(node.content, path, depth + 1)) + return attributes === undefined ? undefined : containerPieces(containerElements.blockquote, attributes, node.content === undefined ? undefined : blockPieces(node.content, path, depth + 1)) } // `undefined` where the list, or one of its items, holds what the elements cannot, so the whole list rides the carry. @@ -87,13 +86,12 @@ function listPieces({ depth, node, path }: Block): Pending[] | undefined { function listElement(node: AdfNode): { attributes: string; tag: string } | undefined { if (node.type === 'bulletList') { const attributes = dataAttributes(node, blockNodes.bulletList.attributes, []) - return attributes === undefined ? undefined : { attributes, tag: 'ul' } + return attributes === undefined ? undefined : { attributes, tag: containerElements.bulletList } } if (node.type !== 'orderedList') return undefined - const order = nodeAttrs(node)['order'] - if (order !== undefined && (!Number.isSafeInteger(order) || Object.is(order, -0))) return undefined + const start = listStart(nodeAttrs(node)['order']) const attributes = dataAttributes(node, blockNodes.orderedList.attributes, ['order']) - return attributes === undefined ? undefined : { attributes: `${order === undefined ? '' : ` start="${order}"`}${attributes}`, tag: 'ol' } + return start === undefined || attributes === undefined ? undefined : { attributes: `${start}${attributes}`, tag: containerElements.orderedList } } // `children` undefined where the node holds no `content` key, so its element holds nothing. @@ -122,8 +120,8 @@ function tryFirstParagraph(paragraph: AdfNode, path: ConvertErrorPath): Result | undefined { if (node.type === 'paragraph') return inlineElement('p', dataAttributes(node, blockNodes.paragraph.attributes, []), nodeContent(node), path) - const level = nodeAttrs(node)['level'] - if (node.type === 'heading' && headingLevels.includes(level)) return inlineElement(`h${level}`, dataAttributes(node, blockNodes.heading.attributes, ['level']), nodeContent(node), path) + const heading = node.type === 'heading' ? headingElement(nodeAttrs(node)['level']) : undefined + if (heading !== undefined) return inlineElement(heading, dataAttributes(node, blockNodes.heading.attributes, ['level']), nodeContent(node), path) const html = tryCodeBlock(node) ?? tryImage(node) ?? tryRule(node) return html === undefined ? undefined : success(html) } diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts index d124fb5..526dade 100644 --- a/src/html/emit/carry-element.ts +++ b/src/html/emit/carry-element.ts @@ -1,80 +1,21 @@ import type { AdfNode } from '../../adf/document.ts' -import type { LinkAttrs } from '../../adf/mark-attributes.ts' -import { anchorOpener } from '../anchor-element.ts' import { carriedJson } from '../../adf/carried-json.ts' import { carryAttributes } from '../carry-attributes.ts' -import { languageClass } from '../language-class.ts' +import { carryContent } from '../carry-content.ts' import { largestNesting } from '../../nesting.ts' -import { nodeAttrs, nodeContent } from '../../adf/document.ts' -import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts' -import { shownValue } from '../escaping.ts' import { success, type ConvertErrorPath, type Result } from '../../result.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 } - export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { const json = carriedJson(node, node, 'compact', path, largestNesting - depth) if (!json.ok) return json - const shown = shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `

${html}

` : `
${html}
`)) - return success(carryElement('div', json.value.json, shown.join(''))) + return success(carryElement('div', json.value.json, carryContent(node, 'div'))) } export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { const json = carriedJson(node, node, 'compact', path, largestNesting) - return json.ok ? success(carryElement('span', json.value.json, shownBlocks(node, '
').map(({ html }) => html).join('
'))) : json + return json.ok ? success(carryElement('span', json.value.json, carryContent(node, 'span'))) : json } export function carryElement(tag: string, json: string, content: string): string { return `<${tag}${carryAttributes(json)}>${content}` } - -// What a carry element shows, block by block, as the writer panels picked it (docs/decisions.md §The HTML dialect); a reader of the element reads its JSON alone. -function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] { - const shown: ShownBlock[] = [] - let current: ShownBlock = { code: undefined, html: '' } - // Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, and what follows stands in that entry's `code`, the code block around the block that ended. - const pending: { code: AdfNode | undefined; node: AdfNode | null }[] = [{ code: undefined, node: root }] - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - const { node } = next - const code = node?.type === 'codeBlock' ? node : node !== null && partsShownText(node.type) ? undefined : next.code - if (node === null || partsShownText(node.type)) { - if (current.html !== '') shown.push(current) - current = { code, html: '' } - } - if (node === null) continue - current = { ...current, html: current.html + shownPiece(node, current.code === undefined ? '
' : codeLineFeed) } - if (partsShownText(node.type)) pending.push({ code: next.code, node: null }) - for (const child of [...nodeContent(node)].reverse()) pending.push({ code, node: child }) - } - if (current.html !== '') shown.push(current) - return shown -} - -// The node's own part of what its carry element shows. -function shownPiece(node: AdfNode, lineFeed: string): string { - if (node.type === 'hardBreak') return '
' - if (['media', 'mediaInline'].includes(node.type)) return linkedImage(node, lineFeed) - return linkedText(node, shownText(node), shownUrl(node), lineFeed) -} - -// As portable markdown writes it inline, except that a link around a note stays and an image's own `text`, outside ADF's schema, shows first (docs/decisions.md §The HTML dialect). -function linkedImage(media: AdfNode, lineFeed: string): string { - const image = shownImage(media, shownLink(media)?.href) - const shown = linkedText(media, image?.text, image?.link, lineFeed) - return image?.after === undefined ? shown : `${shown} (${linked('image', { href: image.after })})` -} - -function linkedText(node: AdfNode, standIn: string | undefined, url: string | undefined, lineFeed: string): string { - const link = shownLink(node) ?? (url === undefined ? undefined : { href: url }) - const text = node.text ?? standIn - if (text !== undefined) return linked(shownValue(text, lineFeed), link) - const name = referenceName(node) - return name === undefined ? '' : linked(`(${shownValue(name, lineFeed)})`, link) -} - -// The shown HTML inside an `` where it links and HTML can hold the link. -function linked(shown: string, link: LinkAttrs | undefined): string { - const anchor = link === undefined ? undefined : anchorOpener(link) - return anchor === undefined ? shown : `${anchor}${shown}` -} diff --git a/src/html/escaping.ts b/src/html/escaping.ts index 5ad8e8a..51b9c09 100644 --- a/src/html/escaping.ts +++ b/src/html/escaping.ts @@ -7,6 +7,8 @@ const entities: ReadonlyMap = new Map([ // 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') +// An `&` opening no entity, a character `escapeHtml` escapes standing raw, or one HTML cannot hold. +const misspelling = new RegExp(`&(?!${[...entities.keys()].map((entity) => entity.slice(1)).join('|')})|[<>"]|${unwritable.source}`, 'u') // The carry's JSON, which spells each character HTML cannot hold as a JSON escape reading back to the same string. export function holdableJson(json: string): string { @@ -17,6 +19,11 @@ export function jsonValue(json: string): string { return escapeHtml(holdableJson(json)) } +// The index of the first character `writableValue` would not write so, -1 where none stands in `html`. +export function misspelledAt(html: string): number { + return html.search(misspelling) +} + // ` name="value"`, `''` where the value is undefined, `undefined` where HTML cannot hold it. export function optionalAttribute(name: string, value: string | undefined): string | undefined { if (value === undefined) return '' @@ -30,8 +37,7 @@ export function shownValue(value: string, lineFeed: string): string { // The text or attribute value escaped HTML spells, `undefined` where `writableValue` would not write it so. export function unescapedValue(html: string): string | undefined { - const value = html.replace(/&(?:amp|gt|lt|quot);/g, (entity) => entities.get(entity) ?? entity) - return writableValue(value) === html ? value : undefined + return misspelledAt(html) < 0 ? html.replace(/&(?:amp|gt|lt|quot);/g, (entity) => entities.get(entity) ?? entity) : undefined } // `undefined` where HTML cannot hold the value, so its node rides the carry element. diff --git a/src/html/parse/carry-element.ts b/src/html/parse/carry-element.ts index fa33b1b..f42177d 100644 --- a/src/html/parse/carry-element.ts +++ b/src/html/parse/carry-element.ts @@ -1,37 +1,35 @@ import type { AdfNode } from '../../adf/document.ts' -import type { JsonValue } from '../../json-value.ts' -import type { ParseError, Read, Result } from '../../result.ts' +import type { Read } from '../../result.ts' import { carriedNesting, carriedNode, carriedValue } from '../../adf/carried-json.ts' +import { carryContent } from '../carry-content.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 { refused, type Place, type StartTag } from './source.ts' import { spellsCanonicalJson } from '../../canonical-json.ts' import { success } from '../../result.ts' const holder = "a carry element's data-adf" -// The JSON a carry element holds, and the element's end; what it shows is skipped unread (docs/decisions.md §The HTML dialect). -export function readCarry(html: string, tag: StartTag, levels: number, place: Place): Result<{ end: number; held: JsonValue; json: string }, ParseError> { +// 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 held = heldJson(json, levels) - if (held.fault !== undefined) return refused(held.fault.code, held.fault.message, html, tag.end, place) - const closer = `` - const end = html.indexOf(closer, tag.end) - return end < 0 ? refused('unmappable-html', `a carry element ends at ${closer}: found none`, html, tag.end, place) : success({ end: end + closer.length, held: held.value, json }) + const node = heldNode(json, levels) + if (node.fault !== undefined) return refused(node.fault.code, node.fault.message, html, tag.end, 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) } -// Short of the node, which `heldNode` reads once the element's end is found, so a carry missing its closing tag is refused for that first. -function heldJson(json: string, levels: number): Read { +function heldNode(json: string, levels: 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 !== undefined) return { fault: nested } - if (spellsCanonicalJson(held.value, 'compact', json, holdableJson)) return held - 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" } } -} - -export function heldNode(held: JsonValue, html: string, index: number, place: Place): Result { - const node = carriedNode(held, holder) - return node.fault === undefined ? success(node.value) : refused(node.fault.code, node.fault.message, html, index, place) + 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 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 2a5496e..453f0f1 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -4,11 +4,11 @@ import test from 'node:test' import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { ParseError, Result } from '../../result.ts' import { adfToHtml, htmlToAdf } from '../../index.ts' -import { largestNesting } from '../../nesting.ts' const ids = { item: '019ad5af-efd9-7f4e-a7c9-6aef16d4945f', list: '019d8e77-7df8-7a83-91ea-43a7ca2aae3b', + mention: '0199f4c2-8b1e-7d03-9a6f-2c5e8d71b4a0', paragraph: '01957ad9-e3f7-70aa-a5f3-4c013274f488', quote: '019d2fe4-3e67-7b76-b40a-a776a8f76aea', } @@ -37,6 +37,13 @@ function refusal(result: Result): string { return result.ok ? 'read' : `${result.error.code} at /${result.error.path.join('/')} line ${result.error.position.line} offset ${result.error.position.offset}` } +function message(result: Result): string { + return result.ok ? 'read' : result.error.message +} + +const documentCarry = carry('div', { type: 'doc', version: 1 }, '') +const rule = JSON.stringify({ type: 'unknownBlock' }).replaceAll('"', '"') + // The carry element of `node`, showing `shown`. function carry(tag: 'div' | 'span', node: unknown, shown: string): string { return `<${tag} class="adf-carry" data-adf="${JSON.stringify(node).replaceAll('"', '"')}">${shown}` @@ -44,7 +51,7 @@ function carry(tag: 'div' | 'span', node: unknown, shown: string): string { test('reads an empty string as a document of no blocks, and the document\'s carry as one holding no content key', () => { assert.deepEqual(read(htmlToAdf('')), document()) - assert.deepEqual(read(htmlToAdf(`${carry('div', { type: 'doc', version: 1 }, '')}\n`)), { type: 'doc', version: 1 }) + assert.deepEqual(read(htmlToAdf(`${documentCarry}\n`)), { type: 'doc', version: 1 }) }) test('reads paragraphs, headings and rules, a line feed after each', () => { @@ -115,21 +122,129 @@ test('reads an image standing alone in a paragraph, a link around it where one m assert.deepEqual(read(htmlToAdf('

\n')), document(linked)) }) -test('reads a carry element from its data-adf alone, whatever it shows', () => { +test('reads a carry element from its data-adf, what it shows read back as adfToHtml writes it', () => { const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' } - const mention: AdfNode = { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' } - const expected = document(panel, paragraph(text('Ask '), mention)) - assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '

Check the collation.

')}\n

Ask ${carry('span', mention, '@Mikael')}

\n`)), expected) - assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '

Edited away  

')}\n

Ask ${carry('span', mention, '')}

\n`)), expected) + const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' } + assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '

Check the collation.

')}\n

Ask ${carry('span', mention, '@Mikael')}

\n`)), document(panel, paragraph(text('Ask '), mention))) assert.deepEqual(read(htmlToAdf(`

${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')}

\n`)), document(paragraph(text('a\u0085b', { type: 'underline' })))) }) +test('refuses an edit to what a carry element shows, naming what it found there', () => { + const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' } + const edited = htmlToAdf(`

a

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

Check the collation.

')}\n`) + assert.equal(refusal(edited), 'unmappable-html at /content/1 line 2 offset 9') + assert.match(message(edited), /: found "collation<\/b>\.<\/p><\/d"…$/) + const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' } + assert.equal(refusal(htmlToAdf(`

Ask ${carry('span', mention, '')}

\n`)), 'unmappable-html at /content/0/content/1 line 1 offset 0') +}) + test('reads a carry element holding a node HTML writes as its own element as that node', () => { assert.deepEqual(read(htmlToAdf(`${carry('div', paragraph(text('x')), '

x

')}\n`)), document(paragraph(text('x')))) assert.deepEqual(read(htmlToAdf(`

a${carry('span', text('b'), 'b')}

\n`)), document(paragraph(text('a'), text('b')))) assert.deepEqual(read(htmlToAdf(`
    \n
  • \n${carry('div', paragraph(text('x')), '

    x

    ')}\n
  • \n
\n`)), document({ content: [item(paragraph(text('x')))], type: 'bulletList' })) }) +// Each group's cases, the HTML and its refusal. +const refusals: Readonly> = { + 'refuses block shapes adfToHtml would not write': [ + ['
    \n
  • Bolt

  • \n
\n', 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5'], + ['
    \n
  • \n

    Bolt

    \n
  • \n
\n', 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'], + [`
    \n
  • \n

    \n
  • \n
\n`, 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'], + ['
    \n

  • \n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'], + ['
    \n
  • a
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'], + [`
    \n
  • a

\n`, 'unmappable-html at /content/0/content/0 line 2 offset 5'], + ['
    \n

    a

    \n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'], + ['
    \n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'], + ['
  • a
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
\n

a

\n', 'unmappable-html at /content/0 line 2 offset 13'], + ['

a

b

\n', 'unmappable-html at / line 1 offset 0'], + ['
    \n
  1. a
  2. \n
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
    \n
  1. a
  2. \n
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
    \n
  1. a
  2. \n
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
    \n
  1. a
  2. \n
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
    \n
  1. a
  2. \n
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
    \n
  1. a
  2. \n
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
x
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
x
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
x
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
x
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
a &bogus; b
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
x
\n`, 'unmappable-html at /content/0 line 1 offset 0'], + [`
\n`, 'unmappable-html at /content/0 line 1 offset 0'], + [`
\n`, 'unmappable-html at /content/0 line 1 offset 0'], + [`
\n`, 'unmappable-html at /content/0 line 1 offset 0'], + ['
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'], + ['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['

\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'], + ['

a

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ], + 'refuses a data-* attribute out of the order adfToHtml writes as unmappable-html, and one no attribute of its node spells as unsupported-node-shape': [ + ['
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['
\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

a

\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['

a

\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
    \n
  1. a
  2. \n
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['

a

\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'], + ['
    \n
  • a
  • \n
\n', 'unsupported-node-shape at /content/0/content/0 line 2 offset 5'], + [`
\n`, 'unsupported-node-shape at /content/0 line 1 offset 0'], + ], + 'refuses HTML adfToHtml does not write as unmappable-html, at the start of the line it stands on': [ + ['

x

', 'unmappable-html at / line 1 offset 0'], + ['

x

X', 'unmappable-html at / line 1 offset 0'], + ['

x

\n\n', 'unmappable-html at /content/1 line 2 offset 9'], + ['\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

a

\n
b
\n', 'unmappable-html at /content/1 line 2 offset 9'], + ['

a

\n

b

\n', 'unmappable-html at /content/1 line 2 offset 9'], + ['x\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

a

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

ab

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

a
b

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

it's

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

a > b

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

a\rb

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

a

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

a

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

a

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

a

\n
b\nc
\nd\n', 'unmappable-html at /content/2 line 4 offset 37'], + ], + 'refuses an image adfToHtml would not write': [ + ['

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

b

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

b

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

b

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

\n', 'unmappable-html at /content/0 line 1 offset 0'], + [`

\n`, 'unmappable-html at /content/0 line 1 offset 0'], + ], + 'refuses marks adfToHtml would not write around text': [ + ['

a

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

ab

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

a

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

a &bogus; b

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

a

\n', 'unmappable-html at /content/0/content/1 line 1 offset 0'], + ['

a', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

a', 'unmappable-html at /content/0 line 1 offset 0'], + ], +} + +for (const [group, cases] of Object.entries(refusals)) { + for (const [html, expected] of cases) test(`${group}: ${JSON.stringify(html)}`, () => assert.equal(refusal(htmlToAdf(html)), expected)) +} + test('refuses a value that is not a string as not-a-string', () => { for (const value of [null, 5, undefined, {}]) { // @ts-expect-error -- a caller without types can pass anything. @@ -137,136 +252,54 @@ test('refuses a value that is not a string as not-a-string', () => { } }) -test('refuses HTML adfToHtml does not write as unmappable-html, at the start of the line it stands on', () => { - const refused = (html: string): string => refusal(htmlToAdf(html)) - assert.equal(refused('

x

'), 'unmappable-html at / line 1 offset 0') - assert.equal(refused('

x

\n\n'), 'unmappable-html at /content/1 line 2 offset 9') - assert.equal(refused('\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

a

\n
b
\n'), 'unmappable-html at /content/1 line 2 offset 9') - assert.equal(refused('

a

\n

b

\n'), 'unmappable-html at /content/1 line 2 offset 9') - assert.equal(refused('x\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

a

\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

ab

\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

a
b

\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

it's

\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refused('

a > b

\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refused('

a\rb

\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0') - const lines = `${carry('div', { type: 'unknownBlock' }, 'a\r\nb\rc')}\nd\n` - assert.equal(refused(lines), `unmappable-html at /content/1 line 4 offset ${lines.indexOf('')}`) - assert.equal(refused('

a

\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

a

\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

a

\n'), 'unmappable-html at /content/0 line 1 offset 0') -}) - -test('refuses marks adfToHtml would not write around text', () => { - const refused = (inline: string): string => refusal(htmlToAdf(`

${inline}

\n`)) - assert.equal(refused('a'), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refused(''), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refused('ab'), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refused('a'), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refused('a &bogus; b'), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refused('a'), 'unmappable-html at /content/0/content/1 line 1 offset 0') - assert.equal(refusal(htmlToAdf('

a')), 'unmappable-html at /content/0/content/0 line 1 offset 0') - assert.equal(refusal(htmlToAdf('

a')), 'unmappable-html at /content/0 line 1 offset 0') -}) - -test('refuses block shapes adfToHtml would not write', () => { - const refused = (html: string): string => refusal(htmlToAdf(html)) - assert.equal(refused('

    \n
  • Bolt

  • \n
\n'), 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5') - assert.equal(refused('
    \n
  • \n

    Bolt

    \n
  • \n
\n'), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10') - assert.equal(refused(`
    \n
  • \n

    \n
  • \n
\n`), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10') - assert.equal(refused('
    \n

  • \n
\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5') - assert.equal(refused('
    \n
  • a
\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5') - assert.equal(refused(`
    \n
  • a

\n`), 'unmappable-html at /content/0/content/0 line 2 offset 5') - assert.equal(refused('
    \n

    a

    \n
\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5') - assert.equal(refused('
    \n
\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5') - assert.equal(refused('
  • a
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
\n

a

\n'), 'unmappable-html at /content/0 line 2 offset 13') - assert.equal(refused('

a

b

\n'), 'unmappable-html at / line 1 offset 0') - for (const start of ['03', '1.5', '-0', '', 'x', '9007199254740993']) assert.equal(refused(`
    \n
  1. a
  2. \n
\n`), 'unmappable-html at /content/0 line 1 offset 0', start) - assert.equal(refused('
x
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
x
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
x
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
x
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
a &bogus; b
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
x {
-  const refused = (inner: string): string => refusal(htmlToAdf(`

${inner}

\n`)) - assert.equal(refused(''), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('b'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('b'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('b'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused(''), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refusal(htmlToAdf('

\n')), 'unmappable-html at /content/0 line 1 offset 0') -}) - -test('refuses a data-* attribute out of the order adfToHtml writes as unmappable-html, and one no attribute of its node spells as unsupported-node-shape', () => { - const refused = (html: string): string => refusal(htmlToAdf(html)) - assert.equal(refused('
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

a

\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('

a

\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('
    \n
  1. a
  2. \n
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('
x
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('

a

\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - for (const wrap of ['yes', ' true', '1', '"true"']) assert.equal(refused(`
x
\n`), 'unsupported-node-shape at /content/0 line 1 offset 0', wrap) - assert.equal(refused('
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('
    \n
  • a
  • \n
\n'), 'unsupported-node-shape at /content/0/content/0 line 2 offset 5') - assert.equal(refused('
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') -}) - -test('refuses a carry element adfToHtml would not write', () => { - const refused = (html: string): string => refusal(htmlToAdf(html)) - const rule = JSON.stringify({ type: 'unknownBlock' }).replaceAll('"', '"') - assert.equal(refused(`
\n`), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused(`
\n`), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused(`
\n`), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused(`
\n`), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('

\n'), 'unmappable-html at /content/0 line 1 offset 0') - assert.equal(refused('

\n'), 'unsupported-node-shape at /content/0/content/0 line 1 offset 0') - assert.equal(refused('
\n'), 'unsupported-node-shape at /content/0 line 1 offset 0') - assert.equal(refused('

\n'), 'unsupported-node-shape at /content/0/content/0 line 1 offset 0') - assert.equal(refused('

a

\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0') -}) - -test('refuses the document\'s carry anywhere but as the whole document', () => { - const documentCarry = carry('div', { type: 'doc', version: 1 }, '') +test('refuses the document\'s carry anywhere but as the whole document, a line feed ending it', () => { + assert.equal(refusal(htmlToAdf(documentCarry)), 'unmappable-html at / line 1 offset 0') + assert.equal(refusal(htmlToAdf(`${documentCarry}\n\n`)), `unmappable-html at / line 2 offset ${documentCarry.length + 1}`) + assert.equal(refusal(htmlToAdf(`${documentCarry.replace('>
', '>x
')}\n`)), 'unmappable-html at / line 1 offset 0') assert.equal(refusal(htmlToAdf(`${documentCarry}\n
\n`)), 'unsupported-node-shape at /content/0 line 1 offset 0') assert.equal(refusal(htmlToAdf(`
\n${documentCarry}\n`)), 'unsupported-node-shape at /content/1 line 2 offset 5') assert.equal(refusal(htmlToAdf(`
\n${documentCarry}\n
\n`)), 'unsupported-node-shape at /content/0/content/0 line 2 offset 13') assert.equal(refusal(htmlToAdf(`${carry('div', { type: 'doc', version: 2 }, '')}\n`)), 'unsupported-node-shape at /content/0 line 1 offset 0') }) +test('refuses a tag of three million attributes, past what one pattern over them all holds, as a value', () => { + assert.equal(refusal(htmlToAdf(`x

\n`)), 'unmappable-html at /content/0 line 1 offset 0') +}) + +test('names what it found where reading stopped, a character no one sees as its escape', () => { + assert.equal(message(htmlToAdf('

ab

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

\\n"') + assert.equal(message(htmlToAdf('

a > b

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

\\n"') + assert.equal(message(htmlToAdf('

a

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

\\n"') + assert.equal(message(htmlToAdf('

a \u00a0

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

\\n"') + assert.equal(message(htmlToAdf('
a
: found "\n
  • a\n')), 'an item\'s line ends at
  • , or at a line feed before the item\'s next block: found "\\n"') +}) + test('reads blocks nested 500 levels, and refuses the next level, a list\'s items one level below it', () => { const quotes = (levels: number): string => `${'
    \n'.repeat(levels)}

    x

    ${'\n
    '.repeat(levels)}\n` - assert.equal(refusal(htmlToAdf(quotes(largestNesting))), 'read') - assert.equal(refusal(htmlToAdf(quotes(largestNesting + 1))).startsWith(`unsupported-nesting-depth at /${'content/0/'.repeat(largestNesting + 1)}content/0 line ${largestNesting + 2}`), true) + 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) const lists = (levels: number): string => `${'
      \n
    • x\n'.repeat(levels)}
      ${'\n
    • \n
    '.repeat(levels)}\n` - assert.equal(refusal(htmlToAdf(lists(largestNesting))), 'read') - assert.equal(refusal(htmlToAdf(lists(largestNesting + 1))).startsWith('unsupported-nesting-depth'), true) + assert.equal(refusal(htmlToAdf(lists(500))), 'read') + assert.equal(refusal(htmlToAdf(lists(501))).startsWith('unsupported-nesting-depth'), true) assert.equal(refusal(htmlToAdf('
    \n'.repeat(1_000_000))).startsWith('unsupported-nesting-depth'), true) }) test('refuses a carried node whose JSON nests past the levels its depth leaves, and an inline carry past 500', () => { let value: unknown = 1 - for (let level = 0; level < largestNesting - 2; level += 1) value = [value] + for (let level = 0; level < 498; level += 1) value = [value] const carried = carry('div', { attrs: { a: value }, type: 'unknownBlock' }, '') assert.equal(refusal(htmlToAdf(`${carried}\n`)), 'read') assert.equal(refusal(htmlToAdf(`
    \n${carried}\n
    \n`)), 'unsupported-nesting-depth at /content/0/content/0 line 2 offset 13') let inline: unknown = { text: 'x', type: 'text' } - for (let level = 0; level < largestNesting; level += 1) inline = { content: [inline], type: 'unknownInline' } + for (let level = 0; level < 500; level += 1) inline = { content: [inline], type: 'unknownInline' } assert.equal(refusal(htmlToAdf(`

    ${carry('span', inline, '')}

    \n`)), 'unsupported-nesting-depth at /content/0/content/0 line 1 offset 0') }) test('reads back what adfToHtml writes for the documents its own tests write', () => { + const bare: AdfDocument = { type: 'doc', version: 1 } const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Run this:')), { attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }], type: 'panel' } - for (const written of [document(panel), document(paragraph(text('a'), text('b'), text('c', { type: 'em' }), text('d'), text('e'))), { type: 'doc' as const, version: 1 }]) { + for (const written of [document(panel), document(paragraph(text('a'), text('b'), text('c', { type: 'em' }), text('d'), text('e'))), bare]) { const html = adfToHtml(written) assert.ok(html.ok) assert.deepEqual(read(htmlToAdf(html.value)), written) diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index 7386b70..56d27f3 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -4,21 +4,22 @@ import type { FirstParagraphForm } from '../first-paragraph.ts' import type { ParseError, Result } from '../../result.ts' import { blockNodes } from '../../adf/block-nodes.ts' import { centeredImage, externalMedia } from '../../adf/plain-image.ts' +import { containerElements, headingElements, headingLevel, readListStart, type ContainerType } from '../block-elements.ts' import { firstParagraphForm } from '../first-paragraph.ts' -import { foundAt, readStartTag, refused, type Place, type ReadNode, type StartTag } from './source.ts' -import { heldNode, readCarry } from './carry-element.ts' +import { foundAt, misread, readStartTag, refused, startTagName, type Place, type ReadNode, type StartTag } from './source.ts' import { largestNesting } from '../../nesting.ts' +import { misspelledAt, unescapedValue } from '../escaping.ts' +import { misspelledText, readInline } from './inline.ts' import { nodeAttrs } from '../../adf/document.ts' import { notAString } from '../../not-a-string.ts' import { positioned, success } from '../../result.ts' import { readAnchorAttributes } from '../anchor-element.ts' +import { readCarry } from './carry-element.ts' import { readCarryAttributes } from '../carry-attributes.ts' import { readDataAttributes } from '../data-attributes.ts' import { readImageAttributes } from '../image-element.ts' -import { readInline } from './inline.ts' import { readLanguageClass } from '../language-class.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' -import { unescapedValue } from '../escaping.ts' import { withinEngineLimits } from '../../length-bound.ts' type BlockReader = (html: string, tag: StartTag, place: Place, open: Open) => Result @@ -30,21 +31,17 @@ type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' // No element name opens both a block and an inline node, so the name alone tells an item's line from its next block. const blockReaders: ReadonlyMap = new Map([ - ['blockquote', readContainer], + [containerElements.blockquote, containerReader('blockquote')], + [containerElements.bulletList, containerReader('bulletList')], + [containerElements.orderedList, containerReader('orderedList')], ['div', readBlockCarry], - ['h1', readHeading], - ['h2', readHeading], - ['h3', readHeading], - ['h4', readHeading], - ['h5', readHeading], - ['h6', readHeading], ['hr', readRule], - ['ol', readContainer], ['p', readParagraph], ['pre', readCodeBlock], - ['ul', readContainer], + ...headingElements.map((name): [string, BlockReader] => [name, readHeading]), ]) const codeCloser = '' +const documentCarryAlone = 'a carry element holding the document stands alone, as the whole document' 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
  • ", @@ -63,15 +60,17 @@ export function htmlToAdf(html: string): Result { function readHtml(html: string): Result { if (typeof html !== 'string') return notAString(html, 'HTML') if (html === '') return success({ content: [], type: 'doc', version: 1 }) - return isDocumentCarry(html) ? success({ type: 'doc', version: 1 }) : readBlocks(html) + return readDocumentCarry(html) ?? readBlocks(html) } -// The carry element of a document holding no content key stands alone, as the whole HTML; the walk refuses it anywhere else. -function isDocumentCarry(html: string): boolean { +// The carry of a document holding no content key stands alone, as the whole HTML; `undefined` where the HTML opens with no such carry. +function readDocumentCarry(html: string): Result | undefined { const tag = readStartTag(html, 0) - if (tag?.name !== 'div' || readCarryAttributes(tag.attributes) !== emptyDocument) return false - const carry = readCarry(html, tag, largestNesting, { index: 0, parent: undefined }) - return carry.ok && carry.value.end === html.length - 1 && html.endsWith('\n') + if (tag?.name !== 'div' || readCarryAttributes(tag.attributes) !== emptyDocument) return undefined + if (!html.startsWith('', 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 + ''.length + if (html.startsWith('\n', end) && isBlockStart(html, end + 1)) return refused('unsupported-node-shape', documentCarryAlone, html, 0, { index: 0, parent: undefined }) + return end === html.length - 1 && html.endsWith('\n') ? success({ type: 'doc', version: 1 }) : partedRefusal(html, end, undefined) } // A stack rather than recursion: only this loop opens and closes containers. @@ -99,16 +98,23 @@ function readBlocks(html: string): Result { open.content.push(closed) } if (open === root && index === html.length - 1 && html.endsWith('\n')) return success({ content: root.content, type: 'doc', version: 1 }) - if (!html.startsWith('\n', index)) return refused('unmappable-html', `a line feed parts each block from the next, and ends the HTML: ${foundAt(html, index)}`, html, index, open.place) + if (!html.startsWith('\n', index)) return partedRefusal(html, index, open.place) index += 1 } } +// Where a block ends short of the line feed parting it from the next, or a second line feed follows it. +function partedRefusal(html: string, end: number, place: Place | undefined): Result { + const at = html.startsWith('\n', end) ? end + 1 : end + return refused('unmappable-html', `a line feed parts each block from the next, and ends the HTML: ${foundAt(html, at)}`, html, at, place) +} + function readBlock(html: string, index: number, place: Place, open: Open): Result { const tag = readStartTag(html, index) - if (tag === undefined) return refused('unmappable-html', `a block element of the dialect stands here: ${foundAt(html, index)}`, html, index, place) - const reader = blockReaders.get(tag.name) - return reader === undefined ? refused('unmappable-html', `<${tag.name}> is no block element of the dialect`, html, index, place) : reader(html, tag, place, open) + const reader = tag === undefined ? undefined : blockReaders.get(tag.name) + if (tag !== undefined && reader !== undefined) return reader(html, tag, place, open) + const stop = misread(html, index, 'a block element of the dialect stands here', 'no block element of the dialect') + return refused('unmappable-html', stop.message, html, stop.at, place) } function asStep(read: ReadNode): Result { @@ -116,7 +122,7 @@ function asStep(read: ReadNode): Result { } function readHeading(html: string, tag: StartTag, place: Place): Result { - return asStep(readInlineBlock(html, tag, 'heading', blockNodes.heading.attributes, { level: Number(tag.name.slice(1)) }, place)) + return asStep(readInlineBlock(html, tag, 'heading', blockNodes.heading.attributes, { level: headingLevel(tag.name) }, place)) } function readParagraph(html: string, tag: StartTag, place: Place, open: Open): Result { @@ -140,7 +146,10 @@ function readInlineBlock(html: string, tag: StartTag, type: string, vocabulary: if (!inline.ok) return inline const { end, nodes } = inline.value const closer = `` - if (!html.startsWith(closer, end)) return refused('unmappable-html', `${closer} ends the ${type}, which holds inline elements alone: ${foundAt(html, end)}`, html, end, place) + if (!html.startsWith(closer, end)) { + const stop = misread(html, end, `${closer} ends the ${type}, and its text spells < as <`, 'no inline element of the dialect') + return refused('unmappable-html', stop.message, html, stop.at, place) + } return success({ end: end + closer.length, node: built(type, { ...own, ...data.value }, nodes) }) } @@ -148,7 +157,7 @@ function readInlineBlock(html: string, tag: StartTag, type: string, vocabulary: function tryImage(html: string, index: number, place: Place): ReadNode | undefined { const opener = readStartTag(html, index) const anchor = opener?.name === 'a' ? opener : undefined - const image = readStartTag(html, anchor?.end ?? index) + const image = anchor === undefined ? opener : readStartTag(html, anchor.end) if (image?.name !== 'img') return undefined const node = imageNode(image, anchor) const closer = anchor === undefined ? '

    ' : '

    ' @@ -170,21 +179,20 @@ 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']) if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place) - const code = readCode(html, tag.end) - if (code === undefined) return refused('unmappable-html', 'a code block is
    , its text escaped, then 
    ', html, tag.end, place) - const { end, language, text } = code - return success({ end, kind: 'node', node: built('codeBlock', language === undefined ? data.value : { ...data.value, language }, text === '' ? [] : [{ text, type: 'text' }]) }) -} - -// The `` at `index`, through the `` after it; `undefined` where `adfToHtml` writes no such elements. -function readCode(html: string, index: number): { end: number; language: string | undefined; text: string } | undefined { - const code = readStartTag(html, index) + const code = readStartTag(html, tag.end) const named = code?.name === 'code' ? readLanguageClass(code.attributes) : undefined - if (code === undefined || named === undefined) return undefined + if (code === undefined || named === undefined) { + const stop = misread(html, tag.end, '
     holds , or  naming the language', 'neither')
    +    return refused('unmappable-html', stop.message, html, stop.at, place)
    +  }
       const next = html.indexOf('<', code.end)
       const end = next < 0 ? html.length : next
    -  const text = unescapedValue(html.slice(code.end, end))
    -  return text === undefined || !html.startsWith(codeCloser, end) ? undefined : { end: end + codeCloser.length, language: named.language, text }
    +  const spelled = html.slice(code.end, end)
    +  const text = unescapedValue(spelled)
    +  if (text === undefined) return misspelledText(html, code.end + misspelledAt(spelled), place)
    +  if (!html.startsWith(codeCloser, end)) return refused('unmappable-html', `a code block's text ends at ${codeCloser}: ${foundAt(html, end)}`, html, end, place)
    +  const { language } = named
    +  return success({ end: end + codeCloser.length, kind: 'node', node: built('codeBlock', language === undefined ? data.value : { ...data.value, language }, text === '' ? [] : [{ text, type: 'text' }]) })
     }
     
     function readRule(html: string, tag: StartTag, place: Place): Result {
    @@ -193,20 +201,20 @@ function readRule(html: string, tag: StartTag, place: Place): Result {
    -  const carry = readCarry(html, tag, largestNesting - open.depth, place)
    -  if (!carry.ok) return carry
    -  if (carry.value.json === emptyDocument) return refused('unsupported-node-shape', 'a carry element holding the document stands alone, as the whole document', html, tag.start, place)
    -  const node = heldNode(carry.value.held, html, tag.start, place)
    -  return node.ok ? success({ end: carry.value.end, kind: 'node', node: node.value }) : node
    +  if (readCarryAttributes(tag.attributes) === emptyDocument) return refused('unsupported-node-shape', documentCarryAlone, html, tag.start, place)
    +  return asStep(readCarry(html, tag, 'div', largestNesting - open.depth, place))
    +}
    +
    +function containerReader(type: ContainerType): BlockReader {
    +  return (html, tag, place, open) => readContainer(html, tag, type, place, open)
     }
     
     // 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, place: Place, open: Open): Result {
    -  const type = tag.name === 'blockquote' ? 'blockquote' : tag.name === 'ul' ? 'bulletList' : 'orderedList'
    +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 order = start === undefined ? undefined : Number(start)
    -  if (order !== undefined && (!Number.isSafeInteger(order) || String(order) !== start)) {
    +  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 data = readDataAttributes(start === undefined ? tag.attributes : rest, type, blockNodes[type].attributes, type === 'orderedList' ? ['order'] : [])
    @@ -220,7 +228,10 @@ function readContainer(html: string, tag: StartTag, place: Place, open: Open): R
     
     function readItem(html: string, index: number, place: Place, depth: number): Result {
       const tag = readStartTag(html, index)
    -  if (tag?.name !== 'li') return refused('unmappable-html', `a list holds 
  • items, one to a line: ${foundAt(html, index)}`, html, index, place) + if (tag?.name !== 'li') { + 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, []) if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place) const item = built('listItem', data.value, []) @@ -253,19 +264,23 @@ function readLineParagraph(html: string, index: number, place: Place): Result<{ const inline = readInline(html, index, place) if (!inline.ok) return inline const { end, nodes } = inline.value + const parted = partedLine(html, end, nodes) + return success(parted === undefined ? { end, form: 'bare', node: { content: nodes, type: 'paragraph' } } : { end: end - 1, form: 'bare', node: { content: parted, type: 'paragraph' } }) +} + +// Only text ends in a line feed, so the text read last holds the one parting the line from the next block; `undefined` where no block follows. +function partedLine(html: string, end: number, nodes: AdfNode[]): AdfNode[] | undefined { const last = nodes[nodes.length - 1] - if (!isBlockStart(html, end) || !html.endsWith('\n', end) || last?.text === undefined) return success({ end, form: 'bare', node: { content: nodes, type: 'paragraph' } }) - // Only text ends in a line feed, so the text read last holds the one parting the line from the next block. + if (!isBlockStart(html, end) || !html.endsWith('\n', end) || last?.text === undefined) return undefined const text = last.text.slice(0, -1) - return success({ end: end - 1, form: 'bare', node: { content: [...nodes.slice(0, -1), ...(text === '' ? [] : [{ ...last, text }])], type: 'paragraph' } }) + return [...nodes.slice(0, -1), ...(text === '' ? [] : [{ ...last, text }])] } function isBlockStart(html: string, index: number): boolean { - const tag = readStartTag(html, index) - return tag !== undefined && blockReaders.has(tag.name) + return blockReaders.has(startTagName(html, index) ?? '') } -// A node holding `attrs` and `content` only where it holds any, as `adfToHtml` reads an element holding none. +// The node an element holding no attribute or content reads as: `adfToHtml` writes a node holding an empty `attrs` or `content` as a carry. function built(type: string, attrs: AdfAttributes, content: AdfNode[]): AdfNode { return { ...(Object.keys(attrs).length === 0 ? {} : { attrs }), ...(content.length === 0 ? {} : { content }), type } } diff --git a/src/html/parse/inline.ts b/src/html/parse/inline.ts index d77081b..7829148 100644 --- a/src/html/parse/inline.ts +++ b/src/html/parse/inline.ts @@ -1,13 +1,13 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { ParseError, Result } from '../../result.ts' -import { heldNode, readCarry } from './carry-element.ts' +import { foundAt, readStartTag, refused, type Place, type ReadNode, type StartTag } from './source.ts' import { largestNesting } from '../../nesting.ts' import { linkMarkFrom } from '../../adf/mark-attributes.ts' import { markOfElement } from '../mark-elements.ts' +import { misspelledAt, unescapedValue } from '../escaping.ts' import { readAnchorAttributes } from '../anchor-element.ts' -import { readStartTag, refused, type Place, type ReadNode, type StartTag } from './source.ts' +import { readCarry } from './carry-element.ts' import { success } from '../../result.ts' -import { unescapedValue } from '../escaping.ts' // The inline nodes from `index` up to the first tag opening none, which the caller reads. export function readInline(html: string, index: number, place: Place): Result<{ end: number; nodes: AdfNode[] }, ParseError> { @@ -28,11 +28,8 @@ export function readInline(html: string, index: number, place: Place): Result<{ function readTagged(html: string, index: number, place: Place): ReadNode | undefined { const tag = readStartTag(html, index) if (tag?.name === 'br' && tag.attributes.length === 0) return success({ end: tag.end, node: { type: 'hardBreak' } }) - if (tag?.name !== 'span') return tag !== undefined && markOf(tag) !== undefined ? readMarked(html, index, place) : undefined - const carry = readCarry(html, tag, largestNesting, place) - if (!carry.ok) return carry - const node = heldNode(carry.value.held, html, index, place) - return node.ok ? success({ end: carry.value.end, node: node.value }) : node + if (tag?.name === 'span') return readCarry(html, tag, 'span', largestNesting, place) + return tag !== undefined && markOf(tag) !== undefined ? readMarked(html, index, place) : undefined } // Each text node writes its own elements, one inside the other around its text, the first mark outermost. @@ -49,12 +46,12 @@ function readMarked(html: string, index: number, place: Place): ReadNode { closers.unshift(``) at = tag.end } - if (at === html.length || html.startsWith('<', at)) return refused('unmappable-html', 'a mark element holds one text, or one mark element holding it', html, at, place) + if (at === html.length || html.startsWith('<', at)) return refused('unmappable-html', `a mark element holds one text, or one mark element holding it: ${foundAt(html, at)}`, html, at, place) const text = readText(html, at, marks, place) if (!text.ok) return text at = text.value.end for (const closer of closers) { - if (!html.startsWith(closer, at)) return refused('unmappable-html', `marked text ends at ${closer}: found something else`, html, at, place) + if (!html.startsWith(closer, at)) return refused('unmappable-html', `marked text ends at ${closer}: ${foundAt(html, at)}`, html, at, place) at += closer.length } return success({ end: at, node: text.value.node }) @@ -64,10 +61,14 @@ function readText(html: string, index: number, marks: AdfMark[], place: Place): const next = html.indexOf('<', index) const end = next < 0 ? html.length : next const text = unescapedValue(html.slice(index, end)) - if (text === undefined) return refused('unmappable-html', 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold', html, index, place) + if (text === undefined) return misspelledText(html, index + misspelledAt(html.slice(index, end)), place) return success({ end, node: marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' } }) } +export function misspelledText(html: string, index: number, place: Place): Result { + return refused('unmappable-html', `text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: ${foundAt(html, index)}`, html, index, place) +} + function markOf(tag: StartTag): AdfMark | undefined { if (tag.name === 'a') { const link = readAnchorAttributes(tag.attributes) diff --git a/src/html/parse/source.ts b/src/html/parse/source.ts index c500459..5a8f8bf 100644 --- a/src/html/parse/source.ts +++ b/src/html/parse/source.ts @@ -1,6 +1,6 @@ import type { AdfNode } from '../../adf/document.ts' import type { ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts' -import { unescapedValue } from '../escaping.ts' +import { misspelledAt, unescapedValue } from '../escaping.ts' // A node's place among its parent's content, the document's own content where `parent` is undefined; a path is built only for a refusal. export type Place = { index: number; parent: Place | undefined } @@ -10,29 +10,39 @@ export type ReadNode = Result<{ end: number; node: AdfNode }, ParseError> // A start tag as `adfToHtml` writes it, its attributes' values unescaped. export type StartTag = { attributes: (readonly [string, string])[]; end: number; name: string; start: number } -const attribute = / ([a-z][a-z0-9-]*)="([^"]*)"/g -const lineEnding = /\r\n?|\n/g -const startTag = /<([a-z][a-z0-9]*)((?: [a-z][a-z0-9-]*="[^"]*")*)>/y +// A tag whose name reads, its attributes or its `>` not: `stopped` is where reading them stopped. +type CutTag = { name: string; stopped: number } + +const attribute = / ([a-z][a-z0-9-]*)="([^"]*)"/y +const invisible = /(?! )[\p{Cf}\p{Z}\u007f-\u009f]/gu +const tagName = /<([a-z][a-z0-9]*)/y // `undefined` where no start tag stands at `index`, or one of its values is not escaped as the dialect escapes it. export function readStartTag(html: string, index: number): StartTag | undefined { - startTag.lastIndex = index - const match = startTag.exec(html) - if (match === null) return undefined - const attributes: (readonly [string, string])[] = [] - for (const [, name = '', spelled = ''] of (match[2] ?? '').matchAll(attribute)) { - const value = unescapedValue(spelled) - if (value === undefined) return undefined - attributes.push([name, value]) - } - return { attributes, end: index + match[0].length, name: match[1] ?? '', start: index } + const tag = scanStartTag(html, index) + return tag !== undefined && 'end' in tag ? tag : undefined } -// What stands at `index`, for a refusal's message. +// The name of the start tag at `index`, read whatever its attributes hold. +export function startTagName(html: string, index: number): string | undefined { + tagName.lastIndex = index + return tagName.exec(html)?.[1] +} + +// Why reading stopped at `index` short of `expected`, and where: a tag the caller takes none of is `unexpected`. +export function misread(html: string, index: number, expected: string, unexpected: string): { at: number; message: string } { + 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)}` } +} + +// What stands at `index`, for a refusal's message, a character no one sees spelled as its escape. export function foundAt(html: string, index: number): string { if (index >= html.length) return 'found the end of the HTML' const found = html.slice(index, index + 24) - return `found ${JSON.stringify(found)}${index + found.length < html.length ? '…' : ''}` + const spelled = JSON.stringify(found).replace(invisible, (character) => Array.from({ length: character.length }, (_, unit) => `\\u${character.charCodeAt(unit).toString(16).padStart(4, '0')}`).join('')) + return `found ${spelled}${index + found.length < html.length ? '…' : ''}` } // At the start of the line `index` stands on. @@ -40,13 +50,31 @@ export function refused(code: ConvertErrorCode, message: string, html: string return { error: { code, message, path: pathOf(place), position: lineStart(html, index) }, ok: false } } -// A line ends at a line feed, a carriage return or both, as an HTML parser reads it. +// One attribute a match, since a pattern repeating over them all overflows V8's regular expression stack on a long tag. +function scanStartTag(html: string, index: number): CutTag | StartTag | undefined { + const name = startTagName(html, index) + if (name === undefined) return undefined + const attributes: (readonly [string, string])[] = [] + let at = index + 1 + name.length + for (;;) { + attribute.lastIndex = at + const match = attribute.exec(html) + if (match === null) return html.startsWith('>', at) ? { attributes, end: at + 1, name, start: index } : { name, stopped: at } + const [whole, key = '', spelled = ''] = match + const value = unescapedValue(spelled) + if (value === undefined) return { name, stopped: at + whole.length - 1 - spelled.length + misspelledAt(spelled) } + attributes.push([key, value]) + at += whole.length + } +} + +// Only a line feed ends a line: the reader refuses a carriage return wherever it stands. function lineStart(html: string, index: number): SourcePosition { let line = 1 let offset = 0 - for (const ending of html.slice(0, index).matchAll(lineEnding)) { + for (let next = html.indexOf('\n'); next >= 0 && next < index; next = html.indexOf('\n', next + 1)) { line += 1 - offset = ending.index + ending[0].length + offset = next + 1 } return { line, offset } } diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 9cb0330..7b4fb71 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -61,11 +61,11 @@ function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, ty const typed = type === undefined || type === '' ? { value } : typedValue(value, type) if (typed.fault !== undefined) return typed const nested = carriedNesting(typed.value, levels) - if (nested !== undefined) return { fault: nested } + if (nested.fault !== undefined) return nested if (!spellsCanonicalJson(value, spelling, raw)) { return { fault: unsupportedNodeShape(`${carryHolder} spells its node's JSON canonically: ${spelling === 'compact' ? 'compact' : 'two-space indent'}, keys sorted; ${wayOut}`) } } - const node = carriedNode(typed.value, carryHolder) + const node = carriedNode(nested.value, carryHolder) return node.fault === undefined ? node : { fault: { ...node.fault, message: `${node.fault.message}; ${wayOut}` } } }