diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 8b6b825..59602c5 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -14,12 +14,12 @@ import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to import { toEditorNormal } from '../markdown/portable/editor-normal.ts' const gateRuns = 1600 -const htmlPieces = fc.constantFrom('<', '>', '&', '"', '\n', '\r', '\0', '&', '"', ''', '

', '

', '
  • ', '
  • ', '', '
    ', '', '', '', '', '', ' data-local-id="x"', ' start="2"', '
    ', '', 'é🎉') +const htmlPieces = fc.constantFrom('<', '>', '&', '"', '\n', '\r', '\0', '&', '"', ''', '

    ', '

    ', '
  • ', '
  • ', '', '
    ', '', '', '
    ', '', '
    ', ' data-local-id="01a17eec-5ed2-75cf-904d-f4af6746ac07"', ' start="2"', '
    ', '', 'é🎉') // What HTML text cannot hold (WHATWG HTML §13.1.3), and the line feed a carry element shows as a line break. const unshown = /\n|(?![\t\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/gu const depthIdentifier = fc.createDepthIdentifier() -const elementAttributes = [' alt=""', ' alt="y"', ' class="language-x"', ' data-local-id="x"', ' start="2"', ' title="t"'] +const elementAttributes = [' alt=""', ' alt="y"', ' class="language-x"', ' data-local-id="01a17e97-ca82-785e-9333-6e8199f3b098"', ' start="2"', ' title="t"'] const elementInlinePieces = ['\n', '&', '

    ', '
    a', '
    ', 'c', 'a', '', '

    ', 'b', 'a'] const elementLines = ['

    \n

    a

    \n
    \n', '
    \n', '
  • \n

    \n
  • \n', '
  • \n

    a

    \n
  • \n', '
  • a

  • \n', '
  • a
  • \n', '

    \n', '

    \n', '

    \n', '

    a

    \n', '
    a
    \n', '\n'] const elementText = fc.string({ maxLength: 4, minLength: 1, unit: fc.constantFrom('\n', ' ', '"', '&', '<', 'a', 'é') }) @@ -27,7 +27,7 @@ const elementInline = fc.oneof( { arbitrary: fc.record({ marks: fc.subarray([{ type: 'strong' }, { type: 'em' }, { type: 'code' }, { attrs: { href: 'x' }, type: 'link' }]), text: elementText }).map(({ marks, text }): AdfNode => (marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' })), weight: 4 }, { arbitrary: fc.constant({ type: 'hardBreak' }), weight: 1 }, ) -const localId = fc.record({ localId: fc.constantFrom('a1', 'b2') }, { noNullPrototype: true, requiredKeys: [] }) +const localId = fc.record({ localId: fc.constantFrom('01a17c28-f53a-7ae4-90e8-e3a686cc94e8', '01a17fb9-4022-7162-a34a-627985fb16cc') }, { noNullPrototype: true, requiredKeys: [] }) // The nodes `adfToHtml` writes as their own elements, so a document holding these alone writes no carry element, short of adjacent unmarked text. const { elementBlock } = fc.letrec<{ elementBlock: AdfNode }>((tie) => { const blocks = fc.array(tie('elementBlock'), { depthIdentifier, maxLength: 3 }) diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index 1ab2556..1907b6f 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 { carryContent } from '../carry-content.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' @@ -28,7 +29,7 @@ export function adfToHtml(document: AdfDocument): Result { function writeHtml(document: AdfDocument): Result { const content = writableContent(document) if (!content.ok) return content - if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`) + if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), carryContent(document, 'div'))}\n`) const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0), '\n'].reverse() let html = '' for (let next = pending.pop(); next !== undefined; next = pending.pop()) { diff --git a/src/html/escaping.ts b/src/html/escaping.ts index 51b9c09..1dd6961 100644 --- a/src/html/escaping.ts +++ b/src/html/escaping.ts @@ -4,11 +4,14 @@ const entities: ReadonlyMap = new Map([ ['<', '<'], ['"', '"'], ]) +const entity = new RegExp([...entities.keys()].join('|'), 'g') +const escapable = new RegExp(`[${[...entities.values()].join('')}]`, 'g') +const escapes: ReadonlyMap = new Map([...entities].map(([spelled, character]) => [character, spelled])) // 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') +// A character `escapeHtml` escapes, other than the `&` opening an entity, or one HTML cannot hold. +const misspelling = new RegExp(`(?!${entity.source})${escapable.source}|${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 { @@ -37,7 +40,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 { - return misspelledAt(html) < 0 ? html.replace(/&(?:amp|gt|lt|quot);/g, (entity) => entities.get(entity) ?? entity) : undefined + return misspelledAt(html) < 0 ? html.replace(entity, (spelled) => entities.get(spelled) ?? spelled) : undefined } // `undefined` where HTML cannot hold the value, so its node rides the carry element. @@ -46,7 +49,7 @@ export function writableValue(value: string): string | undefined { } function escapeHtml(text: string): string { - return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') + return text.replace(escapable, (character) => escapes.get(character) ?? character) } // An astral noncharacter spells as its two surrogates' escapes. diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts index 453f0f1..4e50335 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -21,6 +21,10 @@ function paragraph(...content: AdfNode[]): AdfNode { return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' } } +function heading(level: number, ...content: AdfNode[]): AdfNode { + return content.length === 0 ? { attrs: { level }, type: 'heading' } : { attrs: { level }, content, type: 'heading' } +} + function text(value: string, ...marks: AdfMark[]): AdfNode { return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' } } @@ -42,6 +46,8 @@ function message(result: Result): string { } const documentCarry = carry('div', { type: 'doc', version: 1 }, '') +const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' } +const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' } const rule = JSON.stringify({ type: 'unknownBlock' }).replaceAll('"', '"') // The carry element of `node`, showing `shown`. @@ -49,13 +55,18 @@ function carry(tag: 'div' | 'span', node: unknown, shown: string): string { return `<${tag} class="adf-carry" data-adf="${JSON.stringify(node).replaceAll('"', '"')}">${shown}` } +function nested(value: unknown, levels: number, wrap: (inner: unknown) => unknown): unknown { + let wrapped = value + for (let level = 0; level < levels; level += 1) wrapped = wrap(wrapped) + return wrapped +} + 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(`${documentCarry}\n`)), { type: 'doc', version: 1 }) }) test('reads paragraphs, headings and rules, a line feed after each', () => { - const heading = (level: number, ...content: AdfNode[]): AdfNode => (content.length === 0 ? { attrs: { level }, type: 'heading' } : { attrs: { level }, content, type: 'heading' }) assert.deepEqual(read(htmlToAdf('

    Level 1

    \n

    Read the manual.

    \n
    \n
    Level 6
    \n')), document(heading(1, text('Level 1')), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6, text('Level 6')))) assert.deepEqual(read(htmlToAdf('

    \n

    \n')), document(paragraph(), heading(2))) }) @@ -123,21 +134,10 @@ test('reads an image standing alone in a paragraph, a link around it where one m }) 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: 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')))) @@ -185,6 +185,21 @@ const refusals: Readonly> ['

    \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 an edit to what a carry element shows': [ + [`

    a

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

    Check the collation.

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

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

    \n`, 'unmappable-html at /content/0/content/1 line 1 offset 0'], + ], + 'refuses the document\'s carry anywhere but as the whole document, a line feed ending it': [ + [documentCarry, 'unmappable-html at / line 1 offset 0'], + [`${documentCarry}\n\n`, `unmappable-html at / line 2 offset ${documentCarry.length + 1}`], + [`${documentCarry}X`, 'unmappable-html at / line 1 offset 0'], + [`${documentCarry}X
    \n`, 'unmappable-html at / line 1 offset 0'], + [`${documentCarry.replace('>
    ', '>x')}\n`, 'unmappable-html at / line 1 offset 0'], + [`${documentCarry}\n
    \n`, 'unsupported-node-shape at /content/0 line 1 offset 0'], + [`
    \n${documentCarry}\n`, 'unsupported-node-shape at /content/1 line 2 offset 5'], + [`
    \n${documentCarry}\n
    \n`, 'unsupported-node-shape at /content/0/content/0 line 2 offset 13'], + [`${carry('div', { type: 'doc', version: 2 }, '')}\n`, 'unsupported-node-shape at /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'], @@ -245,35 +260,30 @@ 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, {}]) { +const notStrings: Readonly> = { null: null, number: 5, object: {}, undefined: undefined } + +for (const [kind, value] of Object.entries(notStrings)) { + test(`refuses a value that is not a string as not-a-string: ${kind}`, () => { // @ts-expect-error -- a caller without types can pass anything. assert.equal(refusal(htmlToAdf(value)), 'not-a-string at / line 1 offset 0') - } -}) - -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"') -}) +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 &, <, > and " as &, <, > and ", 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 \u00a0

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

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

    a

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

    Check the collation.

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

    assert.equal(message(htmlToAdf(html)), expected)) 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` @@ -286,22 +296,24 @@ test('reads blocks nested 500 levels, and refuses the next level, a list\'s item }) 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 < 498; level += 1) value = [value] - const carried = carry('div', { attrs: { a: value }, type: 'unknownBlock' }, '') + const carried = carry('div', { attrs: { a: nested(1, 498, (inner) => [inner]) }, 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 < 500; level += 1) inline = { content: [inline], type: 'unknownInline' } + const inline = nested({ text: 'x', type: 'text' }, 500, (inner) => ({ content: [inner], 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'))), bare]) { - const html = adfToHtml(written) +// The documents adfToHtml's own tests write, by what each holds. +const written: Readonly> = { + 'a panel holding a code block': document({ attrs: { panelType: 'info' }, content: [paragraph(text('Run this:')), { attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }], type: 'panel' }), + 'adjacent unmarked text': document(paragraph(text('a'), text('b'), text('c', { type: 'em' }), text('d'), text('e'))), + 'no content key': { type: 'doc', version: 1 }, +} + +for (const [holding, adf] of Object.entries(written)) { + test(`reads back what adfToHtml writes for a document holding ${holding}`, () => { + const html = adfToHtml(adf) assert.ok(html.ok) - assert.deepEqual(read(htmlToAdf(html.value)), written) - } -}) + assert.deepEqual(read(htmlToAdf(html.value)), adf) + }) +} diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index 56d27f3..803654b 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -3,6 +3,7 @@ import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts' import type { FirstParagraphForm } from '../first-paragraph.ts' import type { ParseError, Result } from '../../result.ts' import { blockNodes } from '../../adf/block-nodes.ts' +import { carryContent } from '../carry-content.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' @@ -42,6 +43,7 @@ const blockReaders: ReadonlyMap = new Map([ ]) const codeCloser = '' const documentCarryAlone = 'a carry element holding the document stands alone, as the whole document' +const documentCarryEnd = `${carryContent({ type: 'doc' }, 'div')}` const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact') const formRefusals: Readonly> = { bare: "an item's first paragraph holding content and no attribute stands bare on the item's line:
  • Bolt
  • ", @@ -67,8 +69,8 @@ function readHtml(html: string): Result { function readDocumentCarry(html: string): Result | undefined { const tag = readStartTag(html, 0) 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(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 }) return end === html.length - 1 && html.endsWith('\n') ? success({ type: 'doc', version: 1 }) : partedRefusal(html, end, undefined) }