From ee41a9db70fcc09498fe84452c9261e3fdecfbd7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 4 Nov 2026 13:51:05 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=205=20=F0=9F=9F=A1=20(0=20misread);=20name=20the?= =?UTF-8?q?=20span=20classes=20in=20the=20carry=20refusal,=20call=20the=20?= =?UTF-8?q?shown-text=20attribute=20textAttribute,=20pin=20the=20content,?= =?UTF-8?q?=20whitespace=20and=20wrong-element=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/emit/adf-to-html.test.ts | 1 + src/html/emit/node-element.ts | 4 ++-- src/html/node-element.ts | 16 ++++++++++------ src/html/parse/carry-element.ts | 7 ++++++- src/html/parse/html-to-adf.test.ts | 8 +++++++- src/html/parse/node-element.ts | 6 +++--- 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts index 51cf4b2..175b1f8 100644 --- a/src/html/emit/adf-to-html.test.ts +++ b/src/html/emit/adf-to-html.test.ts @@ -159,6 +159,7 @@ test('carries an emoji, status, date or card holding a URL the element cannot ho assert.equal(carried({ attrs: { size: 'big', text: '🎉' }, type: 'emoji' }), true) assert.equal(carried({ attrs: { id: 5, text: '🎉' }, type: 'emoji' }), true) assert.equal(carried({ attrs: { text: '🎉' }, content: [], type: 'emoji' }), true) + assert.equal(carried({ attrs: { text: '🎉' }, content: [text('x')], type: 'emoji' }), true) assert.equal(carried({ attrs: { color: 'red' }, type: 'status' }), true) assert.equal(carried({ attrs: { timestamp: 'soon' }, type: 'date' }), true) assert.equal(carried({ attrs: { timestamp: 1756080000000 }, type: 'date' }), true) diff --git a/src/html/emit/node-element.ts b/src/html/emit/node-element.ts index 90868c2..94d454c 100644 --- a/src/html/emit/node-element.ts +++ b/src/html/emit/node-element.ts @@ -9,8 +9,8 @@ import { writableValue } from '../escaping.ts' export function nodeElement(node: AdfNode): string | undefined { const model = elementOfNodeType(node.type) if (model === undefined || node.content !== undefined) return undefined - const data = dataAttributes(node, model.vocabulary, model.content === undefined ? [] : [model.content]) - const held = model.content === undefined ? htmlDay(shownText(node)) : nodeAttrs(node)[model.content] + const data = dataAttributes(node, model.vocabulary, model.textAttribute === undefined ? [] : [model.textAttribute]) + const held = model.textAttribute === undefined ? htmlDay(shownText(node)) : nodeAttrs(node)[model.textAttribute] const shown = typeof held === 'string' && held !== '' ? writableValue(held) : undefined if (data === undefined || shown === undefined) return undefined const leading = model.leading === undefined ? '' : ` ${model.leading}="${shown}"` diff --git a/src/html/node-element.ts b/src/html/node-element.ts index a589ff3..81df01f 100644 --- a/src/html/node-element.ts +++ b/src/html/node-element.ts @@ -1,27 +1,31 @@ import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import { inlineNodeModel } from '../adf/inline-nodes.ts' -// `content` names the attribute holding the shown text, none for a date, which shows the day its `timestamp` falls on; `leading` names the attribute right after the class, holding that same text. +// `textAttribute` names the attribute holding the shown text, none for a date, which shows the day its `timestamp` falls on; `leading` names the attribute right after the class, holding that same text. export type NodeElement = { class: string - content: string | undefined element: 'a' | 'span' | 'time' leading: 'datetime' | 'href' | undefined + textAttribute: string | undefined vocabulary: AttributeVocabulary } const nodeElements: Readonly> = { - date: { class: 'adf-date', content: undefined, element: 'time', leading: 'datetime', vocabulary: attributesOf('date') }, - emoji: { class: 'adf-emoji', content: 'text', element: 'span', leading: undefined, vocabulary: attributesOf('emoji') }, + date: { class: 'adf-date', element: 'time', leading: 'datetime', textAttribute: undefined, vocabulary: attributesOf('date') }, + emoji: { class: 'adf-emoji', element: 'span', leading: undefined, textAttribute: inlineNodeModel('emoji')?.textAttribute, vocabulary: attributesOf('emoji') }, // Leaves out `data`, whose spelling is open (todo.md item 133), so a card holding it rides the carry. - inlineCard: { class: 'adf-inline-card', content: 'url', element: 'a', leading: 'href', vocabulary: { localId: 'string', url: 'string' } }, - status: { class: 'adf-status', content: 'text', element: 'span', leading: undefined, vocabulary: attributesOf('status') }, + inlineCard: { class: 'adf-inline-card', element: 'a', leading: 'href', textAttribute: 'url', vocabulary: { localId: 'string', url: 'string' } }, + status: { class: 'adf-status', element: 'span', leading: undefined, textAttribute: inlineNodeModel('status')?.textAttribute, vocabulary: attributesOf('status') }, } function attributesOf(type: string): AttributeVocabulary { return inlineNodeModel(type)?.attributes ?? {} } +export function classesOfElement(element: string): string[] { + return Object.values(nodeElements).filter((candidate) => candidate.element === element).map((candidate) => candidate.class) +} + export function elementOfNodeType(type: string): NodeElement | undefined { return Object.hasOwn(nodeElements, type) ? nodeElements[type] : undefined } diff --git a/src/html/parse/carry-element.ts b/src/html/parse/carry-element.ts index 5995d49..cdfad03 100644 --- a/src/html/parse/carry-element.ts +++ b/src/html/parse/carry-element.ts @@ -3,6 +3,7 @@ import type { Read } from '../../result.ts' import { carriedNesting, carriedNode, carriedValue } from '../../adf/carried-json.ts' import { carryContent } from '../carry-content.ts' import { carryJsonOffset, readCarryAttributes } from '../carry-attributes.ts' +import { classesOfElement } from '../node-element.ts' import { foundAt, refused, type Place, type ReadNode, type StartTag } from './source.ts' import { holdableJson, readsAsShown } from '../escaping.ts' import { spellsCanonicalJson } from '../../canonical-json.ts' @@ -13,7 +14,11 @@ const holder = "a carry element's data-adf" // What the element shows must read back as `adfToHtml` shows its node, so an edit to it is refused rather than dropped (README goal 7). export function readCarry(html: string, tag: StartTag, element: 'div' | 'span', levels: number, place: Place): ReadNode { 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: ${foundAt(html, tag.start)}`, html, tag.start, place) + if (json === undefined) { + const others = classesOfElement(tag.name) + const alternative = others.length === 0 ? '' : `, or opens with class="${others.join('" or "')}"` + return refused('unmappable-html', `a <${tag.name}> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone${alternative}: ${foundAt(html, tag.start)}`, html, tag.start, place) + } const jsonAt = tag.start + carryJsonOffset(tag.name) const node = heldNode(json, levels, html, jsonAt) if (node.fault !== undefined) return refused(node.fault.code, node.fault.message, html, jsonAt, place) diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts index b07a734..aa43040 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -199,6 +199,9 @@ test('reads an emoji, status, date and card holding a URL from their elements', { attrs: { color: 'yellow', localId: 's1', style: 'bold', text: 'In "review"' }, type: 'status' }, { attrs: { localId: 'd1', timestamp: '1756080000000' }, type: 'date' }, { attrs: { localId: 'c1', url: 'https://example.com/spec?a=1&b=2' }, type: 'inlineCard' }, + { attrs: { text: ' ' }, type: 'emoji' }, + { attrs: { text: ' ' }, type: 'status' }, + { attrs: { url: ' ' }, type: 'inlineCard' }, ] for (const node of nodes) assert.deepEqual(inline(node), document(paragraph(text('See '), node))) assert.deepEqual(read(htmlToAdf('

a "b

\n')), document(paragraph({ attrs: { text: 'a\u00a0"b' }, type: 'emoji' }))) @@ -282,6 +285,9 @@ const refusals: Readonly> ['

\n', 'unmappable-html at /content/0 line 1 offset 0'], ['

Spec

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], ['

https://example.com/

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], + ['

x

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

\n', 'unmappable-html at /content/0 line 1 offset 0'], + ['

x

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], ['

a

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], ['

https://example.com/

\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'], ['

https://example.com/

\n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'], @@ -392,7 +398,7 @@ const messages: readonly (readonly [string, string])[] = [ ['

\n', 'a mark element holds text, or one other mark element around text: found "

\\n"'], ['

x

\n', ' holds its attributes as name="value", each value escaping & as & and " as ", then >: found "&b=2\\">x

"…'], [`

a

\n${carry('div', panel, '

Check the collation.

')}\n`, 'a carry element shows what adfToHtml writes for its node, as it or a browser spells it, then ; to change the node, edit its ADF and write the HTML again: found "collation.

a

\n', 'a of the dialect is a carry element, holding class="adf-carry" and then data-adf alone: found "a

\\n"'], + ['

a

\n', 'a of the dialect is a carry element, holding class="adf-carry" and then data-adf alone, or opens with class="adf-emoji" or "adf-status": found "a

\\n"'], ['
\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ "type":""…'], [`${documentCarry}\n
\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n
\\n"'], [`
\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "
holds ${model.leading} first, before its data-* attributes: ${foundAt(html, tag.start)}`, html, tag.start, place) - const data = readDataAttributes(rest, `${tag.name} class="${model.class}"`, model.vocabulary, model.content === undefined ? [] : [model.content], model.leading) + const data = readDataAttributes(rest, `${tag.name} class="${model.class}"`, model.vocabulary, model.textAttribute === undefined ? [] : [model.textAttribute], model.leading) if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place) const closer = `` const stop = html.indexOf('<', tag.end) @@ -22,7 +22,7 @@ export function readNodeElement(html: string, tag: StartTag, place: Place): Read if (shown === undefined) return misspelledText(html, tag.end + misspelledAt(html.slice(tag.end, end), 'text'), place) if (!html.startsWith(closer, end)) return refused('unmappable-html', `<${tag.name} class="${model.class}"> holds its text, then ${closer}: ${foundAt(html, end)}`, html, end, place) const node = nodeOf(type, model, data.value, shown) - const expected = model.content === undefined ? htmlDay(shownText(node)) : shown + const expected = model.textAttribute === undefined ? htmlDay(shownText(node)) : shown if (expected === undefined || expected === '' || shown !== expected || (model.leading !== undefined && rest[0]?.[1] !== expected)) { return refused('unmappable-html', `${mismatch(tag.name, model)}: ${foundAt(html, tag.end)}`, html, tag.end, place) } @@ -30,7 +30,7 @@ export function readNodeElement(html: string, tag: StartTag, place: Place): Read } function nodeOf(type: string, model: NodeElement, data: AdfAttributes, shown: string): AdfNode { - return { attrs: model.content === undefined ? data : { ...data, [model.content]: shown }, type } + return { attrs: model.textAttribute === undefined ? data : { ...data, [model.textAttribute]: shown }, type } } function mismatch(name: string, model: NodeElement): string {