mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-13 12:18:28 +01:00
Answer architecture round 1: 2 🔴, 6 🟡 (0 misread); carried JSON read through adf/, one spelling home per element in html/, block readers in one table, the first paragraph's form shared, a lint boundary for html/parse, a strictness oracle
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import type { LinkAttrs } from '../adf/mark-attributes.ts'
|
||||
import { optionalAttribute, writableValue } from './escaping.ts'
|
||||
|
||||
export function anchorOpener({ href, title }: LinkAttrs): string | undefined {
|
||||
const url = writableValue(href)
|
||||
const titled = optionalAttribute('title', title)
|
||||
return url === undefined || titled === undefined ? undefined : `<a href="${url}"${titled}>`
|
||||
}
|
||||
|
||||
// The link an `<a>`'s attributes hold, `undefined` where `anchorOpener` writes no such attributes.
|
||||
export function readAnchorAttributes(attributes: readonly (readonly [string, string])[]): LinkAttrs | undefined {
|
||||
const [href, title, ...others] = attributes
|
||||
if (href?.[0] !== 'href' || (title !== undefined && title[0] !== 'title') || others.length > 0) return undefined
|
||||
return title === undefined ? { href: href[1] } : { href: href[1], title: title[1] }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { jsonValue } from './escaping.ts'
|
||||
|
||||
const carryClass = 'adf-carry'
|
||||
const jsonAttribute = 'data-adf'
|
||||
|
||||
export function carryAttributes(json: string): string {
|
||||
return ` class="${carryClass}" ${jsonAttribute}="${jsonValue(json)}"`
|
||||
}
|
||||
|
||||
// The JSON a carry element's attributes hold, as HTML can hold it; `undefined` where `carryAttributes` writes no such attributes.
|
||||
export function readCarryAttributes(attributes: readonly (readonly [string, string])[]): string | undefined {
|
||||
const [kind, held, ...others] = attributes
|
||||
return kind?.[0] === 'class' && kind[1] === carryClass && held?.[0] === jsonAttribute && others.length === 0 ? held[1] : undefined
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { anchorOpener, languageClass, optionalAttribute, writableValue } from '../escaping.ts'
|
||||
import { anchorOpener } from '../anchor-element.ts'
|
||||
import { blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { carriedBlock, carryElement } from './carry-element.ts'
|
||||
import { dataAttributes } from '../data-attributes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { firstParagraphForm } from '../first-paragraph.ts'
|
||||
import { imageElement } from '../image-element.ts'
|
||||
import { inlineHtml, writableText } from './inline.ts'
|
||||
import { isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
|
||||
import { languageClass } from '../language-class.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
|
||||
import { plainImage } from '../../adf/plain-image.ts'
|
||||
@@ -99,7 +102,6 @@ function containerPieces(tag: string, attributes: string, children: Pending[] |
|
||||
return [`<${tag}${attributes}>\n`, ...children, `\n</${tag}>`]
|
||||
}
|
||||
|
||||
// An item's first paragraph stands on the item's line, as the writer panels picked (docs/decisions.md §The HTML dialect).
|
||||
function writeItem({ attributes, depth, node, path }: Item): Result<Pending[]> {
|
||||
if (node.content === undefined) return success([`<li${attributes}></li>`])
|
||||
const blocks = blockPieces(node.content, path, depth)
|
||||
@@ -111,11 +113,11 @@ function writeItem({ attributes, depth, node, path }: Item): Result<Pending[]> {
|
||||
return success(blocks.length === 1 ? [`<li${attributes}>${line.value}</li>`] : [`<li${attributes}>${line.value}`, ...blocks.slice(1), '\n</li>'])
|
||||
}
|
||||
|
||||
// `undefined` where the paragraph takes a line of its own: where it rides the carry, or holds neither content nor an attribute: written bare, it would read back as no paragraph.
|
||||
// `undefined` where the paragraph takes a line of its own.
|
||||
function tryFirstParagraph(paragraph: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
|
||||
const attributes = dataAttributes(paragraph, blockNodes.paragraph.attributes, [])
|
||||
if (attributes !== '') return inlineElement('p', attributes, nodeContent(paragraph), path)
|
||||
return nodeContent(paragraph).length === 0 ? undefined : inlineHtml(nodeContent(paragraph), path)
|
||||
const form = firstParagraphForm(paragraph)
|
||||
if (form === 'block') return undefined
|
||||
return form === 'bare' ? inlineHtml(nodeContent(paragraph), path) : tryLeafBlock(paragraph, path)
|
||||
}
|
||||
|
||||
function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
|
||||
@@ -146,11 +148,8 @@ function tryCodeBlock(node: AdfNode): string | undefined {
|
||||
|
||||
function tryImage(node: AdfNode): string | undefined {
|
||||
const image = plainImage(node)
|
||||
if (image === undefined) return undefined
|
||||
const src = writableValue(image.url)
|
||||
const alt = optionalAttribute('alt', image.alt)
|
||||
if (src === undefined || alt === undefined) return undefined
|
||||
const element = `<img src="${src}"${alt}>`
|
||||
const element = image === undefined ? undefined : imageElement(image)
|
||||
if (image === undefined || element === undefined) return undefined
|
||||
if (image.link === undefined) return `<p>${element}</p>`
|
||||
const anchor = anchorOpener(image.link)
|
||||
return anchor === undefined ? undefined : `<p>${anchor}${element}</a></p>`
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { LinkAttrs } from '../../adf/mark-attributes.ts'
|
||||
import { anchorOpener, jsonValue, languageClass, shownValue } from '../escaping.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 { 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.
|
||||
@@ -23,7 +26,7 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
|
||||
}
|
||||
|
||||
export function carryElement(tag: string, json: string, content: string): string {
|
||||
return `<${tag} class="adf-carry" data-adf="${jsonValue(json)}">${content}</${tag}>`
|
||||
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.
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { anchorOpener, writableValue } from '../escaping.ts'
|
||||
import { anchorOpener } from '../anchor-element.ts'
|
||||
import { carriedInline } from './carry-element.ts'
|
||||
import { elementOfMark } from '../mark-elements.ts'
|
||||
import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { linkAttrs } from '../../adf/mark-attributes.ts'
|
||||
import { success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
|
||||
const markElements: ReadonlyMap<string, string> = new Map([
|
||||
['code', 'code'],
|
||||
['em', 'em'],
|
||||
['strong', 'strong'],
|
||||
])
|
||||
import { writableValue } from '../escaping.ts'
|
||||
|
||||
export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
|
||||
let html = ''
|
||||
@@ -51,7 +47,7 @@ export function writableText(text: string | undefined): string | undefined {
|
||||
|
||||
function markElement(mark: AdfMark): { closer: string; opener: string } | undefined {
|
||||
const { type } = mark
|
||||
const element = markElements.get(type)
|
||||
const element = elementOfMark(type)
|
||||
if (element !== undefined) return mark.attrs === undefined ? { closer: `</${element}>`, opener: `<${element}>` } : undefined
|
||||
if (type !== 'link' || Object.keys(nodeAttrs(mark)).some((key) => key !== 'href' && key !== 'title')) return undefined
|
||||
const link = linkAttrs(nodeAttrs(mark))
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import type { LinkAttrs } from '../adf/mark-attributes.ts'
|
||||
|
||||
const entities: ReadonlyMap<string, string> = new Map([
|
||||
['&', '&'],
|
||||
['>', '>'],
|
||||
['<', '<'],
|
||||
['"', '"'],
|
||||
])
|
||||
const languageToken = /^[^\t\n\f\r ]+$/
|
||||
// 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')
|
||||
|
||||
export function anchorOpener({ href, title }: LinkAttrs): string | undefined {
|
||||
const url = writableValue(href)
|
||||
const titled = optionalAttribute('title', title)
|
||||
return url === undefined || titled === undefined ? undefined : `<a href="${url}"${titled}>`
|
||||
}
|
||||
|
||||
// The language a `<code>`'s class names, `undefined` where `languageClass` writes no such class.
|
||||
export function classLanguage(className: string): string | undefined {
|
||||
const language = className.startsWith('language-') ? className.slice('language-'.length) : undefined
|
||||
return language !== undefined && languageToken.test(language) ? language : undefined
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return json.replace(unwritables, (character) => jsonEscape(character))
|
||||
@@ -33,12 +17,6 @@ export function jsonValue(json: string): string {
|
||||
return escapeHtml(holdableJson(json))
|
||||
}
|
||||
|
||||
// A code block's ` class="language-…"`, `''` where it holds no language, `undefined` where a class cannot hold the language as one token.
|
||||
export function languageClass(language: JsonValue | undefined): string | undefined {
|
||||
if (language === undefined) return ''
|
||||
return typeof language === 'string' && languageToken.test(language) ? optionalAttribute('class', `language-${language}`) : undefined
|
||||
}
|
||||
|
||||
// ` 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 ''
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { dataAttributes } from './data-attributes.ts'
|
||||
import { nodeContent } from '../adf/document.ts'
|
||||
|
||||
// Where a list item's first paragraph stands: `bare` on the item's line, its `element` there, or a `block` on a line of its own.
|
||||
export type FirstParagraphForm = 'bare' | 'block' | 'element'
|
||||
|
||||
// As the writer panels picked (docs/decisions.md §The HTML dialect); a paragraph holding neither content nor an attribute, written bare, would read back as no paragraph.
|
||||
export function firstParagraphForm(paragraph: AdfNode): FirstParagraphForm {
|
||||
const attributes = dataAttributes(paragraph, blockNodes.paragraph.attributes, [])
|
||||
if (attributes === undefined) return 'block'
|
||||
if (attributes !== '') return 'element'
|
||||
return nodeContent(paragraph).length === 0 ? 'block' : 'bare'
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PlainImage } from '../adf/plain-image.ts'
|
||||
import { optionalAttribute, writableValue } from './escaping.ts'
|
||||
|
||||
// `undefined` where HTML cannot hold the image's url or alt.
|
||||
export function imageElement({ alt, url }: PlainImage): string | undefined {
|
||||
const src = writableValue(url)
|
||||
const described = optionalAttribute('alt', alt)
|
||||
return src === undefined || described === undefined ? undefined : `<img src="${src}"${described}>`
|
||||
}
|
||||
|
||||
// The `src` and `alt` an `<img>`'s attributes hold, `undefined` where `imageElement` writes no such attributes.
|
||||
export function readImageAttributes(attributes: readonly (readonly [string, string])[]): { alt: string | undefined; src: string } | undefined {
|
||||
const [src, alt, ...others] = attributes
|
||||
if (src?.[0] !== 'src' || (alt !== undefined && alt[0] !== 'alt') || others.length > 0) return undefined
|
||||
return { alt: alt?.[1], src: src[1] }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { optionalAttribute } from './escaping.ts'
|
||||
|
||||
const languagePrefix = 'language-'
|
||||
const languageToken = /^[^\t\n\f\r ]+$/
|
||||
|
||||
// A code block's ` class="language-…"`, `''` where it holds no language, `undefined` where a class cannot hold the language as one token.
|
||||
export function languageClass(language: JsonValue | undefined): string | undefined {
|
||||
if (language === undefined) return ''
|
||||
return typeof language === 'string' && languageToken.test(language) ? optionalAttribute('class', `${languagePrefix}${language}`) : undefined
|
||||
}
|
||||
|
||||
// The language a `<code>`'s attributes name, none where it holds no attribute; `undefined` where `languageClass` writes no such attributes.
|
||||
export function readLanguageClass(attributes: readonly (readonly [string, string])[]): { language: string | undefined } | undefined {
|
||||
const [className, ...others] = attributes
|
||||
if (className === undefined) return { language: undefined }
|
||||
const language = className[0] === 'class' && className[1].startsWith(languagePrefix) ? className[1].slice(languagePrefix.length) : undefined
|
||||
return language !== undefined && languageToken.test(language) && others.length === 0 ? { language } : undefined
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// The element each mark but a link writes around text, holding no attribute; a link writes `<a>`.
|
||||
const markElements: ReadonlyMap<string, string> = new Map([
|
||||
['code', 'code'],
|
||||
['em', 'em'],
|
||||
['strong', 'strong'],
|
||||
])
|
||||
|
||||
export function elementOfMark(type: string): string | undefined {
|
||||
return markElements.get(type)
|
||||
}
|
||||
|
||||
export function markOfElement(name: string): string | undefined {
|
||||
for (const [type, element] of markElements) if (element === name) return type
|
||||
return undefined
|
||||
}
|
||||
@@ -1,33 +1,37 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { JsonValue } from '../../json-value.ts'
|
||||
import type { ParseError, Result } from '../../result.ts'
|
||||
import type { ParseError, Read, Result } from '../../result.ts'
|
||||
import { carriedNesting, carriedNode, carriedValue } from '../../adf/carried-json.ts'
|
||||
import { holdableJson } from '../escaping.ts'
|
||||
import { isAdfNode } from '../../adf/document.ts'
|
||||
import { overNested } from '../../json-value.ts'
|
||||
import { parseJsonText } from '../../json-text.ts'
|
||||
import { readCarryAttributes } from '../carry-attributes.ts'
|
||||
import { refused, type Place, type StartTag } from './source.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { spellsCanonicalJson } from '../../canonical-json.ts'
|
||||
import { success } from '../../result.ts'
|
||||
|
||||
// The JSON a carry element holds, and the end of the element; 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 }, ParseError> {
|
||||
const [kind, held, ...others] = tag.attributes
|
||||
if (kind?.[0] !== 'class' || kind[1] !== 'adf-carry' || held?.[0] !== 'data-adf' || others.length > 0) {
|
||||
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 json = held[1]
|
||||
const parsed = parseJsonText(json)
|
||||
if (parsed.refusal === 'syntax') return refused('unmappable-html', "a carry element's data-adf holds invalid JSON", html, tag.end, place)
|
||||
if (parsed.refusal !== undefined) return refused('unsupported-node-shape', "a carry element's data-adf holds a number JSON cannot spell", html, tag.end, place)
|
||||
if (overNested(parsed.value, levels)) return refused('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, html, tag.end, place)
|
||||
if (holdableJson(serializeCanonicalJson(parsed.value, 'compact')) !== json) {
|
||||
return refused('unsupported-node-shape', "a carry element spells its node's JSON canonically: compact, keys sorted, a character HTML cannot hold as a JSON escape", html, tag.end, place)
|
||||
}
|
||||
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> {
|
||||
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: parsed.value })
|
||||
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 })
|
||||
}
|
||||
|
||||
// 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> {
|
||||
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> {
|
||||
return isAdfNode(held) ? success(held) : refused('unsupported-node-shape', "a carry element's data-adf holds one ADF node's JSON: this JSON is no ADF node", html, index, place)
|
||||
const node = carriedNode(held, holder)
|
||||
return node.fault === undefined ? success(node.value) : refused(node.fault.code, node.fault.message, html, index, place)
|
||||
}
|
||||
|
||||
+128
-74
@@ -1,28 +1,56 @@
|
||||
import type { AdfAttributes, AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
|
||||
import type { FirstParagraphForm } from '../first-paragraph.ts'
|
||||
import type { ParseError, Result } from '../../result.ts'
|
||||
import { blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
|
||||
import { classLanguage, unescapedValue } from '../escaping.ts'
|
||||
import { foundAt, readStartTag, refused, type Place, type StartTag } from './source.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 { largestNesting } from '../../nesting.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 { readCarryAttributes } from '../carry-attributes.ts'
|
||||
import { readDataAttributes } from '../data-attributes.ts'
|
||||
import { readInline, readLink } from './inline.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>
|
||||
|
||||
type BlockStep = { end: number; kind: 'node'; node: AdfNode } | { end: number; kind: 'opened'; open: Open }
|
||||
|
||||
// A container whose blocks or items are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it.
|
||||
type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'items'; node: AdfNode; place: Place | undefined }
|
||||
type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'list'; node: AdfNode; place: Place | undefined }
|
||||
|
||||
type Read = { end: number; kind: 'document' } | { end: number; kind: 'node'; node: AdfNode } | { end: number; kind: 'opened'; open: Open }
|
||||
|
||||
type ReadNode = Result<{ end: number; node: AdfNode }, ParseError>
|
||||
|
||||
const blockElements: ReadonlySet<string> = new Set(['blockquote', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'ol', 'p', 'pre', 'ul'])
|
||||
// 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],
|
||||
['div', readBlockCarry],
|
||||
['h1', readHeading],
|
||||
['h2', readHeading],
|
||||
['h3', readHeading],
|
||||
['h4', readHeading],
|
||||
['h5', readHeading],
|
||||
['h6', readHeading],
|
||||
['hr', readRule],
|
||||
['ol', readContainer],
|
||||
['p', readParagraph],
|
||||
['pre', readCodeBlock],
|
||||
['ul', readContainer],
|
||||
])
|
||||
const codeCloser = '</code></pre>'
|
||||
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>",
|
||||
block: "an item's first paragraph holding neither content nor an attribute stands on a line of its own",
|
||||
element: "an item's first paragraph holding an attribute stands as its <p> on the item's line, straight after <li>",
|
||||
}
|
||||
|
||||
// Reads back exactly what `adfToHtml` writes. Other HTML is `unmappable-html`; a `data-*` name or value no attribute of its node spells is `unsupported-node-shape`, as a directive's attribute is (docs/decisions.md §Which code a cause takes).
|
||||
export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
|
||||
@@ -34,7 +62,16 @@ export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
|
||||
|
||||
function readHtml(html: string): Result<AdfDocument> {
|
||||
if (typeof html !== 'string') return notAString(html, 'HTML')
|
||||
return html === '' ? success({ content: [], type: 'doc', version: 1 }) : readBlocks(html)
|
||||
if (html === '') return success({ content: [], type: 'doc', version: 1 })
|
||||
return isDocumentCarry(html) ? success({ type: 'doc', version: 1 }) : 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 {
|
||||
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')
|
||||
}
|
||||
|
||||
// A stack rather than recursion: only this loop opens and closes containers.
|
||||
@@ -46,19 +83,15 @@ function readBlocks(html: string): Result<AdfDocument, ParseError> {
|
||||
for (;;) {
|
||||
const place = { index: open.content.length, parent: open.place }
|
||||
if (open.depth > largestNesting) return refused('unsupported-nesting-depth', `the HTML nests deeper than the ${largestNesting} levels the reader carries`, html, index, place)
|
||||
const read = open.kind === 'items' ? readItem(html, index, place, open.depth) : readBlock(html, index, place, open)
|
||||
if (!read.ok) return read
|
||||
index = read.value.end
|
||||
if (read.value.kind === 'document') {
|
||||
if (open === root && open.content.length === 0 && index === html.length - 1 && html.endsWith('\n')) return success({ type: 'doc', version: 1 })
|
||||
return refused('unsupported-node-shape', 'a carry element holding the document stands alone, as the whole document', html, index, place)
|
||||
}
|
||||
if (read.value.kind === 'opened') {
|
||||
const step = open.kind === 'list' ? readItem(html, index, place, open.depth) : readBlock(html, index, place, open)
|
||||
if (!step.ok) return step
|
||||
index = step.value.end
|
||||
if (step.value.kind === 'opened') {
|
||||
opened.push(open)
|
||||
open = read.value.open
|
||||
open = step.value.open
|
||||
continue
|
||||
}
|
||||
open.content.push(read.value.node)
|
||||
open.content.push(step.value.node)
|
||||
while (open !== root && html.startsWith(open.closer, index)) {
|
||||
index += open.closer.length
|
||||
const closed: AdfNode = { ...open.node, content: open.content }
|
||||
@@ -71,30 +104,32 @@ function readBlocks(html: string): Result<AdfDocument, ParseError> {
|
||||
}
|
||||
}
|
||||
|
||||
function readBlock(html: string, index: number, place: Place, open: Open): Result<Read, ParseError> {
|
||||
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 level = /^h([1-6])$/.exec(tag.name)?.[1]
|
||||
if (level !== undefined) return asNode(readInlineBlock(html, tag, 'heading', blockNodes.heading.attributes, { level: Number(level) }, place))
|
||||
if (tag.name === 'p') return asNode(readParagraph(html, tag, place, open.kind === 'item' && open.content.length === 0))
|
||||
if (tag.name === 'pre') return asNode(readCodeBlock(html, tag, place))
|
||||
if (tag.name === 'hr') return asNode(readRule(html, tag, place))
|
||||
if (tag.name === 'div') return readBlockCarry(html, tag, place, open.depth)
|
||||
if (['blockquote', 'ol', 'ul'].includes(tag.name)) return readContainer(html, tag, place, open.depth)
|
||||
return refused('unmappable-html', `<${tag.name}> is no block element of the dialect`, 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)
|
||||
}
|
||||
|
||||
function asNode(read: ReadNode): Result<Read, ParseError> {
|
||||
function asStep(read: ReadNode): Result<BlockStep, ParseError> {
|
||||
return read.ok ? success({ ...read.value, kind: 'node' }) : read
|
||||
}
|
||||
|
||||
// `firstInItem`: the paragraph opens an item read block by block, which the writer does only where the paragraph holds neither content nor an attribute.
|
||||
function readParagraph(html: string, tag: StartTag, place: Place, firstInItem: boolean): ReadNode {
|
||||
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))
|
||||
}
|
||||
|
||||
function readParagraph(html: string, tag: StartTag, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
const image = tag.attributes.length === 0 ? tryImage(html, tag.end, place) : undefined
|
||||
if (image !== undefined) return image
|
||||
if (image !== undefined) return asStep(image)
|
||||
const paragraph = readInlineBlock(html, tag, 'paragraph', blockNodes.paragraph.attributes, {}, place)
|
||||
if (!firstInItem || !paragraph.ok || (paragraph.value.node.attrs === undefined && paragraph.value.node.content === undefined)) return paragraph
|
||||
return refused('unmappable-html', "an item's first paragraph holding content or an attribute stands on the item's line, straight after <li>", html, tag.start, place)
|
||||
const firstInItem = open.kind === 'item' && open.content.length === 0
|
||||
return asStep(firstInItem && paragraph.ok ? asFirstParagraph(paragraph.value, 'block', html, tag.start, place) : paragraph)
|
||||
}
|
||||
|
||||
function asFirstParagraph(read: { end: number; node: AdfNode }, found: FirstParagraphForm, html: string, index: number, place: Place): ReadNode {
|
||||
const form = firstParagraphForm(read.node)
|
||||
return form === found ? success(read) : refused('unmappable-html', formRefusals[form], html, index, place)
|
||||
}
|
||||
|
||||
// `own`: the attributes the element itself writes, such as a heading's level.
|
||||
@@ -111,50 +146,62 @@ function readInlineBlock(html: string, tag: StartTag, type: string, vocabulary:
|
||||
|
||||
// `undefined` where no image follows the `<p>`, so it opens a paragraph.
|
||||
function tryImage(html: string, index: number, place: Place): ReadNode | undefined {
|
||||
const anchor = readStartTag(html, index)
|
||||
const linked = anchor?.name === 'a'
|
||||
const image = readStartTag(html, linked ? anchor.end : index)
|
||||
const opener = readStartTag(html, index)
|
||||
const anchor = opener?.name === 'a' ? opener : undefined
|
||||
const image = readStartTag(html, anchor?.end ?? index)
|
||||
if (image?.name !== 'img') return undefined
|
||||
const link = linked ? readLink(anchor) : undefined
|
||||
const [src, alt, ...others] = image.attributes
|
||||
const closer = linked ? '</a></p>' : '</p>'
|
||||
if ((linked && link === undefined) || src?.[0] !== 'src' || (alt !== undefined && (alt[0] !== 'alt' || alt[1] === '')) || others.length > 0 || !html.startsWith(closer, image.end)) {
|
||||
const node = imageNode(image, anchor)
|
||||
const closer = anchor === undefined ? '</p>' : '</a></p>'
|
||||
if (node === undefined || !html.startsWith(closer, image.end)) {
|
||||
return refused('unmappable-html', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it', html, image.start, place)
|
||||
}
|
||||
return success({ end: image.end + closer.length, node: centeredImage([externalMedia(alt?.[1] ?? '', src[1], link)]) })
|
||||
return success({ end: image.end + closer.length, node })
|
||||
}
|
||||
|
||||
function readCodeBlock(html: string, tag: StartTag, place: Place): ReadNode {
|
||||
// The image an `<img>`, and the `<a>` around it, read as; `undefined` where `adfToHtml` writes no such elements.
|
||||
function imageNode(image: StartTag, anchor: StartTag | undefined): AdfNode | undefined {
|
||||
const found = readImageAttributes(image.attributes)
|
||||
const link = anchor === undefined ? undefined : readAnchorAttributes(anchor.attributes)
|
||||
if (found === undefined || (anchor !== undefined && link === undefined)) return undefined
|
||||
const media = externalMedia(found.alt ?? '', found.src, link)
|
||||
return nodeAttrs(media)['alt'] === found.alt ? centeredImage([media]) : undefined
|
||||
}
|
||||
|
||||
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 = readStartTag(html, tag.end)
|
||||
const [className, ...others] = code?.attributes ?? []
|
||||
const language = className?.[0] === 'class' ? classLanguage(className[1]) : undefined
|
||||
const next = code === undefined ? -1 : html.indexOf('<', code.end)
|
||||
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 named = code?.name === 'code' ? readLanguageClass(code.attributes) : undefined
|
||||
if (code === undefined || named === undefined) return undefined
|
||||
const next = html.indexOf('<', code.end)
|
||||
const end = next < 0 ? html.length : next
|
||||
const text = code === undefined ? undefined : unescapedValue(html.slice(code.end, end))
|
||||
if (code?.name !== 'code' || (className !== undefined && language === undefined) || others.length > 0 || text === undefined || !html.startsWith('</code></pre>', end)) {
|
||||
return refused('unmappable-html', 'a code block is <pre><code class="language-…">, its text escaped, then </code></pre>', html, tag.end, place)
|
||||
}
|
||||
const attrs = language === undefined ? data.value : { ...data.value, language }
|
||||
return success({ end: end + '</code></pre>'.length, node: built('codeBlock', attrs, text === '' ? [] : [{ text, type: 'text' }]) })
|
||||
const text = unescapedValue(html.slice(code.end, end))
|
||||
return text === undefined || !html.startsWith(codeCloser, end) ? undefined : { end: end + codeCloser.length, language: named.language, text }
|
||||
}
|
||||
|
||||
function readRule(html: string, tag: StartTag, place: Place): ReadNode {
|
||||
function readRule(html: string, tag: StartTag, place: Place): Result<BlockStep, ParseError> {
|
||||
const data = readDataAttributes(tag.attributes, 'rule', blockNodes.rule.attributes, [])
|
||||
return data.fault === undefined ? success({ end: tag.end, node: built('rule', data.value, []) }) : refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
return data.fault === undefined ? success({ end: tag.end, kind: 'node', node: built('rule', data.value, []) }) : refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
}
|
||||
|
||||
function readBlockCarry(html: string, tag: StartTag, place: Place, depth: number): Result<Read, ParseError> {
|
||||
const carry = readCarry(html, tag, largestNesting - depth, place)
|
||||
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 (tag.attributes[1]?.[1] === emptyDocument) return success({ end: carry.value.end, kind: 'document' })
|
||||
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
|
||||
}
|
||||
|
||||
// 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, depth: number): Result<Read, ParseError> {
|
||||
function readContainer(html: string, tag: StartTag, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
const type = tag.name === 'blockquote' ? 'blockquote' : tag.name === 'ul' ? 'bulletList' : 'orderedList'
|
||||
const [first, ...rest] = tag.attributes
|
||||
const start = type === 'orderedList' && first?.[0] === 'start' ? first[1] : undefined
|
||||
@@ -168,11 +215,10 @@ function readContainer(html: string, tag: StartTag, place: Place, depth: number)
|
||||
const closer = `</${tag.name}>`
|
||||
if (html.startsWith(closer, tag.end)) return success({ end: tag.end + closer.length, kind: 'node', node })
|
||||
if (!html.startsWith('\n', tag.end)) return refused('unmappable-html', `<${tag.name}> holds ${closer} straight after it, or a line feed and its first ${type === 'blockquote' ? 'block' : 'item'}: ${foundAt(html, tag.end)}`, html, tag.end, place)
|
||||
return success({ end: tag.end + 1, kind: 'opened', open: { closer: `\n${closer}`, content: [], depth: depth + 1, kind: type === 'blockquote' ? 'blocks' : 'items', node, place } })
|
||||
return success({ end: tag.end + 1, kind: 'opened', open: { closer: `\n${closer}`, content: [], depth: open.depth + 1, kind: type === 'blockquote' ? 'blocks' : 'list', node, place } })
|
||||
}
|
||||
|
||||
// An item's first paragraph stands on its line, as the writer panels picked (docs/decisions.md §The HTML dialect); where it does not, the item's blocks follow a line each.
|
||||
function readItem(html: string, index: number, place: Place, depth: number): Result<Read, ParseError> {
|
||||
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)
|
||||
const data = readDataAttributes(tag.attributes, 'listItem', blockNodes.listItem.attributes, [])
|
||||
@@ -180,35 +226,43 @@ function readItem(html: string, index: number, place: Place, depth: number): Res
|
||||
const item = built('listItem', data.value, [])
|
||||
if (html.startsWith('</li>', tag.end)) return success({ end: tag.end + '</li>'.length, kind: 'node', node: item })
|
||||
const open: Open = { closer: '\n</li>', content: [], depth, kind: 'item', node: item, place }
|
||||
if (html.startsWith('\n', tag.end) && isBlockStart(html, tag.end + 1)) return success({ end: tag.end + 1, kind: 'opened', open })
|
||||
const line = readItemLine(html, tag.end, { index: 0, parent: place })
|
||||
return html.startsWith('\n', tag.end) && isBlockStart(html, tag.end + 1) ? success({ end: tag.end + 1, kind: 'opened', open }) : readItemLine(html, tag.end, open)
|
||||
}
|
||||
|
||||
function readItemLine(html: string, index: number, open: Open): Result<BlockStep, ParseError> {
|
||||
const place = { index: 0, parent: open.place }
|
||||
const line = readLineParagraph(html, index, place)
|
||||
if (!line.ok) return line
|
||||
const { end, node } = line.value
|
||||
if (html.startsWith('</li>', end)) return success({ end: end + '</li>'.length, kind: 'node', node: { ...item, content: [node] } })
|
||||
if (!html.startsWith('\n', end)) return refused('unmappable-html', `an item's line ends at </li>, or at a line feed before the item's next block: ${foundAt(html, end)}`, html, end, place)
|
||||
if (!html.startsWith('</li>', end) && !html.startsWith('\n', end)) {
|
||||
return refused('unmappable-html', `an item's line ends at </li>, or at a line feed before the item's next block: ${foundAt(html, end)}`, html, end, open.place)
|
||||
}
|
||||
const paragraph = asFirstParagraph({ end, node }, line.value.form, html, index, place)
|
||||
if (!paragraph.ok) return paragraph
|
||||
if (html.startsWith('</li>', end)) return success({ end: end + '</li>'.length, kind: 'node', node: { ...open.node, content: [node] } })
|
||||
return success({ end: end + 1, kind: 'opened', open: { ...open, content: [node] } })
|
||||
}
|
||||
|
||||
// The paragraph on an item's line, up to `</li>` or to the line feed before the item's next block, which its caller reads.
|
||||
function readItemLine(html: string, index: number, place: Place): ReadNode {
|
||||
// The paragraph on an item's line and the form it stands in there, up to `</li>` or to the line feed before the item's next block.
|
||||
function readLineParagraph(html: string, index: number, place: Place): Result<{ end: number; form: FirstParagraphForm; node: AdfNode }, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name === 'p') {
|
||||
if (tag.attributes.length > 0) return readInlineBlock(html, tag, 'paragraph', blockNodes.paragraph.attributes, {}, place)
|
||||
return refused('unmappable-html', "an item's first paragraph holding no attribute stands bare on the item's line: <li>Bolt</li>", html, index, place)
|
||||
const paragraph = readInlineBlock(html, tag, 'paragraph', blockNodes.paragraph.attributes, {}, place)
|
||||
return paragraph.ok ? success({ ...paragraph.value, form: 'element' }) : paragraph
|
||||
}
|
||||
const inline = readInline(html, index, place)
|
||||
if (!inline.ok) return inline
|
||||
const { end, nodes } = inline.value
|
||||
const last = nodes[nodes.length - 1]
|
||||
if (!isBlockStart(html, end) || !html.endsWith('\n', end) || last?.text === undefined) return success({ end, node: { content: nodes, type: 'paragraph' } })
|
||||
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.
|
||||
const text = last.text.slice(0, -1)
|
||||
return success({ end: end - 1, node: { content: [...nodes.slice(0, -1), ...(text === '' ? [] : [{ ...last, text }])], type: 'paragraph' } })
|
||||
return success({ end: end - 1, form: 'bare', node: { content: [...nodes.slice(0, -1), ...(text === '' ? [] : [{ ...last, text }])], type: 'paragraph' } })
|
||||
}
|
||||
|
||||
function isBlockStart(html: string, index: number): boolean {
|
||||
const tag = readStartTag(html, index)
|
||||
return tag !== undefined && blockElements.has(tag.name)
|
||||
return tag !== undefined && blockReaders.has(tag.name)
|
||||
}
|
||||
|
||||
// A node holding `attrs` and `content` only where it holds any, as `adfToHtml` reads an element holding none.
|
||||
|
||||
+10
-15
@@ -2,15 +2,13 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { ParseError, Result } from '../../result.ts'
|
||||
import { heldNode, readCarry } from './carry-element.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { linkMarkFrom, type LinkAttrs } from '../../adf/mark-attributes.ts'
|
||||
import { readStartTag, refused, type Place, type StartTag } from './source.ts'
|
||||
import { linkMarkFrom } from '../../adf/mark-attributes.ts'
|
||||
import { markOfElement } from '../mark-elements.ts'
|
||||
import { readAnchorAttributes } from '../anchor-element.ts'
|
||||
import { readStartTag, refused, type Place, type ReadNode, type StartTag } from './source.ts'
|
||||
import { success } from '../../result.ts'
|
||||
import { unescapedValue } from '../escaping.ts'
|
||||
|
||||
type ReadNode = Result<{ end: number; node: AdfNode }, ParseError>
|
||||
|
||||
const markElements: ReadonlySet<string> = new Set(['code', 'em', 'strong'])
|
||||
|
||||
// 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> {
|
||||
const nodes: AdfNode[] = []
|
||||
@@ -70,14 +68,11 @@ function readText(html: string, index: number, marks: AdfMark[], place: Place):
|
||||
return success({ end, node: marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' } })
|
||||
}
|
||||
|
||||
export function readLink({ attributes }: StartTag): LinkAttrs | undefined {
|
||||
const [href, title, ...others] = attributes
|
||||
if (href?.[0] !== 'href' || (title !== undefined && title[0] !== 'title') || others.length > 0) return undefined
|
||||
return title === undefined ? { href: href[1] } : { href: href[1], title: title[1] }
|
||||
}
|
||||
|
||||
function markOf(tag: StartTag): AdfMark | undefined {
|
||||
if (tag.name !== 'a') return tag.attributes.length === 0 && markElements.has(tag.name) ? { type: tag.name } : undefined
|
||||
const link = readLink(tag)
|
||||
return link === undefined ? undefined : linkMarkFrom(link)
|
||||
if (tag.name === 'a') {
|
||||
const link = readAnchorAttributes(tag.attributes)
|
||||
return link === undefined ? undefined : linkMarkFrom(link)
|
||||
}
|
||||
const type = tag.attributes.length === 0 ? markOfElement(tag.name) : undefined
|
||||
return type === undefined ? undefined : { type }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
|
||||
import { 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 }
|
||||
|
||||
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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user