3
0
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:
2026-10-26 22:46:02 +01:00
committed by Lillem4n
parent 51e37f68df
commit e39ca86d74
24 changed files with 425 additions and 190 deletions
+15
View File
@@ -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] }
}
+14
View File
@@ -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
}
+10 -11
View File
@@ -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>`
+5 -2
View File
@@ -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.
+4 -8
View File
@@ -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))
-22
View File
@@ -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([
['&amp;', '&'],
['&gt;', '>'],
['&lt;', '<'],
['&quot;', '"'],
])
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 ''
+15
View File
@@ -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'
}
+16
View File
@@ -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] }
}
+19
View File
@@ -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
}
+15
View File
@@ -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
}
+25 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
+3
View File
@@ -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 }