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>
['2025-08-25
\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'],
+ ['x
\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 = `${tag.name}>`
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 {