3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer product-owner round 1: 0 🔴, 3 🟡 (0 misread); name the fix each refusal needs, list the card's href as an unfiltered URL, carry dates outside years 1 to 9999

This commit is contained in:
2026-11-04 13:45:29 +01:00
committed by Lillem4n
parent d67a935230
commit 731b10fdea
7 changed files with 48 additions and 29 deletions
+3 -3
View File
@@ -525,11 +525,11 @@ content. No stylesheet ships. The complete dialect:
| `paragraph` | `<p>` |
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<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: `<p data-local-id="a1f3">` |
| 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: `<p data-local-id="a1f3">` |
| any other node, or a node holding what its element cannot | `<div class="adf-carry" data-adf="{…}"><p>its content</p></div>`, a `<span>` 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.
+8 -9
View File
@@ -35,7 +35,7 @@ block's `data-*` attributes stand on its `<pre>`.
| ADF | HTML |
| --- | --- |
| `date` | `<time class="adf-date" datetime="2025-08-25" data-timestamp="1756080000000">2025-08-25</time>`, the day in UTC; its other attributes after the class, as below |
| `date` | `<time class="adf-date" datetime="2025-08-25" data-timestamp="1756080000000">2025-08-25</time>`, the day in UTC |
| `em`, `strong`, `code` | `<em>`, `<strong>`, `<code>` around the text |
| `emoji` | `<span class="adf-emoji" data-id="1f389" data-short-name=":tada:">🎉</span>`, its `text` the content |
| `hardBreak` | `<br>` |
@@ -45,10 +45,7 @@ block's `data-*` attributes stand on its `<pre>`.
| `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:
`<strong><em>manual</em></strong>`. Each text node writes its own elements, so two text nodes a link
@@ -59,7 +56,7 @@ marks are two `<a>` elements.
Text and attribute values escape `&`, `<`, `>` and `"` as `&amp;`, `&lt;`, `&gt;` and `&quot;`.
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 `<h1>`, 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,
+4 -2
View File
@@ -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)))), '<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>\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' }), '<p>See <span class="adf-emoji" data-id="1f389" data-local-id="e1" data-short-name=":tada:">🎉</span></p>\n')
assert.equal(inline({ attrs: { text: '<3' }, type: 'emoji' }), '<p>See <span class="adf-emoji">&lt;3</span></p>\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' }), '<p>See <a class="adf-inline-card" href="https://example.com/spec?a=1&amp;b=2" data-local-id="c1">https://example.com/spec?a=1&amp;b=2</a></p>\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)
+3 -3
View File
@@ -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}"`
+7 -2
View File
@@ -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<Record<string, NodeElement>> = {
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
}
+9 -3
View File
@@ -191,7 +191,7 @@ test('reads a carry element holding a node HTML writes as its own element as tha
assert.deepEqual(read(htmlToAdf(`<ul>\n<li>\n${carry('div', paragraph(text('x')), '<p>x</p>')}\n</li>\n</ul>\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('<p><span class="adf-emoji">a&nbsp;&quot;b</span></p>\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(`<p>${carry('span', emoji, '🎉')}</p>\n`)), document(paragraph(emoji)))
})
@@ -263,7 +263,7 @@ const refusals: Readonly<Record<string, readonly (readonly [string, string])[]>>
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, 'unmappable-html at /content/1 line 2 offset 9'],
[`<p>Ask ${carry('span', mention, '')}</p>\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': [
['<p><span class="adf-emoji"></span></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><span class="adf-emoji">a<b>b</b></span></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><span class="adf-emoji">a</p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
@@ -278,6 +278,7 @@ const refusals: Readonly<Record<string, readonly (readonly [string, string])[]>>
['<p><time class="adf-date" datetime="2025-08-25" data-timestamp="1756080000000">Today</time></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><time class="adf-date" datetime="2025-08-25">2025-08-25</time></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><time class="adf-date" datetime="x" data-timestamp="x">x</time></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><time class="adf-date" datetime="+010000-01-01" data-timestamp="253402300800000">+010000-01-01</time></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><time datetime="2025-08-25">2025-08-25</time></p>\n', 'unmappable-html at /content/0 line 1 offset 0'],
['<p><a class="adf-inline-card" href="https://example.com/">Spec</a></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
['<p><a class="adf-inline-card">https://example.com/</a></p>\n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
@@ -406,6 +407,11 @@ const messages: readonly (readonly [string, string])[] = [
['<ol data-local-id="x" start="2">\n<li>a</li>\n</ol>\n', '<ol> takes start once, before its data-* attributes: found start after data-local-id'],
['<ol start="2" start="3">\n<li>a</li>\n</ol>\n', '<ol> takes start once, before its data-* attributes: found start after start'],
['<pre data-wrap="yes"><code>x</code></pre>\n', 'data-wrap holds no boolean written as compact JSON'],
['<p><span class="adf-emoji"></span></p>\n', '<span class="adf-emoji"> holds its text, which is not empty: found "</span></p>\\n"'],
['<p><a class="adf-inline-card" href="https://example.com/">Spec</a></p>\n', '<a class="adf-inline-card"> holds one URL, as its href and as its text; to change the card, change both: found "Spec</a></p>\\n"'],
['<p><time class="adf-date" datetime="2025-08-26" data-timestamp="1756080000000">x</time></p>\n', '<time class="adf-date"> shows the day its data-timestamp names, in datetime and as its text; to change the date, change all three: found "x</time></p>\\n"'],
['<p><span class="adf-emoji" data-size="big">a</span></p>\n', '<span class="adf-emoji"> takes no data-size attribute'],
['<p><span class="adf-status">a&b</span></p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&b</span></p>\\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))
+14 -7
View File
@@ -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`
}