diff --git a/README.md b/README.md index ec8107d..4bc5361 100644 --- a/README.md +++ b/README.md @@ -466,15 +466,15 @@ saving what this pair read replaces mentions, attachments and macros with text. ## HTML Serves [Goals](#goals) 1, 6 and 7. `adfToHtml` writes the HTML element for each node HTML has one -for, an attribute HTML has none for as `data-*`, and keeps every other node whole: an element of -class `adf-carry` holds the node's JSON in `data-adf` and shows the node's content, its links -included, so a reader of the page loses no content. No stylesheet ships. The complete dialect: +for, and keeps every other node whole: an element of class `adf-carry` holds the node's JSON in +`data-adf` and shows the node's content, its links included, so a reader of the page loses no +content. No stylesheet ships. The complete dialect: [`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md). | ADF | HTML | | --- | --- | | `blockquote` | `
` | -| `bulletList`, `orderedList`, `listItem` | ``, `
`, `
` for an `order` of 3, and `
- `; an item's first paragraph stands bare, `
- Bolt
` | +| `bulletList`, `orderedList`, `listItem` | ``, `
`, `
` for an `order` of 3, and `
- `; an item's first paragraph stands bare, `
- Bolt
`, or as `- ` where it holds an attribute | | `codeBlock` | `
Bolt
`, no `class` without a language | | `hardBreak` | `
` | | `heading` | `` to `
` | @@ -483,14 +483,15 @@ included, so a reader of the page loses no content. No stylesheet ships. The com | `paragraph` | `
` | | `rule` | `
` | | `text` marked `code`, `em`, `strong` | ``, ``, ``, the first mark outermost | -| any other attribute the node's type holds | `data-*` named as HTML's `dataset` names it: `` | +| any other attribute the node's type holds, on a paragraph, heading, quote, list, list item, code block or rule | `data-*` named as HTML's `dataset` names it: `
` | | any other node, or a node holding what its element cannot | `
`, a `` inline | - 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. -- The carry element shows each block's content as a `its content
`, text a link marks as a link, a mention, - status or emoji by its text, a date as `2026-09-13` in UTC, a card as a link to its URL, an image as - a link to it, and an expand by its title. What it cannot show leaves a note, `(image)`. +- The carry element shows each block's content as a `
` in a `
`, parted by `
` in a + ``. It shows text a link marks as a link, a mention, status or emoji by its text, a date as + `2026-09-13` in UTC, a card as a link to its URL, an external image as a link to it, a stored one + by its `alt`, and an expand by its title. What it cannot show leaves a note, `(image)`. - A link's or image's URL is written as the ADF holds it, `javascript:` included: sanitize the HTML before showing a document from an author you do not trust. diff --git a/docs/decisions.md b/docs/decisions.md index 00a73b7..5b8829c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -412,6 +412,12 @@ block and a block carried for it would show no element at all; inside a carry el line feed, and media it cannot show as `(image)` over `(image not shown)` and nothing. +A third panel, seven readers in the same three roles, was asked how to write a list item whose first +paragraph holds an attribute, the shape of every item Atlassian's editor writes. It picked +`- `, 6 of 7, over the same elements with +the `
Bolt
` on a line of its own, 1 of 7, and over +`
- Bolt
`, 0 of 7. + ## No runtime dependencies 2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each diff --git a/spec/html.md b/spec/html.md index 26cc4f2..1830639 100644 --- a/spec/html.md +++ b/spec/html.md @@ -16,7 +16,7 @@ document whose `content` is an empty array is the empty string. | `blockquote` | ``, a line feed, its blocks, a line feed, `` | | `bulletList` | ``, a line feed, its items a line each, a line feed, `
` | | `orderedList` | as `bulletList` with ``; `
` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` | -| `listItem` | `
- …
`: a first paragraph holding content and no attribute bare, its inline content straight after `- `, then each other block after a line feed and a line feed before `
`; with no bare paragraph, `- `, a line feed, its blocks, a line feed, `
` | +| `listItem` | its first paragraph straight after `- `: bare where it holds content and no attribute, `
- Bolt
`, and as its `` element where it holds an attribute, `
- `; where other blocks follow, each after a line feed, and a line feed before ``. Otherwise `
Bolt
- `, a line feed, its blocks, a line feed, `
` | | `codeBlock` | ``, the language one token holding no whitespace; no `class` where it holds no `language` | | `rule` | `…
` | | `mediaSingle`, centered, holding one external `media` | ``, no `alt` where the media holds none; `` where a link marks it | @@ -24,9 +24,10 @@ document whose `content` is an empty array is the empty string. A container holding no `content` key is its element holding nothing: ``, `
`, ``; so is a paragraph or heading, ``. -Every other attribute its type holds (`spec/flavour.md` §Block nodes) is a `data-*` attribute on the -element, named as HTML's `dataset` names it, `localId` as `data-local-id`, after the element's own -attributes and sorted by name. A string is its value; any other value is its JSON, compact: +For a paragraph, heading, quote, list, list item, code block or rule, every other attribute its type +holds (`spec/flavour.md` §Block nodes) is a `data-*` attribute on its element, named as HTML's +`dataset` names it, `localId` as `data-local-id`, after the element's own attributes and sorted by +name. A string is its value; any other value is its JSON, compact: `
`, `
`. A code block's `data-*` attributes stand on its ``. @@ -91,10 +92,12 @@ later release writes it as its own element. - an unmarked text node straight after another, which would read back as one; - a link whose `href` or `title` holds a character HTML cannot hold, and an image whose `url` or `alt` holds one or whose link's `href` or `title` does; + - an image holding an attribute besides its `center` layout and its media's `type`, `url` and + 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`; - a code block holding more than one text node, a marked one, or a language holding whitespace; - - a rule or hard break holding `content`; + - a rule or hard break holding `content`, and a hard break holding an attribute; - a list holding a node that is no `listItem`, or an item holding a mark, text, an empty key or an attribute its type does not hold: the whole list rides the carry; - a `listItem` outside a list, an inline node in block position and a block node inline. diff --git a/src/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts index 1218b06..4ecc177 100644 --- a/src/html/emit/adf-to-html.test.ts +++ b/src/html/emit/adf-to-html.test.ts @@ -92,10 +92,13 @@ test('writes lists tight, an item holding more than one paragraph or a list in e assert.equal(html(adfToHtml(document({ content: [item(paragraph())], type: 'bulletList' }))), '\n
\n') }) -test('writes an item\'s first paragraph as its element where it holds an attribute, and as its carry where it holds a mark', () => { - const first = (node: AdfNode): string => html(adfToHtml(document({ content: [item(node)], type: 'bulletList' }))) - assert.equal(first({ attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' }), `- \n\n
\n\n
\n`) - assert.equal(first({ content: [text('Bolt')], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }).startsWith('- \n
\nBolt
\n\n
- \n
{ + const listed = (...blocks: AdfNode[]): string => html(adfToHtml(document({ content: [item(...blocks)], type: 'bulletList' }))) + const held: AdfNode = { attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' } + assert.equal(listed(held), `\n
\n`) + assert.equal(listed(held, { content: [item(paragraph(text('M8')))], type: 'bulletList' }), `- \n
Bolt
\n
\n`) + assert.equal(listed({ attrs: { localId: ids.paragraph }, type: 'paragraph' }), `- \n
Bolt
\n\n
\n- M8
\n\n
\n`) + assert.equal(listed({ content: [text('Bolt')], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }).startsWith('- \n
\n
- \n
{ @@ -104,7 +107,7 @@ test('writes the attributes an element cannot express as data-* attributes on it assert.equal(html(adfToHtml(document({ attrs: { level: 2, ...localId }, content: [text('x')], type: 'heading' }))), `x
\n`) assert.equal(html(adfToHtml(document({ attrs: { localId: ids.quote }, content: [paragraph(text('x'))], type: 'blockquote' }))), `\n\n`) const listed: AdfNode = { attrs: { localId: ids.list, order: 3 }, content: [{ attrs: { localId: ids.item }, content: [{ attrs: localId, content: [text('Bolt')], type: 'paragraph' }], type: 'listItem' }], type: 'orderedList' } - assert.equal(html(adfToHtml(document(listed))), `x
\n\n
\n`) + assert.equal(html(adfToHtml(document(listed))), `- \n
\nBolt
\n\n
\n`) const code: AdfNode = { attrs: { wrap: true, language: 'js', hideLineNumbers: false, ...localId }, content: [text('x')], type: 'codeBlock' } assert.equal(html(adfToHtml(document(code))), `- \n
Bolt
\n`) assert.equal(html(adfToHtml(document({ attrs: { weight: -0, color: '#ccc', style: 'a"b' }, type: 'rule' }))), 'x
\n') @@ -259,6 +262,7 @@ test('refuses a carried block whose JSON nests past the levels its depth leaves, assert.equal(refusal(adfToHtml(document(paragraph(deep)))), 'unsupported-nesting-depth at /content/0/content/0') assert.equal(refusal(adfToHtml(document({ attrs: { level: 1 }, content: [deep], type: 'heading' }))), 'unsupported-nesting-depth at /content/0/content/0') assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/0/content/0') + assert.equal(refusal(adfToHtml(document({ content: [item({ attrs: { localId: ids.paragraph }, content: [deep], type: 'paragraph' })], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/0/content/0') assert.equal(refusal(adfToHtml(document({ content: [item(paragraph(text('a')), paragraph(deep)), item(paragraph(deep))], type: 'bulletList' }))), 'unsupported-nesting-depth at /content/0/content/0/content/1/content/0') }) diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index d7e7ae1..4cd6c30 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -112,16 +112,23 @@ function containerPieces(tag: string, attributes: string, children: Pending[] | return [`<${tag}${attributes}>\n`, ...children, `\n${tag}>`] } -// An item's blocks sit at its own level, one below its list; its first paragraph stands bare where it holds content and no attribute, panel pick, docs/decisions.md §The HTML dialect. +// An item's blocks sit at its own level, one below its list; its first paragraph stands on the item's line, panel picks, docs/decisions.md §The HTML dialect. function writeItem({ attributes, depth, node, path }: Item): Result{ if (node.content === undefined) return success([``]) const blocks = blockPieces(node.content, path, depth) const [first] = node.content - if (first === undefined || first.type !== 'paragraph' || dataAttributes(first, []) !== '' || nodeContent(first).length === 0) return success([` - \n`, ...blocks, '\n
']) - const bare = inlineHtml(nodeContent(first), [...path, 'content', 0]) - if (!bare.ok) return bare - // The bare paragraph's inline content stands in for its block. - return success(blocks.length === 1 ? [`- ${bare.value}
`] : [`- ${bare.value}`, ...blocks.slice(1), '\n
']) + const line = first?.type === 'paragraph' ? tryFirstParagraph(first, [...path, 'content', 0]) : undefined + if (line === undefined) return success([`- \n`, ...blocks, '\n
']) + if (!line.ok) return line + // The first paragraph, written on the item's line, stands in for its block. + return success(blocks.length === 1 ? [`- ${line.value}
`] : [`- ${line.value}`, ...blocks.slice(1), '\n
']) +} + +// `undefined` where the paragraph takes a line of its own: where it rides the carry, or holds neither content nor an attribute, which bare would read back as no paragraph. +function tryFirstParagraph(paragraph: AdfNode, path: ConvertErrorPath): Result| undefined { + const attributes = dataAttributes(paragraph, []) + if (attributes !== '') return inlineElement('p', attributes, nodeContent(paragraph), path) + return nodeContent(paragraph).length === 0 ? undefined : inlineHtml(nodeContent(paragraph), path) } function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined {