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` | `


` | | `text` marked `code`, `em`, `strong` | ``, ``, ``, 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'], ['

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'], @@ -406,6 +407,11 @@ const messages: readonly (readonly [string, string])[] = [ ['
    \n
  1. a
  2. \n
\n', '
    takes start once, before its data-* attributes: found start after data-local-id'], ['
      \n
    1. a
    2. \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"'], + ['

      Spec

      \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 = `` 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` +}