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