From 2e7caeb6ae256ef4d6f1440357dba25e3dade316 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 4 Nov 2026 19:37:08 +0100 Subject: [PATCH] Refuse an inline card a link marks in adfToHtml as unsupported-node-shape --- CHANGELOG.md | 3 ++- README.md | 2 ++ docs/decisions.md | 7 ++++--- spec/html.md | 4 +++- src/html/emit/adf-to-html.test.ts | 18 +++++++++++++++--- src/html/emit/inline.ts | 5 ++++- todo.md | 13 ++----------- 7 files changed, 32 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b41b959..f55f943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ `localId` as `data-local-id`. Every other node is an element of class `adf-carry` holding the node's JSON in `data-adf` and showing its content, links included. Every URL is written as the ADF holds it, `javascript:` included: sanitize HTML from an author you do not trust before - showing it. + showing it. It refuses an inline card a link marks, which Atlassian's schema does not allow, as + `unsupported-node-shape`. - `htmlToAdf` reads `adfToHtml`'s HTML back to the document it came from, a carry element from its `data-adf`. It reads that HTML as a browser's `innerHTML` writes it too (a raw `"` in text, ` `, a raw `<` or `>` in an attribute value) and as a form post sends it (`\r\n` for each diff --git a/README.md b/README.md index 436914b..2680eeb 100644 --- a/README.md +++ b/README.md @@ -646,6 +646,8 @@ Every call can raise these: twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Keep the ADF and show the document read-only; where you built the ADF, fix what the message names. +- **Writing, in `adfToHtml`**: an inline card marked by a link, which Atlassian's schema does not + allow. Remove the mark; where you hold the ADF, the message's path names the card. - **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell. This is a bug in this library: report it. - **Past the engine's longest string**: diff --git a/docs/decisions.md b/docs/decisions.md index bb016e1..73e42d3 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -441,9 +441,10 @@ it joins the second panel's `` to that portable spelling. Inside a carry element, a card a link marks links to the link's target, and shows its own URL as its text where it has one, so both stay. A card showing only its name keeps its URL in `data-adf` alone. -Atlassian's schema gives no card a link mark, so only a hand-built document meets this; -`todo.md` item 133 settles, in 0.3.0, how such a card is written. Recorded 2026-10-24 -without a panel of its own: it puts the second panel's `` around the text a card shows. +Atlassian's schema gives no card a link mark, so only a hand-built document meets this, and +`adfToHtml` refuses such a card among the inline nodes it writes as `unsupported-node-shape`: no +element holds both its link and the card's own. Inside a carried block it shows as above, since a +carried node's JSON is not checked. Recorded 2026-10-24 without a panel of its own: it puts the second panel's `` around the text a card shows. A fifth panel (2026-11-04, three readers in the same roles, then four more for what the first three split on) was asked how to write a mention, emoji, status, date and inline card, which until then diff --git a/spec/html.md b/spec/html.md index 0e6857b..5a8b086 100644 --- a/spec/html.md +++ b/spec/html.md @@ -94,7 +94,7 @@ later release writes it as its own element. - A hard break is `
`, and so is a line feed in text outside a `
`'s code block. - Text a link marks is `` around it, or the text alone where HTML cannot hold the link's `href` or `title`; a `title` that is no string is left out. A card is a link to - the target of a link marking it, else to its URL. + its URL; inside a carry element, to the target of a link marking it. - An external image with no link to another target is a link to its URL. An image marked by a link to another target shows as portable markdown writes it inline. First comes the link, around the image's `alt`, else the link's target, else the image's URL. Then comes ` (image)`, @@ -155,6 +155,8 @@ that node in either spelling: an edit there would be lost, so it is `unmappable- is not the day its `data-timestamp` names, a card without `data-data` whose `href` and text are not one URL, and a card with `data-data` whose `href` is not the URL its `data` holds or whose text is not its name, else that URL, are `unmappable-html`. +- `adfToHtml` refuses an inline card a link marks, which Atlassian's schema gives no link mark, as + `unsupported-node-shape`. - 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 1c9bbe0..7f0cd4f 100644 --- a/src/html/emit/adf-to-html.test.ts +++ b/src/html/emit/adf-to-html.test.ts @@ -172,7 +172,6 @@ test('carries an emoji, mention, status, date or card holding what its element c 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) assert.equal(carried({ attrs: { url: '' }, type: 'inlineCard' }), true) assert.equal(carried({ attrs: { url: 'https://example.com/\u0001' }, type: 'inlineCard' }), true) assert.equal(carried({ attrs: { data: { name: 'Roadmap', url: 'https://example.com/' } }, marks: [comment], type: 'inlineCard' }), true) @@ -278,9 +277,22 @@ test('shows a carried image marked by a link to another target as that link, the assert.equal(inline({ attrs: { href: 'https://example.com/', title: 5 }, type: 'link' }), 'The moon (image)') }) -test('links a carried card a link marks to that link\'s target alone', () => { +test('links a card a link marks, inside a carried node, to that link\'s target alone', () => { const card: AdfNode = { attrs: { url: 'https://example.com/roadmap' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' } - assert.equal(shown(html(adfToHtml(document(paragraph(card))))), 'https://example.com/roadmap') + assert.equal(shown(html(adfToHtml(document({ content: [paragraph(card)], type: 'panel' })))), '

https://example.com/roadmap

') +}) + +test('refuses an inline card a link marks, which ADF\'s schema gives no link mark', () => { + const marks: AdfMark[] = [{ attrs: { href: 'https://example.com/' }, type: 'link' }] + const cards: AdfNode[] = [{ attrs: { url: 'https://example.com/roadmap' }, marks, type: 'inlineCard' }, { attrs: { data: { name: 'Roadmap', url: 'https://example.com/roadmap' } }, marks, type: 'inlineCard' }] + for (const card of cards) { + const refused = adfToHtml(document(paragraph(text('See '), card))) + assert.equal(!refused.ok && refused.error.code, 'unsupported-node-shape') + assert.deepEqual(!refused.ok && refused.error.path, ['content', 0, 'content', 1]) + assert.match(!refused.ok ? refused.error.message : '', /inlineCard.*link/) + } + const nested = adfToHtml(document({ content: [{ content: [paragraph(cards[0] ?? text('x'))], type: 'listItem' }], type: 'bulletList' })) + assert.deepEqual(!nested.ok && nested.error.path, ['content', 0, 'content', 0, 'content', 0, 'content', 0]) }) test('shows a note naming what a carried node references where it shows no text, linked where a link marks the node', () => { diff --git a/src/html/emit/inline.ts b/src/html/emit/inline.ts index d84f4b6..96c4a39 100644 --- a/src/html/emit/inline.ts +++ b/src/html/emit/inline.ts @@ -5,13 +5,16 @@ import { elementOfMark } from '../mark-elements.ts' import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { linkAttrs } from '../../adf/mark-attributes.ts' import { nodeElement } from './node-element.ts' -import { success, type ConvertErrorPath, type Result } from '../../result.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { writableValue } from '../escaping.ts' export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result { let html = '' let afterPlainText = false for (const [index, node] of nodes.entries()) { + if (node.type === 'inlineCard' && nodeMarks(node).some(({ type }) => type === 'link')) { + return failure('unsupported-node-shape', 'An inlineCard takes no link mark in ADF.', [...path, 'content', index]) + } // Two unmarked text nodes side by side read back as one, so the second rides the carry. const written: string | undefined = afterPlainText && isUnmarkedSpellableText(node) ? undefined : tryInlineNode(node) if (written === undefined) { diff --git a/todo.md b/todo.md index 302dc1c..5005b9e 100644 --- a/todo.md +++ b/todo.md @@ -12,13 +12,12 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 133 | 0.3.0 | | question | **Record how `adfToHtml` writes an inline card a link marks in `docs/decisions.md` §The HTML dialect, and make `adfToHtml` and `htmlToAdf` follow it.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 | | 163 | 0.3.0 | | | **Gather a corpus of real-world ADF documents from public Jira and Confluence sites, each recording where it came from and under what licence.** | 3 | 2 | 4 | 7 | 4 | 6 by 3 | 3.0 | | 164 | 0.3.0 | 163 | | **Publish a feature table setting chosen corpus examples beside what each call writes for them, readable at a glance by people and parseable by LLMs, and kept current by the gate.** | 3 | 2 | 1 | 4 | 5 | 3 by 6, 6 by 4 | 6.2 | | 165 | 0.3.0 | 164 | | **Have a reader panel seated from `## Audience` judge each feature-table row reasonable or not, and file an item for each row it rejects.** | 2 | 2 | 1 | 3 | 3 | 6 by 5 | 7.5 | | 132 | 0.3.0 | | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 | -| 73 | 0.3.0 | 132, 133, 163, 165 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | -| 72 | 0.3.0 | 132, 133 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | +| 73 | 0.3.0 | 132, 163, 165 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | +| 72 | 0.3.0 | 132 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | | 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 | | 156 | 0.4.0 | | | **Stop a leg's container when its script is interrupted, so Ctrl-C ends `ci.sh` or `mutation-tests/run.sh` at once.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 20.9 | | 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | @@ -69,14 +68,6 @@ ## Details -### 133. Record how `adfToHtml` writes an inline card a link marks in `docs/decisions.md` §The HTML dialect, and make `adfToHtml` and `htmlToAdf` follow it. - -Class: the HTML dialect's spellings. Earlier entries: a card is ``, -and HTML nests no `` in an ``, so the card's element leaves no room for the link. Candidate -entry: "A card a link marks rides the carry, which links to the link's target and shows the card's -URL where it has one; no document Atlassian's editor writes holds one." Its instance is today's -output. The item ends with that entry or with a spelling the maintainer names. - ### 163. Gather a corpus of real-world ADF documents from public Jira and Confluence sites, each recording where it came from and under what licence. The maintainer asked for it on 2026-11-03: paragraphs such as bold around a code span or italics over a line break occur all the time, and the project holds no evidence of what real documents contain. Find public sites that serve ADF without an account, such as Jira Cloud issues on public projects through the REST v3 API, and fetch enough documents to cover the node and mark types the flavour spells.