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 = ['
\na
\n
\n', '
\n', '\n\n\n', '\na
\n\n', 'a
\n', 'a\n', '\n', '
\n', '
\n', 'a
\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${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 {
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}${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.
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: `${element}>`, 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 = `${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 {
+ 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 = `${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 {
+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.