From 9146850f55a22bccf12dd9bb0d45c75529293a70 Mon Sep 17 00:00:00 2001
From: Lilleman auf Larv
Date: Wed, 28 Oct 2026 01:44:00 +0100
Subject: [PATCH] =?UTF-8?q?Answer=20principles=20round=202:=200=20?=
=?UTF-8?q?=F0=9F=94=B4,=203=20=F0=9F=9F=A1=20(0=20misread);=20the=20docum?=
=?UTF-8?q?ent=20carry's=20two=20clauses=20pinned,=20one=20test=20per=20ca?=
=?UTF-8?q?se,=20the=20escaped=20set=20spelled=20once=20in=20the=20entitie?=
=?UTF-8?q?s=20map,=20UUID=20v7=20ids,=20the=20document=20carry's=20tail?=
=?UTF-8?q?=20from=20carryContent?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/conformance/adf-property.test.ts | 6 +-
src/html/emit/adf-to-html.ts | 3 +-
src/html/escaping.ts | 11 ++-
src/html/parse/html-to-adf.test.ts | 106 +++++++++++++++------------
src/html/parse/html-to-adf.ts | 6 +-
5 files changed, 75 insertions(+), 57 deletions(-)
diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts
index 8b6b825..59602c5 100644
--- a/src/conformance/adf-property.test.ts
+++ b/src/conformance/adf-property.test.ts
@@ -14,12 +14,12 @@ import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to
import { toEditorNormal } from '../markdown/portable/editor-normal.ts'
const gateRuns = 1600
-const htmlPieces = fc.constantFrom('<', '>', '&', '"', '\n', '\r', '\0', '&', '"', ''', '', '
', '', '', '', '
', '', '', '', '', '', ' data-local-id="x"', ' start="2"', '', '

', 'é🎉')
+const htmlPieces = fc.constantFrom('<', '>', '&', '"', '\n', '\r', '\0', '&', '"', ''', '
', '
', '
', '', '
', '
', '
', '', '
', '', ' ', ' data-local-id="01a17eec-5ed2-75cf-904d-f4af6746ac07"', ' start="2"', '', '

', 'é🎉')
// 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 elementAttributes = [' alt=""', ' alt="y"', ' class="language-x"', ' data-local-id="01a17e97-ca82-785e-9333-6e8199f3b098"', ' start="2"', ' title="t"']
const elementInlinePieces = ['\n', '&', '', '
a', '
', '
c', '
a', '

', '
', 'b', 'a']
const elementLines = ['
\na
\n
\n', '
\n', '
\n\n\n', '
\na
\n\n', '
a
\n', '
a\n', '
\n', '

\n', '

\n', '
a
\n', '
a
\n', '
\n']
const elementText = fc.string({ maxLength: 4, minLength: 1, unit: fc.constantFrom('\n', ' ', '"', '&', '<', 'a', 'é') })
@@ -27,7 +27,7 @@ const elementInline = fc.oneof(
{ arbitrary: fc.record({ marks: fc.subarray
([{ type: 'strong' }, { type: 'em' }, { type: 'code' }, { attrs: { href: 'x' }, type: 'link' }]), text: elementText }).map(({ marks, text }): AdfNode => (marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' })), weight: 4 },
{ arbitrary: fc.constant({ type: 'hardBreak' }), weight: 1 },
)
-const localId = fc.record({ localId: fc.constantFrom('a1', 'b2') }, { noNullPrototype: true, requiredKeys: [] })
+const localId = fc.record({ localId: fc.constantFrom('01a17c28-f53a-7ae4-90e8-e3a686cc94e8', '01a17fb9-4022-7162-a34a-627985fb16cc') }, { 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 })
diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts
index 1ab2556..1907b6f 100644
--- a/src/html/emit/adf-to-html.ts
+++ b/src/html/emit/adf-to-html.ts
@@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { anchorOpener } from '../anchor-element.ts'
import { blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock, carryElement } from './carry-element.ts'
+import { carryContent } from '../carry-content.ts'
import { containerElements, headingElement, listStart } from '../block-elements.ts'
import { dataAttributes } from '../data-attributes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -28,7 +29,7 @@ export function adfToHtml(document: AdfDocument): Result {
function writeHtml(document: AdfDocument): Result {
const content = writableContent(document)
if (!content.ok) return content
- if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`)
+ if (content.value === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), carryContent(document, 'div'))}\n`)
const pending: Pending[] = content.value.length === 0 ? [] : [...blockPieces(content.value, [], 0), '\n'].reverse()
let html = ''
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
diff --git a/src/html/escaping.ts b/src/html/escaping.ts
index 51b9c09..1dd6961 100644
--- a/src/html/escaping.ts
+++ b/src/html/escaping.ts
@@ -4,11 +4,14 @@ const entities: ReadonlyMap = new Map([
['<', '<'],
['"', '"'],
])
+const entity = new RegExp([...entities.keys()].join('|'), 'g')
+const escapable = new RegExp(`[${[...entities.values()].join('')}]`, 'g')
+const escapes: ReadonlyMap = new Map([...entities].map(([spelled, character]) => [character, spelled]))
// 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')
-// An `&` opening no entity, a character `escapeHtml` escapes standing raw, or one HTML cannot hold.
-const misspelling = new RegExp(`&(?!${[...entities.keys()].map((entity) => entity.slice(1)).join('|')})|[<>"]|${unwritable.source}`, 'u')
+// A character `escapeHtml` escapes, other than the `&` opening an entity, or one HTML cannot hold.
+const misspelling = new RegExp(`(?!${entity.source})${escapable.source}|${unwritable.source}`, 'u')
// 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 {
@@ -37,7 +40,7 @@ export function shownValue(value: string, lineFeed: string): string {
// The text or attribute value escaped HTML spells, `undefined` where `writableValue` would not write it so.
export function unescapedValue(html: string): string | undefined {
- return misspelledAt(html) < 0 ? html.replace(/&(?:amp|gt|lt|quot);/g, (entity) => entities.get(entity) ?? entity) : undefined
+ return misspelledAt(html) < 0 ? html.replace(entity, (spelled) => entities.get(spelled) ?? spelled) : undefined
}
// `undefined` where HTML cannot hold the value, so its node rides the carry element.
@@ -46,7 +49,7 @@ export function writableValue(value: string): string | undefined {
}
function escapeHtml(text: string): string {
- return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
+ return text.replace(escapable, (character) => escapes.get(character) ?? character)
}
// An astral noncharacter spells as its two surrogates' escapes.
diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts
index 453f0f1..4e50335 100644
--- a/src/html/parse/html-to-adf.test.ts
+++ b/src/html/parse/html-to-adf.test.ts
@@ -21,6 +21,10 @@ function paragraph(...content: AdfNode[]): AdfNode {
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
+function heading(level: number, ...content: AdfNode[]): AdfNode {
+ return content.length === 0 ? { attrs: { level }, type: 'heading' } : { attrs: { level }, content, type: 'heading' }
+}
+
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
@@ -42,6 +46,8 @@ function message(result: Result): string {
}
const documentCarry = carry('div', { type: 'doc', version: 1 }, '')
+const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' }
+const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' }
const rule = JSON.stringify({ type: 'unknownBlock' }).replaceAll('"', '"')
// The carry element of `node`, showing `shown`.
@@ -49,13 +55,18 @@ function carry(tag: 'div' | 'span', node: unknown, shown: string): string {
return `<${tag} class="adf-carry" data-adf="${JSON.stringify(node).replaceAll('"', '"')}">${shown}${tag}>`
}
+function nested(value: unknown, levels: number, wrap: (inner: unknown) => unknown): unknown {
+ let wrapped = value
+ for (let level = 0; level < levels; level += 1) wrapped = wrap(wrapped)
+ return wrapped
+}
+
test('reads an empty string as a document of no blocks, and the document\'s carry as one holding no content key', () => {
assert.deepEqual(read(htmlToAdf('')), document())
assert.deepEqual(read(htmlToAdf(`${documentCarry}\n`)), { type: 'doc', version: 1 })
})
test('reads paragraphs, headings and rules, a line feed after each', () => {
- const heading = (level: number, ...content: AdfNode[]): AdfNode => (content.length === 0 ? { attrs: { level }, type: 'heading' } : { attrs: { level }, content, type: 'heading' })
assert.deepEqual(read(htmlToAdf('Level 1
\nRead the manual.
\n
\nLevel 6
\n')), document(heading(1, text('Level 1')), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6, text('Level 6'))))
assert.deepEqual(read(htmlToAdf('\n\n')), document(paragraph(), heading(2)))
})
@@ -123,21 +134,10 @@ test('reads an image standing alone in a paragraph, a link around it where one m
})
test('reads a carry element from its data-adf, what it shows read back as adfToHtml writes it', () => {
- const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' }
- const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' }
assert.deepEqual(read(htmlToAdf(`${carry('div', panel, 'Check the collation.
')}\nAsk ${carry('span', mention, '@Mikael')}
\n`)), document(panel, paragraph(text('Ask '), mention)))
assert.deepEqual(read(htmlToAdf(`${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')}
\n`)), document(paragraph(text('a\u0085b', { type: 'underline' }))))
})
-test('refuses an edit to what a carry element shows, naming what it found there', () => {
- const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.'))], type: 'panel' }
- const edited = htmlToAdf(`a
\n${carry('div', panel, 'Check the collation.
')}\n`)
- assert.equal(refusal(edited), 'unmappable-html at /content/1 line 2 offset 9')
- assert.match(message(edited), /: found "collation<\/b>\.<\/p><\/d"…$/)
- const mention: AdfNode = { attrs: { id: ids.mention, text: '@Mikael' }, type: 'mention' }
- assert.equal(refusal(htmlToAdf(`Ask ${carry('span', mention, '')}
\n`)), 'unmappable-html at /content/0/content/1 line 1 offset 0')
-})
-
test('reads a carry element holding a node HTML writes as its own element as that node', () => {
assert.deepEqual(read(htmlToAdf(`${carry('div', paragraph(text('x')), 'x
')}\n`)), document(paragraph(text('x'))))
assert.deepEqual(read(htmlToAdf(`a${carry('span', text('b'), 'b')}
\n`)), document(paragraph(text('a'), text('b'))))
@@ -185,6 +185,21 @@ const refusals: Readonly>
['
\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
['a
\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
],
+ 'refuses an edit to what a carry element shows': [
+ [`a
\n${carry('div', panel, 'Check the collation.
')}\n`, 'unmappable-html at /content/1 line 2 offset 9'],
+ [`Ask ${carry('span', mention, '')}
\n`, 'unmappable-html at /content/0/content/1 line 1 offset 0'],
+ ],
+ 'refuses the document\'s carry anywhere but as the whole document, a line feed ending it': [
+ [documentCarry, 'unmappable-html at / line 1 offset 0'],
+ [`${documentCarry}\n\n`, `unmappable-html at / line 2 offset ${documentCarry.length + 1}`],
+ [`${documentCarry}X`, 'unmappable-html at / line 1 offset 0'],
+ [`${documentCarry}X
\n`, 'unmappable-html at / line 1 offset 0'],
+ [`${documentCarry.replace('> ', '>x')}\n`, 'unmappable-html at / line 1 offset 0'],
+ [`${documentCarry}\n
\n`, 'unsupported-node-shape at /content/0 line 1 offset 0'],
+ [`
\n${documentCarry}\n`, 'unsupported-node-shape at /content/1 line 2 offset 5'],
+ [`\n${documentCarry}\n
\n`, 'unsupported-node-shape at /content/0/content/0 line 2 offset 13'],
+ [`${carry('div', { type: 'doc', version: 2 }, '')}\n`, 'unsupported-node-shape at /content/0 line 1 offset 0'],
+ ],
'refuses a data-* attribute out of the order adfToHtml writes as unmappable-html, and one no attribute of its node spells as unsupported-node-shape': [
['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
@@ -245,35 +260,30 @@ for (const [group, cases] of Object.entries(refusals)) {
for (const [html, expected] of cases) test(`${group}: ${JSON.stringify(html)}`, () => assert.equal(refusal(htmlToAdf(html)), expected))
}
-test('refuses a value that is not a string as not-a-string', () => {
- for (const value of [null, 5, undefined, {}]) {
+const notStrings: Readonly> = { null: null, number: 5, object: {}, undefined: undefined }
+
+for (const [kind, value] of Object.entries(notStrings)) {
+ test(`refuses a value that is not a string as not-a-string: ${kind}`, () => {
// @ts-expect-error -- a caller without types can pass anything.
assert.equal(refusal(htmlToAdf(value)), 'not-a-string at / line 1 offset 0')
- }
-})
-
-test('refuses the document\'s carry anywhere but as the whole document, a line feed ending it', () => {
- assert.equal(refusal(htmlToAdf(documentCarry)), 'unmappable-html at / line 1 offset 0')
- assert.equal(refusal(htmlToAdf(`${documentCarry}\n\n`)), `unmappable-html at / line 2 offset ${documentCarry.length + 1}`)
- assert.equal(refusal(htmlToAdf(`${documentCarry.replace('>', '>x')}\n`)), 'unmappable-html at / line 1 offset 0')
- assert.equal(refusal(htmlToAdf(`${documentCarry}\n
\n`)), 'unsupported-node-shape at /content/0 line 1 offset 0')
- assert.equal(refusal(htmlToAdf(`
\n${documentCarry}\n`)), 'unsupported-node-shape at /content/1 line 2 offset 5')
- assert.equal(refusal(htmlToAdf(`\n${documentCarry}\n
\n`)), 'unsupported-node-shape at /content/0/content/0 line 2 offset 13')
- assert.equal(refusal(htmlToAdf(`${carry('div', { type: 'doc', version: 2 }, '')}\n`)), 'unsupported-node-shape at /content/0 line 1 offset 0')
-})
+ })
+}
test('refuses a tag of three million attributes, past what one pattern over them all holds, as a value', () => {
assert.equal(refusal(htmlToAdf(`x
\n`)), 'unmappable-html at /content/0 line 1 offset 0')
})
-test('names what it found where reading stopped, a character no one sees as its escape', () => {
- assert.equal(message(htmlToAdf('ab
\n')), ' as written here is no inline element of the dialect: found "b
\\n"')
- assert.equal(message(htmlToAdf('a > b
\n')), 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found "> b\\n"')
- assert.equal(message(htmlToAdf('a
\n')), ' holds its attributes as name="value", each value escaped as text is, then >: found "b\\">a\\n"')
- assert.equal(message(htmlToAdf('a \u00a0
\n')), 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found " \\u00a0\\n"')
- assert.equal(message(htmlToAdf('a
: found "\na\n')), 'an item\'s line ends at , or at a line feed before the item\'s next block: found "\\n"')
-})
+const messages: readonly (readonly [string, string])[] = [
+ ['ab
\n', ' as written here is no inline element of the dialect: found "b\\n"'],
+ ['a > b
\n', 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found "> b\\n"'],
+ ['a
\n', ' holds its attributes as name="value", each value escaped as text is, then >: found "b\\">a\\n"'],
+ ['a \u00a0
\n', 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found " \\u00a0\\n"'],
+ ['a
: found "\na\n', 'an item\'s line ends at , or at a line feed before the item\'s next block: found "\\n"'],
+ [`a
\n${carry('div', panel, 'Check the collation.
')}\n`, 'a carry element shows its node\'s text as adfToHtml writes it, then ends at ; an edit to it is no edit to the node: found "collation. assert.equal(message(htmlToAdf(html)), expected))
test('reads blocks nested 500 levels, and refuses the next level, a list\'s items one level below it', () => {
const quotes = (levels: number): string => `${'\n'.repeat(levels)}x
${'\n
'.repeat(levels)}\n`
@@ -286,22 +296,24 @@ test('reads blocks nested 500 levels, and refuses the next level, a list\'s item
})
test('refuses a carried node whose JSON nests past the levels its depth leaves, and an inline carry past 500', () => {
- let value: unknown = 1
- for (let level = 0; level < 498; level += 1) value = [value]
- const carried = carry('div', { attrs: { a: value }, type: 'unknownBlock' }, '')
+ const carried = carry('div', { attrs: { a: nested(1, 498, (inner) => [inner]) }, type: 'unknownBlock' }, '')
assert.equal(refusal(htmlToAdf(`${carried}\n`)), 'read')
assert.equal(refusal(htmlToAdf(`\n${carried}\n
\n`)), 'unsupported-nesting-depth at /content/0/content/0 line 2 offset 13')
- let inline: unknown = { text: 'x', type: 'text' }
- for (let level = 0; level < 500; level += 1) inline = { content: [inline], type: 'unknownInline' }
+ const inline = nested({ text: 'x', type: 'text' }, 500, (inner) => ({ content: [inner], type: 'unknownInline' }))
assert.equal(refusal(htmlToAdf(`${carry('span', inline, '')}
\n`)), 'unsupported-nesting-depth at /content/0/content/0 line 1 offset 0')
})
-test('reads back what adfToHtml writes for the documents its own tests write', () => {
- const bare: AdfDocument = { type: 'doc', version: 1 }
- const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Run this:')), { attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }], type: 'panel' }
- for (const written of [document(panel), document(paragraph(text('a'), text('b'), text('c', { type: 'em' }), text('d'), text('e'))), bare]) {
- const html = adfToHtml(written)
+// The documents adfToHtml's own tests write, by what each holds.
+const written: Readonly> = {
+ 'a panel holding a code block': document({ attrs: { panelType: 'info' }, content: [paragraph(text('Run this:')), { attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }], type: 'panel' }),
+ 'adjacent unmarked text': document(paragraph(text('a'), text('b'), text('c', { type: 'em' }), text('d'), text('e'))),
+ 'no content key': { type: 'doc', version: 1 },
+}
+
+for (const [holding, adf] of Object.entries(written)) {
+ test(`reads back what adfToHtml writes for a document holding ${holding}`, () => {
+ const html = adfToHtml(adf)
assert.ok(html.ok)
- assert.deepEqual(read(htmlToAdf(html.value)), written)
- }
-})
+ assert.deepEqual(read(htmlToAdf(html.value)), adf)
+ })
+}
diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts
index 56d27f3..803654b 100644
--- a/src/html/parse/html-to-adf.ts
+++ b/src/html/parse/html-to-adf.ts
@@ -3,6 +3,7 @@ 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 { carryContent } from '../carry-content.ts'
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
import { containerElements, headingElements, headingLevel, readListStart, type ContainerType } from '../block-elements.ts'
import { firstParagraphForm } from '../first-paragraph.ts'
@@ -42,6 +43,7 @@ const blockReaders: ReadonlyMap = new Map([
])
const codeCloser = ''
const documentCarryAlone = 'a carry element holding the document stands alone, as the whole document'
+const documentCarryEnd = `${carryContent({ type: 'doc' }, 'div')}`
const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact')
const formRefusals: Readonly> = {
bare: "an item's first paragraph holding content and no attribute stands bare on the item's line: Bolt",
@@ -67,8 +69,8 @@ function readHtml(html: string): Result {
function readDocumentCarry(html: string): Result | undefined {
const tag = readStartTag(html, 0)
if (tag?.name !== 'div' || readCarryAttributes(tag.attributes) !== emptyDocument) return undefined
- if (!html.startsWith('', tag.end)) return refused('unmappable-html', `a carry element holding the document shows nothing: ${foundAt(html, tag.end)}`, html, tag.end, undefined)
- const end = tag.end + ''.length
+ if (!html.startsWith(documentCarryEnd, tag.end)) return refused('unmappable-html', `a carry element holding the document shows nothing: ${foundAt(html, tag.end)}`, html, tag.end, undefined)
+ const end = tag.end + documentCarryEnd.length
if (html.startsWith('\n', end) && isBlockStart(html, end + 1)) return refused('unsupported-node-shape', documentCarryAlone, html, 0, { index: 0, parent: undefined })
return end === html.length - 1 && html.endsWith('\n') ? success({ type: 'doc', version: 1 }) : partedRefusal(html, end, undefined)
}