From 0cb80fd4fc16f626006e533cae47c6e94cee691a Mon Sep 17 00:00:00 2001
From: Lilleman auf Larv `, a code block's as ` ${html} ${html} Check the collation. Ask ${carry('span', mention, '@Mikael')} Edited away Ask ${carry('span', mention, '')} Check the collation. Ask ${carry('span', mention, '@Mikael')} ${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')} a Check the collation. Ask ${carry('span', mention, '')} x a${carry('span', text('b'), 'b')} x Bolt Bolt a a a a b \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 a a x x x a a b a ab a it's a > b a\rb a a \n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
+ [' ab a a &bogus; b 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 x a a b a ab a it's a > b a\rb ${inline} 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(' Bolt Bolt a a a a b ${inner} a a \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 x ab` 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 `
`; 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 ? `
`)).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 ? '${html}
' : 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 | 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
`))
- 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${html}
').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}${tag}>`
}
-
-// 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\n
\n`)), document({ content: [item(paragraph(text('x')))], type: 'bulletList' }))
})
+// Each group's cases, the HTML and its refusal.
+const refusals: Readonly\n
\n', 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5'],
+ ['\n
\n', 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'],
+ [`\n
\n`, 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'],
+ ['\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'],
+ [`\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'],
+ ['\n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
+ ['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['\n
\n', 'unmappable-html at /content/0 line 2 offset 13'],
+ ['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['
\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'],
+ ['xx
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
+ ['a &bogus; bx
\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'],
+ ['\n
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
+ ['
\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'],
+ ['x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
+ ['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
+ ['\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': [
+ ['
b
\nd\n', 'unmappable-html at /content/2 line 4 offset 37'],
+ ],
+ 'refuses an image adfToHtml would not write': [
+ ['b\ncb
b'), 'unmappable-html at /content/0/content/1 line 1 offset 0')
- assert.equal(refusal(htmlToAdf('
\n
\n'), 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5')
- assert.equal(refused('\n
\n'), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10')
- assert.equal(refused(`\n
\n`), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10')
- assert.equal(refused('\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(`\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('\n
\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
- assert.equal(refused('
\n'), 'unmappable-html at /content/0 line 1 offset 0')
- assert.equal(refused('\n
\n'), 'unmappable-html at /content/0 line 2 offset 13')
- assert.equal(refused('\n
\n`), 'unmappable-html at /content/0 line 1 offset 0', start)
- assert.equal(refused('
\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('xx
\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 &bogus; b
{
- const refused = (inner: string): string => refusal(htmlToAdf(`x'), 'unmappable-html at /content/0 line 1 offset 0')
- assert.equal(refused('
'), 'unmappable-html at /content/0 line 1 offset 0')
- 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('
'), 'unmappable-html at /content/0 line 1 offset 0')
- assert.equal(refusal(htmlToAdf('
\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('\n
\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('x
\n`), 'unsupported-node-shape at /content/0 line 1 offset 0', wrap)
- assert.equal(refused('x
\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
\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`)), '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(`
a > b
\n')), 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found "> b\\n"') + assert.equal(message(htmlToAdf('\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\n'.repeat(levels)}'.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 => `${'x
${'\n
\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 = `${tag.name}>` - 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 ', 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 `, its text escaped, then` 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, ornaming 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(`${tag.name}>`) 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}` } } }