mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 19:58:28 +01:00
Answer principles round 1: 1 🔴, 5 🟡 (0 misread); the document carry's line feeds checked, element spellings shared with the writer, carried JSON bounded by type, an edit to a carry refused, one test per case, a start tag read one attribute at a time
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<BoundedJson> {
|
||||
return overNested(held, levels) ? { fault: nestedPast(levels) } : { value: { bounded: held } }
|
||||
}
|
||||
|
||||
export function carriedNode(held: JsonValue, holder: string): Read<AdfNode> {
|
||||
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<AdfNode> {
|
||||
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 {
|
||||
|
||||
@@ -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<Record<ContainerType, string>> = { 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 `<h1>` to `<h6>`, 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
|
||||
}
|
||||
@@ -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 `<div>`, each block's content as a `<p>`, a code block's as `<pre><code>`; in a `<span>`, the blocks parted by `<br>`.
|
||||
export function carryContent(node: AdfNode, tag: 'div' | 'span'): string {
|
||||
if (tag === 'span') return shownBlocks(node, '<br>').map(({ html }) => html).join('<br>')
|
||||
return shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `<p>${html}</p>` : `<pre><code${languageClass(nodeAttrs(code)['language']) ?? ''}>${html}</code></pre>`)).join('')
|
||||
}
|
||||
|
||||
// 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 ? '<br>' : 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 '<br>'
|
||||
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(`<em>(${shownValue(name, lineFeed)})</em>`, link)
|
||||
}
|
||||
|
||||
// The shown HTML inside an `<a>` 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}</a>`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string> {
|
||||
return withinEngineLimits(() => writeHtml(document))
|
||||
}
|
||||
@@ -66,7 +65,7 @@ function writeBlock(block: Block): Result<Pending[]> {
|
||||
|
||||
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<s
|
||||
|
||||
function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result<string> | 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)
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
const json = carriedJson(node, node, 'compact', path, largestNesting - depth)
|
||||
if (!json.ok) return json
|
||||
const shown = shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `<p>${html}</p>` : `<pre><code${languageClass(nodeAttrs(code)['language']) ?? ''}>${html}</code></pre>`))
|
||||
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<string> {
|
||||
const json = carriedJson(node, node, 'compact', path, largestNesting)
|
||||
return json.ok ? success(carryElement('span', json.value.json, shownBlocks(node, '<br>').map(({ html }) => html).join('<br>'))) : 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 ? '<br>' : 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 '<br>'
|
||||
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(`<em>(${shownValue(name, lineFeed)})</em>`, link)
|
||||
}
|
||||
|
||||
// The shown HTML inside an `<a>` 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}</a>`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const entities: ReadonlyMap<string, string> = 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.
|
||||
|
||||
@@ -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 = `</${tag.name}>`
|
||||
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)}</${element}>`
|
||||
if (html.startsWith(shown, tag.end)) return success({ end: tag.end + shown.length, node: node.value })
|
||||
let at = tag.end
|
||||
while (html[at] === shown[at - tag.end]) at += 1
|
||||
return refused('unmappable-html', `a carry element shows its node's text as adfToHtml writes it, then ends at </${element}>; an edit to it is no edit to the node: ${foundAt(html, at)}`, html, at, place)
|
||||
}
|
||||
|
||||
// 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<JsonValue> {
|
||||
function heldNode(json: string, levels: number): Read<AdfNode> {
|
||||
const held = carriedValue(json, holder, { code: 'unmappable-html', message: `${holder} holds invalid JSON` })
|
||||
if (held.fault !== undefined) return held
|
||||
const nested = carriedNesting(held.value, levels)
|
||||
if (nested !== 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<AdfNode, ParseError> {
|
||||
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)
|
||||
}
|
||||
|
||||
+147
-114
@@ -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<AdfDocument, ParseError>): 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<AdfDocument, ParseError>): 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}</${tag}>`
|
||||
@@ -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('<p><a href="https://example.com/" title="Home"><img src="https://example.com/moon.jpg"></a></p>\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, '<p>Check the collation.</p>')}\n<p>Ask ${carry('span', mention, '@Mikael')}</p>\n`)), expected)
|
||||
assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '<p>Edited <b>away</b> </p>')}\n<p>Ask ${carry('span', mention, '')}</p>\n`)), expected)
|
||||
const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' }
|
||||
assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '<p>Check the collation.</p>')}\n<p>Ask ${carry('span', mention, '@Mikael')}</p>\n`)), document(panel, paragraph(text('Ask '), mention)))
|
||||
assert.deepEqual(read(htmlToAdf(`<p>${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')}</p>\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(`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`)
|
||||
assert.equal(refusal(edited), 'unmappable-html at /content/1 line 2 offset 9')
|
||||
assert.match(message(edited), /: found "<b>collation<\/b>\.<\/p><\/d"…$/)
|
||||
const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' }
|
||||
assert.equal(refusal(htmlToAdf(`<p>Ask ${carry('span', mention, '')}</p>\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')), '<p>x</p>')}\n`)), document(paragraph(text('x'))))
|
||||
assert.deepEqual(read(htmlToAdf(`<p>a${carry('span', text('b'), 'b')}</p>\n`)), document(paragraph(text('a'), text('b'))))
|
||||
assert.deepEqual(read(htmlToAdf(`<ul>\n<li>\n${carry('div', paragraph(text('x')), '<p>x</p>')}\n</li>\n</ul>\n`)), document({ content: [item(paragraph(text('x')))], type: 'bulletList' }))
|
||||
})
|
||||
|
||||
// Each group's cases, the HTML and its refusal.
|
||||
const refusals: Readonly<Record<string, readonly (readonly [string, string])[]>> = {
|
||||
'refuses block shapes adfToHtml would not write': [
|
||||
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5'],
|
||||
['<ul>\n<li>\n<p>Bolt</p>\n</li>\n</ul>\n', 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'],
|
||||
[`<ul>\n<li>\n<p data-local-id="${ids.paragraph}"></p>\n</li>\n</ul>\n`, 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'],
|
||||
['<ul>\n<li><hr></li>\n</ul>\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
|
||||
['<ul>\n<li>a</ul>\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
|
||||
[`<ul>\n<li><p data-local-id="${ids.paragraph}">a</p></ul>\n`, 'unmappable-html at /content/0/content/0 line 2 offset 5'],
|
||||
['<ul>\n<p>a</p>\n</ul>\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
|
||||
['<ul>\n</ul>\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
|
||||
['<ul><li>a</li></ul>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<blockquote>\n<p>a</p></blockquote>\n', 'unmappable-html at /content/0 line 2 offset 13'],
|
||||
['<p>a</p><p>b</p>\n', 'unmappable-html at / line 1 offset 0'],
|
||||
['<ol start="03">\n<li>a</li>\n</ol>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<ol start="1.5">\n<li>a</li>\n</ol>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<ol start="-0">\n<li>a</li>\n</ol>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<ol start="">\n<li>a</li>\n</ol>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<ol start="x">\n<li>a</li>\n</ol>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<ol start="9007199254740993">\n<li>a</li>\n</ol>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<pre><code class="lang-js">x</code></pre>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<pre><code class="language-">x</code></pre>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<pre><code class="language-js" id="x">x</code></pre>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<pre>x</pre>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<pre><code>a &bogus; b</code></pre>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<pre><code>x</code></pre\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
],
|
||||
'refuses a carry element adfToHtml would not write': [
|
||||
[`<div data-adf="${rule}" class="adf-carry"></div>\n`, 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
[`<div class="adf-carry" data-adf="${rule}" id="x"></div>\n`, 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
[`<div class="carry" data-adf="${rule}"></div>\n`, 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
[`<div class="adf-carry" data-adf="${rule}">\n`, 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<div class="adf-carry" data-adf="{"></div>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<div class="adf-carry" data-adf="{"type":1e999}"></div>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<div class="adf-carry" data-adf="{ "type":"x"}"></div>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<p><span class="adf-carry" data-adf="{"text":"a\u0085","type":"text"}"></span></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p><span class="adf-carry" data-adf="{"type":"text","text":"a"}"></span></p>\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
|
||||
['<div class="adf-carry" data-adf="{"type":5}"></div>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<p><span class="adf-carry" data-adf="[]"></span></p>\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
|
||||
['<p><span>a</span></p>\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': [
|
||||
['<hr data-style="a" data-color="b">\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<hr data-color="a" data-color="b">\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p data-color="x">a</p>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<h1 data-level="2">a</h1>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<ol data-order="2">\n<li>a</li>\n</ol>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<pre data-language="js"><code>x</code></pre>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<p data-localid="x">a</p>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<pre data-wrap="yes"><code>x</code></pre>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<pre data-wrap=" true"><code>x</code></pre>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<pre data-wrap="1"><code>x</code></pre>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<pre data-wrap=""true""><code>x</code></pre>\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<hr data-weight="1.0">\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<hr data-weight="1e999">\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
|
||||
['<ul>\n<li data-x="a">a</li>\n</ul>\n', 'unsupported-node-shape at /content/0/content/0 line 2 offset 5'],
|
||||
[`<blockquote data-local-id="${ids.quote}" data-x="b"></blockquote>\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': [
|
||||
['<p>x</p>', 'unmappable-html at / line 1 offset 0'],
|
||||
['<p>x</p>X', 'unmappable-html at / line 1 offset 0'],
|
||||
['<p>x</p>\n\n', 'unmappable-html at /content/1 line 2 offset 9'],
|
||||
['\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p>a</p>\n<div>b</div>\n', 'unmappable-html at /content/1 line 2 offset 9'],
|
||||
['<p>a</p>\n<P>b</P>\n', 'unmappable-html at /content/1 line 2 offset 9'],
|
||||
['<h7>x</h7>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p class="x">a</p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p>a<b>b</b></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p>a<br/>b</p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p>it's</p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p>a > b</p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p>a\rb</p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p><a href="x" target="y">a</a></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p><a title="t" href="x">a</a></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p><a href="a"b">a</a></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p>a</p>\n<pre><code>b\nc</code></pre>\n<i>d</i>\n', 'unmappable-html at /content/2 line 4 offset 37'],
|
||||
],
|
||||
'refuses an image adfToHtml would not write': [
|
||||
['<p><img src="a" alt=""></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p><img alt="b" src="a"></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p><img src="a" alt="b" title="c"></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p><img src="a">b</p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
['<p><a href="x" rel="y"><img src="a"></a></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
[`<p data-local-id="${ids.paragraph}"><img src="a"></p>\n`, 'unmappable-html at /content/0 line 1 offset 0'],
|
||||
],
|
||||
'refuses marks adfToHtml would not write around text': [
|
||||
['<p><em><em>a</em></em></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p><em></em></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p><em>a<strong>b</strong></em></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p><em><strong>a</em></strong></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p><em>a &bogus; b</em></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p>a<a href="x"><img src="y"></a></p>\n', 'unmappable-html at /content/0/content/1 line 1 offset 0'],
|
||||
['<p><em>a', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
|
||||
['<p>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('<p>x</p>'), 'unmappable-html at / line 1 offset 0')
|
||||
assert.equal(refused('<p>x</p>\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('<p>a</p>\n<div>b</div>\n'), 'unmappable-html at /content/1 line 2 offset 9')
|
||||
assert.equal(refused('<p>a</p>\n<P>b</P>\n'), 'unmappable-html at /content/1 line 2 offset 9')
|
||||
assert.equal(refused('<h7>x</h7>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p class="x">a</p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a<b>b</b></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a<br/>b</p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>it's</p>\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a > b</p>\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p>a\rb</p>\n'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
const lines = `${carry('div', { type: 'unknownBlock' }, 'a\r\nb\rc')}\n<i>d</i>\n`
|
||||
assert.equal(refused(lines), `unmappable-html at /content/1 line 4 offset ${lines.indexOf('<i>')}`)
|
||||
assert.equal(refused('<p><a href="x" target="y">a</a></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><a title="t" href="x">a</a></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><a href="a"b">a</a></p>\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(`<p>${inline}</p>\n`))
|
||||
assert.equal(refused('<em><em>a</em></em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em></em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em>a<strong>b</strong></em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em><strong>a</em></strong>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<em>a &bogus; b</em>'), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('a<a href="x"><img src="y"></a>'), 'unmappable-html at /content/0/content/1 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf('<p><em>a')), 'unmappable-html at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf('<p>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('<ul>\n<li><p>Bolt</p></li>\n</ul>\n'), 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n<li>\n<p>Bolt</p>\n</li>\n</ul>\n'), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10')
|
||||
assert.equal(refused(`<ul>\n<li>\n<p data-local-id="${ids.paragraph}"></p>\n</li>\n</ul>\n`), 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10')
|
||||
assert.equal(refused('<ul>\n<li><hr></li>\n</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n<li>a</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused(`<ul>\n<li><p data-local-id="${ids.paragraph}">a</p></ul>\n`), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n<p>a</p>\n</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul>\n</ul>\n'), 'unmappable-html at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<ul><li>a</li></ul>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<blockquote>\n<p>a</p></blockquote>\n'), 'unmappable-html at /content/0 line 2 offset 13')
|
||||
assert.equal(refused('<p>a</p><p>b</p>\n'), 'unmappable-html at / line 1 offset 0')
|
||||
for (const start of ['03', '1.5', '-0', '', 'x', '9007199254740993']) assert.equal(refused(`<ol start="${start}">\n<li>a</li>\n</ol>\n`), 'unmappable-html at /content/0 line 1 offset 0', start)
|
||||
assert.equal(refused('<pre><code class="lang-js">x</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code class="language-">x</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code class="language-js" id="x">x</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre>x</pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code>a &bogus; b</code></pre>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre><code>x</code></pre\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
})
|
||||
|
||||
test('refuses an image adfToHtml would not write', () => {
|
||||
const refused = (inner: string): string => refusal(htmlToAdf(`<p>${inner}</p>\n`))
|
||||
assert.equal(refused('<img src="a" alt="">'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<img alt="b" src="a">'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<img src="a" alt="b" title="c">'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<img src="a">b'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<a href="x" rel="y"><img src="a"></a>'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf('<p data-local-id="a"><img src="a"></p>\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('<hr data-style="a" data-color="b">\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<hr data-color="a" data-color="b">\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p data-color="x">a</p>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<h1 data-level="2">a</h1>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<ol data-order="2">\n<li>a</li>\n</ol>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<pre data-language="js"><code>x</code></pre>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p data-localid="x">a</p>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
for (const wrap of ['yes', ' true', '1', '"true"']) assert.equal(refused(`<pre data-wrap="${wrap.replaceAll('"', '"')}"><code>x</code></pre>\n`), 'unsupported-node-shape at /content/0 line 1 offset 0', wrap)
|
||||
assert.equal(refused('<hr data-weight="1.0">\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<hr data-weight="1e999">\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<ul>\n<li data-x="a">a</li>\n</ul>\n'), 'unsupported-node-shape at /content/0/content/0 line 2 offset 5')
|
||||
assert.equal(refused('<blockquote data-local-id="a" data-x="b"></blockquote>\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(`<div data-adf="${rule}" class="adf-carry"></div>\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused(`<div class="adf-carry" data-adf="${rule}" id="x"></div>\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused(`<div class="carry" data-adf="${rule}"></div>\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused(`<div class="adf-carry" data-adf="${rule}">\n`), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{"></div>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{"type":1e999}"></div>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{ "type":"x"}"></div>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span class="adf-carry" data-adf="{"text":"a\u0085","type":"text"}"></span></p>\n'), 'unmappable-html at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span class="adf-carry" data-adf="{"type":"text","text":"a"}"></span></p>\n'), 'unsupported-node-shape at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<div class="adf-carry" data-adf="{"type":5}"></div>\n'), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span class="adf-carry" data-adf="[]"></span></p>\n'), 'unsupported-node-shape at /content/0/content/0 line 1 offset 0')
|
||||
assert.equal(refused('<p><span>a</span></p>\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('></div>', '>x</div>')}\n`)), 'unmappable-html at / line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf(`${documentCarry}\n<hr>\n`)), 'unsupported-node-shape at /content/0 line 1 offset 0')
|
||||
assert.equal(refusal(htmlToAdf(`<hr>\n${documentCarry}\n`)), 'unsupported-node-shape at /content/1 line 2 offset 5')
|
||||
assert.equal(refusal(htmlToAdf(`<blockquote>\n${documentCarry}\n</blockquote>\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(`<p${' a=""'.repeat(3_000_000)}>x</p>\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('<p>a<b>b</b></p>\n')), '<b> as written here is no inline element of the dialect: found "<b>b</b></p>\\n"')
|
||||
assert.equal(message(htmlToAdf('<p>a > b</p>\n')), 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found "> b</p>\\n"')
|
||||
assert.equal(message(htmlToAdf('<p><a href="a"b">a</a></p>\n')), '<a> holds its attributes as name="value", each value escaped as text is, then >: found "b\\">a</a></p>\\n"')
|
||||
assert.equal(message(htmlToAdf('<p>a \u00a0</p>\n')), 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found " \\u00a0</p>\\n"')
|
||||
assert.equal(message(htmlToAdf('<pre><code>a</code></pre\n')), 'a code block\'s text ends at </code></pre>: found "</code></pre\\n"')
|
||||
assert.equal(message(htmlToAdf('<ul>\n<li>a</ul>\n')), 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "</ul>\\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 => `${'<blockquote>\n'.repeat(levels)}<p>x</p>${'\n</blockquote>'.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 => `${'<ul>\n<li>x\n'.repeat(levels)}<hr>${'\n</li>\n</ul>'.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('<blockquote>\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(`<blockquote>\n${carried}\n</blockquote>\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(`<p>${carry('span', inline, '')}</p>\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)
|
||||
|
||||
@@ -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<BlockStep, ParseError>
|
||||
@@ -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<string, BlockReader> = 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 = '</code></pre>'
|
||||
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<Record<FirstParagraphForm, string>> = {
|
||||
bare: "an item's first paragraph holding content and no attribute stands bare on the item's line: <li>Bolt</li>",
|
||||
@@ -63,15 +60,17 @@ export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
|
||||
function readHtml(html: string): Result<AdfDocument> {
|
||||
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<AdfDocument, ParseError> | 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('</div>', 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 + '</div>'.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<AdfDocument, ParseError> {
|
||||
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<T>(html: string, end: number, place: Place | undefined): Result<T, ParseError> {
|
||||
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<BlockStep, ParseError> {
|
||||
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<BlockStep, ParseError> {
|
||||
@@ -116,7 +122,7 @@ function asStep(read: ReadNode): Result<BlockStep, ParseError> {
|
||||
}
|
||||
|
||||
function readHeading(html: string, tag: StartTag, place: Place): Result<BlockStep, ParseError> {
|
||||
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<BlockStep, ParseError> {
|
||||
@@ -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 ? '</p>' : '</a></p>'
|
||||
@@ -170,21 +179,20 @@ function imageNode(image: StartTag, anchor: StartTag | undefined): AdfNode | und
|
||||
function readCodeBlock(html: string, tag: StartTag, place: Place): Result<BlockStep, ParseError> {
|
||||
const data = readDataAttributes(tag.attributes, 'codeBlock', blockNodes.codeBlock.attributes, ['language'])
|
||||
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 <pre><code class="language-…">, its text escaped, then </code></pre>', 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 `<code>` at `index`, through the `</pre>` 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, '<pre> holds <code>, or <code class="language-…"> 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<BlockStep, ParseError> {
|
||||
@@ -193,20 +201,20 @@ function readRule(html: string, tag: StartTag, place: Place): Result<BlockStep,
|
||||
}
|
||||
|
||||
function readBlockCarry(html: string, tag: StartTag, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
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<BlockStep, ParseError> {
|
||||
const type = tag.name === 'blockquote' ? 'blockquote' : tag.name === 'ul' ? 'bulletList' : 'orderedList'
|
||||
function readContainer(html: string, tag: StartTag, type: ContainerType, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
const [first, ...rest] = tag.attributes
|
||||
const start = type === 'orderedList' && first?.[0] === 'start' ? first[1] : undefined
|
||||
const 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<BlockStep, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name !== 'li') return refused('unmappable-html', `a list holds <li> items, one to a line: ${foundAt(html, index)}`, html, index, place)
|
||||
if (tag?.name !== 'li') {
|
||||
const stop = misread(html, index, 'a list holds <li> items, one to a line', 'no list item of the dialect')
|
||||
return refused('unmappable-html', stop.message, html, stop.at, place)
|
||||
}
|
||||
const data = readDataAttributes(tag.attributes, 'listItem', blockNodes.listItem.attributes, [])
|
||||
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 }
|
||||
}
|
||||
|
||||
+12
-11
@@ -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<T>(html: string, index: number, place: Place): Result<T, ParseError> {
|
||||
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)
|
||||
|
||||
+47
-19
@@ -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<T>(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 }
|
||||
}
|
||||
|
||||
@@ -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}` } }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user