From e39ca86d74aee8d431213acf5326b832eb7188dd Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 26 Oct 2026 22:46:02 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20architecture=20round=201:=202=20?= =?UTF-8?q?=F0=9F=94=B4,=206=20=F0=9F=9F=A1=20(0=20misread);=20carried=20J?= =?UTF-8?q?SON=20read=20through=20adf/,=20one=20spelling=20home=20per=20el?= =?UTF-8?q?ement=20in=20html/,=20block=20readers=20in=20one=20table,=20the?= =?UTF-8?q?=20first=20paragraph's=20form=20shared,=20a=20lint=20boundary?= =?UTF-8?q?=20for=20html/parse,=20a=20strictness=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .oxlintrc.json | 6 + README.md | 2 + ci.sh | 2 + docs/decisions.md | 11 +- spec/html.md | 4 +- src/adf/carried-json.ts | 30 +++- src/canonical-json.test.ts | 6 + src/canonical-json.ts | 7 +- src/conformance/adf-property.test.ts | 90 +++++++++++- src/html/anchor-element.ts | 15 ++ src/html/carry-attributes.ts | 14 ++ src/html/emit/adf-to-html.ts | 21 ++- src/html/emit/carry-element.ts | 7 +- src/html/emit/inline.ts | 12 +- src/html/escaping.ts | 22 --- src/html/first-paragraph.ts | 15 ++ src/html/image-element.ts | 16 +++ src/html/language-class.ts | 19 +++ src/html/mark-elements.ts | 15 ++ src/html/parse/carry-element.ts | 46 +++--- src/html/parse/html-to-adf.ts | 202 +++++++++++++++++---------- src/html/parse/inline.ts | 25 ++-- src/html/parse/source.ts | 3 + src/markdown/opaque-carry.ts | 25 ++-- 24 files changed, 425 insertions(+), 190 deletions(-) create mode 100644 src/html/anchor-element.ts create mode 100644 src/html/carry-attributes.ts create mode 100644 src/html/first-paragraph.ts create mode 100644 src/html/image-element.ts create mode 100644 src/html/language-class.ts create mode 100644 src/html/mark-elements.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 32f6278..a9d4558 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -15,6 +15,12 @@ "no-restricted-imports": ["error", { "patterns": [{ "group": ["**/markdown/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }] } }, + { + "files": ["src/html/parse/**"], + "rules": { + "no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "**/markdown/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }] + } + }, { "files": ["src/markdown/**"], "rules": { diff --git a/README.md b/README.md index 3118c69..009ae4c 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,8 @@ content. No stylesheet ships. The complete dialect: `htmlToAdf` reads exactly that HTML back, so `htmlToAdf(adfToHtml(doc))` deep-equals `doc`. A carry element reads from its `data-adf` alone, so editing what it shows doesn't change the document read. +The exception is an edit adding the element's own closing tag, `` or ``, which ends the +element there. Any other HTML is refused as `unmappable-html`: another element, attribute or escape, such as ``, `
` or `'`. diff --git a/ci.sh b/ci.sh index c6ff1dd..2cf5ed1 100755 --- a/ci.sh +++ b/ci.sh @@ -11,6 +11,8 @@ leg "lint ($node_image)" in_image "$node_image" npm run lint boundary_probes="src/adf/boundary-probe.ts ../markdown/flavour.ts src/adf/boundary-probe-html.ts ../html/emit/adf-to-html.ts src/html/boundary-probe.ts ../markdown/flavour.ts +src/html/parse/boundary-probe.ts ../emit/adf-to-html.ts +src/html/parse/boundary-probe-markdown.ts ../../markdown/flavour.ts src/markdown/boundary-probe-html.ts ../html/emit/adf-to-html.ts src/markdown/directive/boundary-probe.ts ../parse/blocks.ts src/markdown/directive/boundary-probe-emit.ts ../emit/image.ts diff --git a/docs/decisions.md b/docs/decisions.md index ef2a84d..883d68d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1013,10 +1013,11 @@ consults it rather than restating it, so no fixture the emitter writes can be re spelling the emitter refuses gives its own error rather than a second name for it. That consulting is the only import `parse/` takes from `emit/`, through `emit/spelling-questions.ts`. -`.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `parse/` and `emit/` from -importing `markdown/portable/`, keeps `markdown/directive/` from importing `emit/` or `parse/`, -keeps `markdown/` and `html/` from importing each other, and keeps `adf/` from importing either, as -`no-restricted-imports` overrides. A later override replaces an earlier one's rule, so each -`markdown/` subdirectory's override repeats the ban on `html/`, and `ci.sh` probes every override. They are lint rules because the technical principles refuse a +`.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `html/parse/` from importing +`html/emit/`, keeps `parse/` and `emit/` from importing `markdown/portable/`, keeps +`markdown/directive/` from importing `emit/` or `parse/`, keeps `markdown/` and `html/` from +importing each other, and keeps `adf/` from importing either, as `no-restricted-imports` overrides. +A later override replaces an earlier one's rule, so each subdirectory's override repeats its +format's ban on the other format, and `ci.sh` probes every override. They are lint rules because the technical principles refuse a test that scans the source, and a lint rule reads every import spelling: `from`, a bare `import`, double quotes and a dynamic `import()`. diff --git a/spec/html.md b/spec/html.md index de29dae..3897f6a 100644 --- a/spec/html.md +++ b/spec/html.md @@ -120,7 +120,9 @@ later release writes it as its own element. ## Reading `htmlToAdf` reads exactly the HTML above, so `htmlToAdf(adfToHtml(doc))` deep-equals `doc`. A carry -element reads from its `data-adf` alone, so editing what it shows doesn't change the document read. +element reads from its `data-adf` alone, and ends at the first closing tag of its own name, `` +or ``. So editing what it shows doesn't change the document read, unless the edit adds that +closing tag. - Any other HTML is `unmappable-html`: another element, attribute or escape, attributes in another order, a block on the line of another, or no line feed at the end. diff --git a/src/adf/carried-json.ts b/src/adf/carried-json.ts index 2b2f22a..96bd59f 100644 --- a/src/adf/carried-json.ts +++ b/src/adf/carried-json.ts @@ -1,17 +1,37 @@ import type { AdfNode } from './document.ts' import type { JsonSpelling } from '../canonical-json.ts' +import type { JsonValue } from '../json-value.ts' import { canonicalJsonPieces } from '../canonical-json.ts' -import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' -import { isJsonValue, nestingDepth } from '../json-value.ts' +import { faulted, success, type ConvertErrorPath, type ConvertFault, type Read, type Result } from '../result.ts' +import { isAdfNode } from './document.ts' +import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { joinedWithin } from '../length-bound.ts' +import { parseJsonText } from '../json-text.ts' // `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) - if (!isJsonValue(spelled) || headroom < 0) { - return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) - } + if (!isJsonValue(spelled) || headroom < 0) return faulted(nestedPast(levels), path) // Two-space JSON indents each value by its depth, so a short compact value can spell past the bound: join only up to it. const json = joinedWithin(canonicalJsonPieces(spelled, spelling)) return json.ok ? success({ headroom, json: json.value }) : json } + +// The read inverse of `carriedJson` takes three steps, value, nesting and node, so each format's own refusals keep their place between them; `invalid` is the format's refusal of text that is no JSON, and `holder` names the carry in a message. +export function carriedValue(json: string, holder: string, invalid: ConvertFault): Read { + const parsed = parseJsonText(json) + if (parsed.refusal === undefined) return { value: parsed.value } + 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 carriedNode(held: JsonValue, holder: string): Read { + return isAdfNode(held) ? { value: held } : { fault: { code: 'unsupported-node-shape', message: `${holder} holds one ADF node's JSON: this JSON is no ADF node` } } +} + +function nestedPast(levels: number): ConvertFault { + return { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } +} diff --git a/src/canonical-json.test.ts b/src/canonical-json.test.ts index a06af18..e284d91 100644 --- a/src/canonical-json.test.ts +++ b/src/canonical-json.test.ts @@ -62,3 +62,9 @@ test('holds a text to the canonical spelling, whole', () => { assert.ok(!spellsCanonicalJson(value, 'two-space', '{"a":[1,2]}')) assert.ok(!spellsCanonicalJson(1e21, 'compact', '1e21')) }) + +test('holds a text to the canonical spelling with each piece respelled', () => { + const upper = (piece: string): string => piece.toUpperCase() + assert.ok(spellsCanonicalJson({ a: 'b' }, 'compact', '{"A":"B"}', upper)) + assert.ok(!spellsCanonicalJson({ a: 'b' }, 'compact', '{"a":"b"}', upper)) +}) diff --git a/src/canonical-json.ts b/src/canonical-json.ts index f6909f9..55e9fdf 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -10,10 +10,11 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling) return [...canonicalJsonPieces(value, spelling)].join('') } -// Stops at the first piece that does not match `text`: re-spelled with two-space indentation, a short compact text can pass the engine's longest string. -export function spellsCanonicalJson(value: JsonValue, spelling: JsonSpelling, text: string): boolean { +// Stops at the first piece, passed through `respell`, that does not match `text`: re-spelled with two-space indentation, a short compact text can pass the engine's longest string. +export function spellsCanonicalJson(value: JsonValue, spelling: JsonSpelling, text: string, respell = (canonical: string): string => canonical): boolean { let offset = 0 - for (const piece of canonicalJsonPieces(value, spelling)) { + for (const canonical of canonicalJsonPieces(value, spelling)) { + const piece = respell(canonical) if (!text.startsWith(piece, offset)) return false offset += piece.length } diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index ee52841..8b6b825 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -2,11 +2,12 @@ import assert from 'node:assert/strict' import fc from 'fast-check' import test from 'node:test' -import type { AdfNode } from '../adf/document.ts' +import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts' import { adfDocument, blockTaskItemChildren, propertyRuns, propertyTimeout } from './property-harness.ts' import { adfToHtml } from '../html/emit/adf-to-html.ts' import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts' import { adfToPortableMarkdown, reduceToPortable } from '../markdown/portable/adf-to-portable-markdown.ts' +import { centeredImage, externalMedia } from '../adf/plain-image.ts' import { htmlToAdf } from '../html/parse/html-to-adf.ts' import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to-adf.ts' @@ -17,6 +18,73 @@ const htmlPieces = fc.constantFrom('<', '>', '&', '"', '\n', '\r', '\0', '&' // What HTML text cannot hold (WHATWG HTML §13.1.3), and the line feed a carry element shows as a line break. const unshown = /\n|(?![\t\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/gu +const depthIdentifier = fc.createDepthIdentifier() +const elementAttributes = [' alt=""', ' alt="y"', ' class="language-x"', ' data-local-id="x"', ' start="2"', ' title="t"'] +const elementInlinePieces = ['\n', '&', '

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

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

\n

a

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

    \n
  • \n', '
  • \n

    a

    \n
  • \n', '
  • a

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

    \n', '

    \n', '

    \n', '

    a

    \n', '
    a
    \n', '
      \n
    • a
    • \n
    \n'] +const elementText = fc.string({ maxLength: 4, minLength: 1, unit: fc.constantFrom('\n', ' ', '"', '&', '<', 'a', 'é') }) +const elementInline = fc.oneof( + { arbitrary: fc.record({ marks: fc.subarray([{ type: 'strong' }, { type: 'em' }, { type: 'code' }, { attrs: { href: 'x' }, type: 'link' }]), text: elementText }).map(({ marks, text }): AdfNode => (marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' })), weight: 4 }, + { arbitrary: fc.constant({ type: 'hardBreak' }), weight: 1 }, +) +const localId = fc.record({ localId: fc.constantFrom('a1', 'b2') }, { noNullPrototype: true, requiredKeys: [] }) +// The nodes `adfToHtml` writes as their own elements, so a document holding these alone writes no carry element, short of adjacent unmarked text. +const { elementBlock } = fc.letrec<{ elementBlock: AdfNode }>((tie) => { + const blocks = fc.array(tie('elementBlock'), { depthIdentifier, maxLength: 3 }) + const inline = fc.array(elementInline, { maxLength: 3 }) + const items = fc.array(fc.record({ attrs: localId, content: blocks }).map(({ attrs, content }) => element('listItem', attrs, content)), { depthIdentifier, maxLength: 3 }) + const language = fc.record({ language: fc.constantFrom('c', 'js') }, { noNullPrototype: true, requiredKeys: [] }) + const order = fc.record({ order: fc.integer({ max: 3, min: -1 }) }, { noNullPrototype: true, requiredKeys: [] }) + return { + elementBlock: fc.oneof( + { depthIdentifier, depthSize: 'small', maxDepth: 3 }, + { arbitrary: fc.record({ attrs: localId, content: inline }).map(({ attrs, content }) => element('paragraph', attrs, content)), weight: 6 }, + fc.record({ attrs: localId, content: inline, level: fc.integer({ max: 6, min: 1 }) }).map(({ attrs, content, level }) => element('heading', { ...attrs, level }, content)), + fc.record({ attrs: language, text: fc.option(elementText, { nil: undefined }) }).map(({ attrs, text }) => element('codeBlock', attrs, text === undefined ? [] : [{ text, type: 'text' }])), + localId.map((attrs) => element('rule', attrs, [])), + fc.record({ alt: fc.option(elementText, { nil: undefined }), linked: fc.boolean(), url: elementText }).map(({ alt, linked, url }) => centeredImage([externalMedia(alt ?? '', url, linked ? { href: 'x' } : undefined)])), + { arbitrary: fc.record({ attrs: localId, content: blocks }).map(({ attrs, content }) => element('blockquote', attrs, content)), weight: 2 }, + { arbitrary: fc.record({ attrs: localId, content: items }).map(({ attrs, content }) => element('bulletList', attrs, content)), weight: 2 }, + { arbitrary: fc.record({ attrs: order, content: items }).map(({ attrs, content }) => element('orderedList', attrs, content)), weight: 2 }, + ), + } +}) +const elementDocument = fc.array(elementBlock, { maxLength: 4 }).map((content): AdfDocument => ({ content, type: 'doc', version: 1 })) + +// A node holding `attrs` and `content` only where it holds any, as a node `adfToHtml` writes as its element. +function element(type: string, attrs: AdfAttributes, content: AdfNode[]): AdfNode { + return { ...(Object.keys(attrs).length === 0 ? {} : { attrs }), ...(content.length === 0 ? {} : { content }), type } +} + +// An attribute goes inside a start tag, a line at the start of one, and any other piece between two of the HTML's tags, text runs and line feeds, in place of up to `removed` of them. +function edited(html: string, [at, removed, piece]: readonly [number, number, string]): string { + if (piece.startsWith(' ')) { + const tags = [...html.matchAll(/<[a-z][^>]*>/g)] + const tag = tags[at % Math.max(tags.length, 1)] + if (tag === undefined) return html + const end = tag.index + tag[0].length - 1 + return `${html.slice(0, end)}${piece}${html.slice(end)}` + } + const tokens = html.split(/(<[^>]*>|\n)/).filter((token) => token !== '') + if (!elementLines.includes(piece)) { + tokens.splice(at % (tokens.length + 1), removed, piece) + return tokens.join('') + } + const lineStarts = [0, ...tokens.flatMap((token, index) => (token === '\n' ? [index + 1] : []))] + tokens.splice(lineStarts[at % lineStarts.length] ?? 0, 0, piece) + return tokens.join('') +} + +// Without a carry element, HTML spells the document it reads one way, so writing that document gives back the very HTML read. +function assertReadsBack(html: string): void { + const read = htmlToAdf(html) + if (!read.ok) return + const rewritten = adfToHtml(read.value) + assert.ok(rewritten.ok, rewritten.ok ? '' : `${rewritten.error.code}: ${rewritten.error.message} — reading ${JSON.stringify(html)}`) + if (!html.includes('class="adf-carry"')) assert.equal(rewritten.value, html, `reading ${JSON.stringify(html)}`) + assert.deepEqual(htmlToAdf(rewritten.value), { ok: true, value: read.value }, `reading ${JSON.stringify(html)}`) +} + // Every text node's text in document order. function documentTexts(nodes: readonly AdfNode[]): string[] { const texts: string[] = [] @@ -103,7 +171,7 @@ test('a generated document writes HTML that reads back to it', { timeout: proper ) }) -test('HTML a generated document writes, edited, reads to a result, and every document read writes HTML reading back to it', { timeout: propertyTimeout }, () => { +test('HTML a generated document writes, edited, reads to a result, and every document read writes HTML reading back to it, the very HTML read where it holds no carry element', { timeout: propertyTimeout }, () => { const edits = fc.array(fc.tuple(fc.nat(), fc.nat({ max: 3 }), htmlPieces), { maxLength: 4, minLength: 1 }) fc.assert( fc.property(adfDocument, edits, (document, cuts) => { @@ -111,11 +179,19 @@ test('HTML a generated document writes, edited, reads to a result, and every doc assert.ok(written.ok) let html = written.value for (const [at, removed, piece] of cuts) html = `${html.slice(0, at % (html.length + 1))}${piece}${html.slice((at % (html.length + 1)) + removed)}` - const read = htmlToAdf(html) - if (!read.ok) return - const rewritten = adfToHtml(read.value) - assert.ok(rewritten.ok, rewritten.ok ? '' : `${rewritten.error.code}: ${rewritten.error.message} — reading ${JSON.stringify(html)}`) - assert.deepEqual(htmlToAdf(rewritten.value), { ok: true, value: read.value }, `reading ${JSON.stringify(html)}`) + assertReadsBack(html) + }), + propertyRuns(gateRuns), + ) +}) + +test('HTML a document of the dialect\'s own elements writes, edited between its tags, reads only as the very HTML its document read writes', { timeout: propertyTimeout }, () => { + const edits = fc.array(fc.tuple(fc.nat(), fc.nat({ max: 1 }), fc.constantFrom(...elementAttributes, ...elementInlinePieces, ...elementLines)), { maxLength: 2, minLength: 1 }) + fc.assert( + fc.property(elementDocument, edits, (document, cuts) => { + const written = adfToHtml(document) + assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`) + assertReadsBack(cuts.reduce(edited, written.value)) }), propertyRuns(gateRuns), ) diff --git a/src/html/anchor-element.ts b/src/html/anchor-element.ts new file mode 100644 index 0000000..c2c6d46 --- /dev/null +++ b/src/html/anchor-element.ts @@ -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 : `` +} + +// The link an ``'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] } +} diff --git a/src/html/carry-attributes.ts b/src/html/carry-attributes.ts new file mode 100644 index 0000000..b8859fd --- /dev/null +++ b/src/html/carry-attributes.ts @@ -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 +} diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index 6bb9fb5..d12645c 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -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`] } -// 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 { if (node.content === undefined) return success([``]) const blocks = blockPieces(node.content, path, depth) @@ -111,11 +113,11 @@ function writeItem({ attributes, depth, node, path }: Item): Result { return success(blocks.length === 1 ? [`${line.value}`] : [`${line.value}`, ...blocks.slice(1), '\n']) } -// `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 | 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 | 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 = `` + const element = image === undefined ? undefined : imageElement(image) + if (image === undefined || element === undefined) return undefined if (image.link === undefined) return `

    ${element}

    ` const anchor = anchorOpener(image.link) return anchor === undefined ? undefined : `

    ${anchor}${element}

    ` diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts index d42b5d4..d124fb5 100644 --- a/src/html/emit/carry-element.ts +++ b/src/html/emit/carry-element.ts @@ -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${content}` + return `<${tag}${carryAttributes(json)}>${content}` } // 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. diff --git a/src/html/emit/inline.ts b/src/html/emit/inline.ts index eb0cd64..4294852 100644 --- a/src/html/emit/inline.ts +++ b/src/html/emit/inline.ts @@ -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 = new Map([ - ['code', 'code'], - ['em', 'em'], - ['strong', 'strong'], -]) +import { writableValue } from '../escaping.ts' export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result { 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: ``, opener: `<${element}>` } : undefined if (type !== 'link' || Object.keys(nodeAttrs(mark)).some((key) => key !== 'href' && key !== 'title')) return undefined const link = linkAttrs(nodeAttrs(mark)) diff --git a/src/html/escaping.ts b/src/html/escaping.ts index 5c4a7eb..5ad8e8a 100644 --- a/src/html/escaping.ts +++ b/src/html/escaping.ts @@ -1,29 +1,13 @@ -import type { JsonValue } from '../json-value.ts' -import type { LinkAttrs } from '../adf/mark-attributes.ts' - const entities: ReadonlyMap = 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 : `` -} - -// The language a ``'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 '' diff --git a/src/html/first-paragraph.ts b/src/html/first-paragraph.ts new file mode 100644 index 0000000..d8023bf --- /dev/null +++ b/src/html/first-paragraph.ts @@ -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' +} diff --git a/src/html/image-element.ts b/src/html/image-element.ts new file mode 100644 index 0000000..30d4f7f --- /dev/null +++ b/src/html/image-element.ts @@ -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 : `` +} + +// The `src` and `alt` an ``'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] } +} diff --git a/src/html/language-class.ts b/src/html/language-class.ts new file mode 100644 index 0000000..abd524e --- /dev/null +++ b/src/html/language-class.ts @@ -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 ``'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 +} diff --git a/src/html/mark-elements.ts b/src/html/mark-elements.ts new file mode 100644 index 0000000..bbc15b9 --- /dev/null +++ b/src/html/mark-elements.ts @@ -0,0 +1,15 @@ +// The element each mark but a link writes around text, holding no attribute; a link writes ``. +const markElements: ReadonlyMap = 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 +} diff --git a/src/html/parse/carry-element.ts b/src/html/parse/carry-element.ts index 9107071..fa33b1b 100644 --- a/src/html/parse/carry-element.ts +++ b/src/html/parse/carry-element.ts @@ -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 = `` 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 { + 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 { - 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) } diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index b821de8..7386b70 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -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 + +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 = 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 = 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 = '' const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact') +const formRefusals: Readonly> = { + bare: "an item's first paragraph holding content and no attribute stands bare on the item's line:
  • Bolt
  • ", + 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

    on the item's line, straight after

  • ", +} // 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 { @@ -34,7 +62,16 @@ export function htmlToAdf(html: string): Result { function readHtml(html: string): Result { 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 { 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 { } } -function readBlock(html: string, index: number, place: Place, open: Open): Result { +function readBlock(html: string, index: number, place: Place, open: Open): Result { const tag = readStartTag(html, index) if (tag === undefined) return refused('unmappable-html', `a block element of the dialect stands here: ${foundAt(html, index)}`, html, index, place) - const 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 { +function asStep(read: ReadNode): Result { 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 { + 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 { 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
  • ", 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 `

    `, 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 ? '

    ' : '

    ' - 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 ? '

    ' : '

    ' + if (node === undefined || !html.startsWith(closer, image.end)) { return refused('unmappable-html', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside 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 ``, and the `` 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 { 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
    , its text escaped, then 
    ', 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 `` at `index`, through the `` after it; `undefined` where `adfToHtml` writes no such elements. +function readCode(html: string, index: number): { end: number; language: string | undefined; text: string } | undefined { + const code = readStartTag(html, index) + const 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('', end)) { - return refused('unmappable-html', 'a code block is
    , its text escaped, then 
    ', html, tag.end, place) - } - const attrs = language === undefined ? data.value : { ...data.value, language } - return success({ end: end + '
    '.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 { 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 { - const carry = readCarry(html, tag, largestNesting - depth, place) +function readBlockCarry(html: string, tag: StartTag, place: Place, open: Open): Result { + 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 { +function readContainer(html: string, tag: StartTag, place: Place, open: Open): Result { 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 = `` 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 { +function readItem(html: string, index: number, place: Place, depth: number): Result { const tag = readStartTag(html, index) if (tag?.name !== 'li') return refused('unmappable-html', `a list holds
  • items, one to a line: ${foundAt(html, index)}`, html, index, place) 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('
  • ', tag.end)) return success({ end: tag.end + ''.length, kind: 'node', node: item }) const open: Open = { closer: '\n', 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 { + 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('', end)) return success({ end: end + ''.length, kind: 'node', node: { ...item, content: [node] } }) - if (!html.startsWith('\n', end)) return refused('unmappable-html', `an item's line ends at , or at a line feed before the item's next block: ${foundAt(html, end)}`, html, end, place) + if (!html.startsWith('', end) && !html.startsWith('\n', end)) { + return refused('unmappable-html', `an item's line ends at , 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('', end)) return success({ end: end + ''.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 `` 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 `` 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:
  • Bolt
  • ", 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. diff --git a/src/html/parse/inline.ts b/src/html/parse/inline.ts index c4269ac..d77081b 100644 --- a/src/html/parse/inline.ts +++ b/src/html/parse/inline.ts @@ -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 = 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 } } diff --git a/src/html/parse/source.ts b/src/html/parse/source.ts index 4beb3af..c500459 100644 --- a/src/html/parse/source.ts +++ b/src/html/parse/source.ts @@ -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 } diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index e290f28..9cb0330 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -2,14 +2,11 @@ import type { AdfNode } from '../adf/document.ts' import type { DirectiveSpan } from './directive/syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' import type { JsonValue } from '../json-value.ts' -import { carriedJson } from '../adf/carried-json.ts' +import { carriedJson, carriedNesting, carriedNode, carriedValue } from '../adf/carried-json.ts' import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive/syntax.ts' import { fencedCodeBlock } from './commonmark/backtick-runs.ts' import { infoStringCarries } from './commonmark/grammar.ts' -import { isAdfNode } from '../adf/document.ts' import { largestNesting } from '../nesting.ts' -import { overNested } from '../json-value.ts' -import { parseJsonText } from '../json-text.ts' import { spellsCanonicalJson } from '../canonical-json.ts' import { success, type ConvertErrorPath, type Read, type Result } from '../result.ts' @@ -17,6 +14,7 @@ export const carryFencePrefix = 'adf:' export const carryName = 'carry' +const carryHolder = 'the opaque carry' const jsonAttribute = 'json' // spec/flavour.md, The opaque carry: the info string names the type wherever it carries the type back. @@ -57,23 +55,18 @@ export function readCarriedInline(span: DirectiveSpan): Read | undefine // `type` is the one the fence's info string names, `undefined` for the inline carry. function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read { const wayOut = type === undefined ? directiveEscape : fenceEscape(type) - const parsed = parseJsonText(raw) - if (parsed.refusal !== undefined) { - return { fault: parsed.refusal === 'syntax' ? malformedDirective(`the opaque carry holds invalid JSON; ${wayOut}`) : unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') } - } + const parsed = carriedValue(raw, carryHolder, malformedDirective(`${carryHolder} holds invalid JSON; ${wayOut}`)) + if (parsed.fault !== undefined) return parsed const { value } = parsed const typed = type === undefined || type === '' ? { value } : typedValue(value, type) if (typed.fault !== undefined) return typed - const held = typed.value - if (overNested(held, levels)) { - return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } } - } + const nested = carriedNesting(typed.value, levels) + if (nested !== undefined) return { fault: nested } if (!spellsCanonicalJson(value, spelling, raw)) { - const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' - return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}; ${wayOut}`) } + return { fault: unsupportedNodeShape(`${carryHolder} spells its node's JSON canonically: ${spelling === 'compact' ? 'compact' : 'two-space indent'}, keys sorted; ${wayOut}`) } } - if (!isAdfNode(held)) return { fault: unsupportedNodeShape(`the opaque carry holds one ADF node's JSON: this JSON is no ADF node; ${wayOut}`) } - return { value: held } + const node = carriedNode(typed.value, carryHolder) + return node.fault === undefined ? node : { fault: { ...node.fault, message: `${node.fault.message}; ${wayOut}` } } } // A code fence the carry claims stays code under the directive, whose language rides the attribute.