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