diff --git a/README.md b/README.md index d96522f..b4bcb86 100644 --- a/README.md +++ b/README.md @@ -525,11 +525,11 @@ content. No stylesheet ships. The complete dialect: | `paragraph` | `
` | | `rule` | `
`, ``, ``, the first mark outermost |
-| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block or rule | `data-*` named as HTML's `dataset` names it: `` |
+| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block, rule, emoji, status, date or card | `data-*` named as HTML's `dataset` names it: `
` |
| any other node, or a node holding what its element cannot | `
its content
`, a `` inline |
-- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, and a
- card's or a carried link's in the carry element. Sanitize the HTML before showing a document from
+- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, a
+ card's, and a carried link's in the carry element. Sanitize the HTML before showing a document from
an author you do not trust.
- Text and attribute values are escaped: `&`, `<`, `>` and `"`. A node holding what HTML cannot
hold, such as a carriage return or a control character, rides the carry element.
diff --git a/spec/html.md b/spec/html.md
index 4fe6475..c102704 100644
--- a/spec/html.md
+++ b/spec/html.md
@@ -35,7 +35,7 @@ block's `data-*` attributes stand on its ``.
| ADF | HTML |
| --- | --- |
-| `date` | ``, the day in UTC; its other attributes after the class, as below |
+| `date` | ``, the day in UTC |
| `em`, `strong`, `code` | ``, ``, `` around the text |
| `emoji` | `π`, its `text` the content |
| `hardBreak` | `
` |
@@ -45,10 +45,7 @@ block's `data-*` attributes stand on its ``.
| `text` | its text, escaped |
A `date`, `emoji`, `inlineCard` or `status` writes each other attribute its type holds as a
-`data-*` attribute, named and sorted as on a block, `data-local-id` among them. It rides the carry
-where it holds a mark, `content`, a key or value its type does not hold, or an empty `attrs`; where
-it has no text to show (an emoji or status with no `text`, a date whose `timestamp` names no day, a
-card with no `url`); and where a card holds `data`. A `mention` rides the carry.
+`data-*` attribute, named and sorted as on a block, `data-local-id` among them.
A text node's marks nest in the order its `marks` array lists them, the first outermost:
`manual`. Each text node writes its own elements, so two text nodes a link
@@ -59,7 +56,7 @@ marks are two `` elements.
Text and attribute values escape `&`, `<`, `>` and `"` as `&`, `<`, `>` and `"`.
Attribute values are double-quoted. Every URL is written as the ADF holds it, whatever its scheme
(`docs/decisions.md` Β§HTML writes a URL as the ADF holds it, whatever its scheme): a link's `href`,
-an image's `src`, and the `href` of each link a carry element shows.
+an image's `src`, a card's `href`, and the `href` of each link a carry element shows.
HTML text and attribute values cannot hold these (WHATWG HTML Β§13.1.3): any control but a tab, line
feed or form feed, so a carriage return too, which a parser reads as a line feed; a noncharacter;
@@ -118,6 +115,8 @@ later release writes it as its own element.
non-empty `alt`;
- a heading whose `level` is not 1 to 6, and an ordered list whose `order` is not a safe integer
or is `-0`;
+ - an emoji or status with no `text`, a date whose `timestamp` names no day from year 1 to 9999,
+ and a card with no `url` or holding `data`;
- a code block holding more than one text node, a marked one, or a language holding ASCII
whitespace;
- a rule or hard break holding `content`, and a hard break holding an attribute;
@@ -148,9 +147,9 @@ that node in either spelling: an edit there would be lost, so it is `unmappable-
- Any other HTML is `unmappable-html`: another element, attribute or escape, attributes in another
order, a block on the line of another, a carriage return not followed by a line feed, or
non-empty HTML not ending in a line feed.
-- An emoji, status, date or card element reads back as its node only when it shows what `adfToHtml`
- writes for it: the text is not empty, a date's `datetime` and text are the day its `data-timestamp`
- names, and a card's `href` and text are one URL. Another shape is `unmappable-html`.
+- An emoji, status, date or card element with empty text, a date whose `datetime` or text is not the
+ day its `data-timestamp` names, and a card whose `href` and text are not one URL are
+ `unmappable-html`.
- A `data-*` attribute that its element does not take, such as `data-level` on ``, or that holds
a value outside its attribute's kind or not written as compact JSON, is `unsupported-node-shape`.
- A carry element's `data-adf` holding invalid JSON is `unmappable-html`. JSON that is no ADF node,
diff --git a/src/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts
index 921c65b..51cf4b2 100644
--- a/src/html/emit/adf-to-html.test.ts
+++ b/src/html/emit/adf-to-html.test.ts
@@ -140,7 +140,7 @@ test('carries every other node in an adf-carry element holding its JSON', () =>
assert.equal(html(adfToHtml(document(paragraph(text('Ask '), mention)))), '
Ask @Mikael
\n')
})
-test('writes an emoji, status, date and URL card as their elements, their attributes data-* sorted by name', () => {
+test('writes an emoji, status, date and card holding a URL as their elements, their attributes data-* sorted by name', () => {
const inline = (node: AdfNode): string => html(adfToHtml(document(paragraph(text('See '), node))))
assert.equal(inline({ attrs: { id: '1f389', localId: 'e1', shortName: ':tada:', text: 'π' }, type: 'emoji' }), 'See π
\n')
assert.equal(inline({ attrs: { text: '<3' }, type: 'emoji' }), 'See <3
\n')
@@ -149,7 +149,7 @@ test('writes an emoji, status, date and URL card as their elements, their attrib
assert.equal(inline({ attrs: { localId: 'c1', url: 'https://example.com/spec?a=1&b=2' }, type: 'inlineCard' }), 'See https://example.com/spec?a=1&b=2
\n')
})
-test('carries an emoji, status, date or URL card the element cannot hold', () => {
+test('carries an emoji, status, date or card holding a URL the element cannot hold', () => {
const carried = (node: AdfNode): boolean => html(adfToHtml(document(paragraph(node)))).includes('adf-carry')
const comment: AdfMark = { attrs: { annotationType: 'inlineComment', id: 'c1' }, type: 'annotation' }
assert.equal(carried({ attrs: { text: 'π' }, marks: [comment], type: 'emoji' }), true)
@@ -163,6 +163,8 @@ test('carries an emoji, status, date or URL card the element cannot hold', () =>
assert.equal(carried({ attrs: { timestamp: 'soon' }, type: 'date' }), true)
assert.equal(carried({ attrs: { timestamp: 1756080000000 }, type: 'date' }), true)
assert.equal(carried({ type: 'date' }), true)
+ for (const timestamp of ['253402300800000', '-62135596800001', '-62167219200000']) assert.equal(carried({ attrs: { timestamp }, type: 'date' }), true)
+ for (const timestamp of ['253402300799999', '-62135596800000']) assert.equal(carried({ attrs: { timestamp }, type: 'date' }), false)
assert.equal(carried({ attrs: { data: { name: 'Roadmap' } }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { data: { name: 'Roadmap' }, url: 'https://example.com/' }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { url: 'https://example.com/' }, marks: [{ attrs: { href: 'https://example.org/' }, type: 'link' }], type: 'inlineCard' }), true)
diff --git a/src/html/emit/node-element.ts b/src/html/emit/node-element.ts
index b515d82..90868c2 100644
--- a/src/html/emit/node-element.ts
+++ b/src/html/emit/node-element.ts
@@ -1,16 +1,16 @@
import type { AdfNode } from '../../adf/document.ts'
import { dataAttributes } from '../data-attributes.ts'
-import { elementOfNodeType } from '../node-element.ts'
+import { elementOfNodeType, htmlDay } from '../node-element.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { shownText } from '../../adf/shown-text.ts'
import { writableValue } from '../escaping.ts'
-// `undefined` where the node is no emoji, status, date or URL card, or holds what its element cannot write: it rides the carry.
+// `undefined` where the node is no emoji, status, date or card holding a URL, or holds what its element cannot write: it rides the carry.
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 ? shownText(node) : nodeAttrs(node)[model.content]
+ const held = model.content === undefined ? htmlDay(shownText(node)) : nodeAttrs(node)[model.content]
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 bcb4533..a589ff3 100644
--- a/src/html/node-element.ts
+++ b/src/html/node-element.ts
@@ -1,7 +1,7 @@
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import { inlineNodeModel } from '../adf/inline-nodes.ts'
-// An inline node HTML writes as its own element instead of the carry (docs/decisions.md Β§The HTML dialect). `content` is the attribute its text is, none for a date, whose text is the day its `timestamp` falls on; `leading` is the attribute standing first after the class, holding that same text.
+// `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.
export type NodeElement = {
class: string
content: string | undefined
@@ -13,7 +13,7 @@ export type NodeElement = {
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') },
- // A card's `data` is not written yet, so a card holding it rides the carry.
+ // 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') },
}
@@ -29,3 +29,8 @@ export function elementOfNodeType(type: string): NodeElement | undefined {
export function nodeTypeOfElement(element: string, className: string): [string, NodeElement] | undefined {
return Object.entries(nodeElements).find(([, candidate]) => candidate.element === element && candidate.class === className)
}
+
+// HTML's valid date string holds a four-digit year above 0, so a day outside years 1 to 9999 rides the carry.
+export function htmlDay(day: string | undefined): string | undefined {
+ return day !== undefined && /^\d{4}-/.test(day) && !day.startsWith('0000-') ? day : undefined
+}
diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts
index cef3703..b07a734 100644
--- a/src/html/parse/html-to-adf.test.ts
+++ b/src/html/parse/html-to-adf.test.ts
@@ -191,7 +191,7 @@ test('reads a carry element holding a node HTML writes as its own element as tha
assert.deepEqual(read(htmlToAdf(`\n- \n${carry('div', paragraph(text('x')), '
x
')}\n \n
\n`)), document({ content: [item(paragraph(text('x')))], type: 'bulletList' }))
})
-test('reads an emoji, status, date and URL card from their elements', () => {
+test('reads an emoji, status, date and card holding a URL from their elements', () => {
const inline = (node: AdfNode): AdfDocument | string => read(htmlToAdf(adfToHtmlText(document(paragraph(text('See '), node)))))
const nodes: AdfNode[] = [
{ attrs: { id: '1f389', localId: 'e1', shortName: ':tada:', text: 'π' }, type: 'emoji' },
@@ -204,7 +204,7 @@ test('reads an emoji, status, date and URL card from their elements', () => {
assert.deepEqual(read(htmlToAdf('a "b
\n')), document(paragraph({ attrs: { text: 'a\u00a0"b' }, type: 'emoji' })))
})
-test('reads a carry element holding an emoji, status, date or URL card as that node', () => {
+test('reads a carry element holding an emoji, status, date or card holding a URL as that node', () => {
const emoji: AdfNode = { attrs: { text: 'π' }, type: 'emoji' }
assert.deepEqual(read(htmlToAdf(`${carry('span', emoji, 'π')}
\n`)), document(paragraph(emoji)))
})
@@ -263,7 +263,7 @@ const refusals: Readonly>
[`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 an emoji, status, date or URL card element that is not as adfToHtml writes it': [
+ 'refuses an emoji, status, date or card holding a URL element that is not as adfToHtml writes it': [
['
\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['ab
\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'],
@@ -278,6 +278,7 @@ const refusals: Readonly>
['\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
+ ['\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['\n', 'unmappable-html at /content/0 line 1 offset 0'],
['\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
@@ -406,6 +407,11 @@ const messages: readonly (readonly [string, string])[] = [
['\n- a
\n
\n', ' takes start once, before its data-* attributes: found start after data-local-id'],
['\n- a
\n
\n', ' takes start once, before its data-* attributes: found start after start'],
['x
\n', 'data-wrap holds no boolean written as compact JSON'],
+ ['
\n', ' holds its text, which is not empty: found "\\n"'],
+ ['\n', ' holds one URL, as its href and as its text; to change the card, change both: found "Spec\\n"'],
+ ['\n', '\\n"'],
+ ['a
\n', ' takes no data-size attribute'],
+ ['a&b
\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "&b\\n"'],
]
for (const [html, expected] of messages) test(`names what it found where reading stopped, a character no one sees as its escape: ${JSON.stringify(html)}`, () => assert.equal(message(htmlToAdf(html)), expected))
diff --git a/src/html/parse/node-element.ts b/src/html/parse/node-element.ts
index c229ac2..e8e1db9 100644
--- a/src/html/parse/node-element.ts
+++ b/src/html/parse/node-element.ts
@@ -1,30 +1,30 @@
import type { AdfAttributes, AdfNode } from '../../adf/document.ts'
import { foundAt, misspelledText, refused, type Place, type ReadNode, type StartTag } from './source.ts'
-import { nodeTypeOfElement, type NodeElement } from '../node-element.ts'
+import { htmlDay, nodeTypeOfElement, type NodeElement } from '../node-element.ts'
import { readDataAttributes } from '../data-attributes.ts'
import { shownText } from '../../adf/shown-text.ts'
import { success } from '../../result.ts'
-import { unescapedValue } from '../escaping.ts'
+import { misspelledAt, unescapedValue } from '../escaping.ts'
-// `undefined` where the tag opens no emoji, status, date or URL card element; one that does reads as the node `nodeElement` wrote it from, or is refused.
+// `undefined` where the tag opens no emoji, status, date or card element; one that does reads as the node `nodeElement` wrote it from, or is refused.
export function readNodeElement(html: string, tag: StartTag, place: Place): ReadNode | undefined {
const [kind, ...rest] = tag.attributes
const found = kind?.[0] === 'class' ? nodeTypeOfElement(tag.name, kind[1]) : undefined
if (found === undefined) return undefined
const [type, model] = found
if (model.leading !== undefined && rest[0]?.[0] !== model.leading) return refused('unmappable-html', `<${tag.name} class="${model.class}"> holds ${model.leading} first, before its data-* attributes: ${foundAt(html, tag.start)}`, html, tag.start, place)
- const data = readDataAttributes(rest, tag.name, model.vocabulary, model.content === undefined ? [] : [model.content], model.leading)
+ const data = readDataAttributes(rest, `${tag.name} class="${model.class}"`, model.vocabulary, model.content === undefined ? [] : [model.content], 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)
const end = stop < 0 ? html.length : stop
const shown = unescapedValue(html.slice(tag.end, end), 'text')
- if (shown === undefined) return misspelledText(html, tag.end, place)
+ 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 ? shownText(node) : shown
+ const expected = model.content === undefined ? htmlDay(shownText(node)) : shown
if (expected === undefined || expected === '' || shown !== expected || (model.leading !== undefined && rest[0]?.[1] !== expected)) {
- return refused('unmappable-html', `<${tag.name} class="${model.class}"> shows what adfToHtml writes for its node, ${model.leading === undefined ? '' : `${model.leading} the same, `}then ${closer}; to change the node, edit its data-* attributes: ${foundAt(html, tag.end)}`, html, tag.end, place)
+ return refused('unmappable-html', `${mismatch(tag.name, model)}: ${foundAt(html, tag.end)}`, html, tag.end, place)
}
return success({ end: end + closer.length, node })
}
@@ -32,3 +32,10 @@ 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 }
}
+
+function mismatch(name: string, model: NodeElement): string {
+ const element = `<${name} class="${model.class}">`
+ if (model.leading === 'datetime') return `${element} shows the day its data-timestamp names, in datetime and as its text; to change the date, change all three`
+ if (model.leading === 'href') return `${element} holds one URL, as its href and as its text; to change the card, change both`
+ return `${element} holds its text, which is not empty`
+}