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