diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8575cf..7f6b186 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,9 +3,9 @@
## Unreleased
- `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, code blocks, rules, hard
- breaks, external images, and `em`, `strong`, `code` and links as their elements. A paragraph,
- heading, quote, list, list item, code block or rule writes an attribute such as `localId` as
- `data-local-id`. Every other node is an element of class `adf-carry` holding the node's JSON in
+ breaks, a centered external image holding no attribute but its URL and alt, and `em`, `strong`,
+ `code` and links as their elements. A paragraph, heading, quote, list, list item, code block or
+ rule writes an attribute such as `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.
- **Breaking:** `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status`
diff --git a/README.md b/README.md
index 7641501..ffb8317 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,9 @@ products store as ADF.
and Obsidian render — GitHub Flavored Markdown's alerts, task lists and pipe tables, Obsidian
Flavored Markdown's callouts and highlights. It keeps the text, images by URL and link targets and
drops the rest.
-- **HTML out:** `adfToHtml` writes paragraphs, headings, lists, quotes, code blocks, images and
- links as their HTML elements, and every other node as an element holding its JSON and showing its
- content.
+- **HTML out:** `adfToHtml` writes paragraphs, headings, lists, quotes, code blocks and links as
+ their HTML elements, and every other node, an image Atlassian's editor wrote among them, as an
+ element holding its JSON and showing its content.
- **No throws:** every call returns a result; a refusal carries a code from a closed list.
- **Runs anywhere:** pure functions with no runtime dependencies, in Node, Deno, Bun and browsers.
TypeScript types ship beside the JavaScript.
@@ -479,7 +479,7 @@ content. No stylesheet ships. The complete dialect:
| `hardBreak` | `
` |
| `heading` | `
` | | `rule` | `
`, ``, ``, the first mark outermost |
diff --git a/docs/decisions.md b/docs/decisions.md
index 260de52..33bd4b1 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -422,8 +422,9 @@ A fourth panel of the same three roles picked, 3 of 3, a code block inside a car
`…
` over `…
` and over a `` whose lines part at `
`, which
loses the indentation. All three wrote ``, which no option held.
-Inside a carry element, an image a link to another target marks takes the spelling portable
-markdown gives it inline (§A linked image standing inline in portable markdown, its link to another
+Inside a carry element (2026-10-24, no panel of its own: the second panel's `` joined to a
+portable spelling), an image a link to another target marks takes the spelling portable markdown
+gives it inline (§A linked image standing inline in portable markdown, its link to another
target, is written `[alt](href) ([image](url))`), keeping both targets, which Goal 7 counts as
content. A link marking a node that shows a note goes around the note.
diff --git a/spec/html.md b/spec/html.md
index fd733a7..b15b8ea 100644
--- a/spec/html.md
+++ b/spec/html.md
@@ -17,7 +17,7 @@ document whose `content` is an empty array is the empty string.
| `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` | its first paragraph straight after `- `: bare where it holds content and no attribute, `
- Bolt
`, and as its `` element where it holds an attribute, `
Bolt
`; where other blocks follow, each after a line feed, and a line feed before ``. Otherwise `- `, a line feed, its blocks, a line feed, `
` |
-| `codeBlock` | `…
`, the language one token holding no whitespace; no `class` where it holds no `language` |
+| `codeBlock` | `…
`, the language one token holding no ASCII whitespace (a tab, line feed, form feed, carriage return or space); 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 |
@@ -73,7 +73,7 @@ later release writes it as its own element.
included, each character HTML cannot hold spelled as a JSON escape, `\u0085`. A document holding
no `content` key is the block carry of `{"type":"doc","version":1}`.
- The content shown is every text node's text below the node, and what a node holding no text shows
- in its place: a mention's text, else `@` and its id; an emoji's text, else its `shortName`; a
+ in its place: a mention's text, else `@` and its id where it has one; an emoji's text, else its `shortName`; a
status's text; a date as `2026-09-13` in UTC; a card's URL, else its name, else the URL its data
holds; media's `alt`, else an external one's URL; an extension's text; an expand's title.
- In a ``, each block's content is a ``, and a code block's is
@@ -103,7 +103,7 @@ 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`;
- - a code block holding more than one text node, a marked one, or a language holding whitespace;
+ - 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;
- 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;
diff --git a/src/adf/shown-text.test.ts b/src/adf/shown-text.test.ts
new file mode 100644
index 0000000..fd1369b
--- /dev/null
+++ b/src/adf/shown-text.test.ts
@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { isBlockNodeType, shownText, shownUrl } from './shown-text.ts'
+
+test('shows a date as its UTC day, one before 1970 included', () => {
+ assert.equal(shownText({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14')
+ assert.equal(shownText({ attrs: { timestamp: '-86400000' }, type: 'date' }), '1969-12-31')
+})
+
+test('shows an emoji by its text, else by its short name where the text is blank', () => {
+ assert.equal(shownText({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄')
+ assert.equal(shownText({ attrs: { shortName: ':smile:', text: ' ' }, type: 'emoji' }), ':smile:')
+})
+
+test('shows a mention by its text, else by its id, and nothing for an empty id', () => {
+ assert.equal(shownText({ attrs: { id: '5b10a2' }, type: 'mention' }), '@5b10a2')
+ assert.equal(shownText({ attrs: { id: '' }, type: 'mention' }), undefined)
+})
+
+test('shows an expand or a nested expand by its title', () => {
+ assert.equal(shownText({ attrs: { title: 'Details' }, type: 'expand' }), 'Details')
+ assert.equal(shownText({ attrs: { title: 'Details' }, type: 'nestedExpand' }), 'Details')
+})
+
+test('links an external image to its URL, and a stored one to nothing', () => {
+ assert.equal(shownUrl({ attrs: { type: 'external', url: 'https://example.com/a.png' }, type: 'media' }), 'https://example.com/a.png')
+ assert.equal(shownUrl({ attrs: { type: 'file', url: 'https://example.com/a.png' }, type: 'mediaInline' }), undefined)
+})
+
+test('counts a card standing apart as a block, as a block node is', () => {
+ assert.equal(isBlockNodeType('blockCard'), true)
+ assert.equal(isBlockNodeType('embedCard'), true)
+ assert.equal(isBlockNodeType('paragraph'), true)
+ assert.equal(isBlockNodeType('inlineCard'), false)
+})
diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts
index 31b71ed..d67a906 100644
--- a/src/html/emit/carry-element.ts
+++ b/src/html/emit/carry-element.ts
@@ -29,44 +29,32 @@ export function carryElement(tag: string, json: string, content: string): string
// What a carry element shows, block by block, as the writer panels picked it (docs/decisions.md §The HTML dialect); a reader of the element reads its JSON alone.
function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] {
const shown: ShownBlock[] = []
- // The blocks the walk is inside, innermost last; a `null` on `pending` closes the innermost.
- const blocks: AdfNode[] = []
- let html = ''
- const pending: (AdfNode | null)[] = [root]
+ let current: ShownBlock = { code: undefined, html: '' }
+ // Each entry holds the code block it stands in; a `null` node ends a block, after which the content stands in `code` again.
+ const pending: { code: AdfNode | undefined; node: AdfNode | null }[] = [{ code: undefined, node: root }]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
- const parts = next === null || partsShownText(next.type)
- if (parts && html !== '') shown.push({ code: innermostCode(blocks), html })
- if (parts) html = ''
- if (next === null) {
- blocks.pop()
- continue
+ const { node } = next
+ const code = node?.type === 'codeBlock' ? node : next.code
+ if (node === null || partsShownText(node.type)) {
+ if (current.html !== '') shown.push(current)
+ current = { code, html: '' }
}
- if (parts) {
- blocks.push(next)
- pending.push(null)
- }
- html += shownPiece(next, innermostCode(blocks) === undefined ? '
' : codeLineFeed)
- for (const child of [...nodeContent(next)].reverse()) pending.push(child)
+ if (node === null) continue
+ current = { ...current, html: current.html + shownPiece(node, current.code === undefined ? '
' : codeLineFeed) }
+ if (partsShownText(node.type)) pending.push({ code: next.code, node: null })
+ for (const child of [...nodeContent(node)].reverse()) pending.push({ code, node: child })
}
- if (html !== '') shown.push({ code: undefined, html })
+ if (current.html !== '') shown.push(current)
return shown
}
-function innermostCode(blocks: readonly AdfNode[]): AdfNode | undefined {
- const innermost = blocks.at(-1)
- return innermost?.type === 'codeBlock' ? innermost : undefined
-}
-
// The node's own part of what its carry element shows: its text, what it shows in its place, or a note naming what it references, linked to its link mark's target, else to the URL a card or an image links to; an image a link to another target marks is followed by a link to it.
function shownPiece(node: AdfNode, lineFeed: string): string {
if (node.type === 'hardBreak') return '
'
const mark = nodeMarks(node).find((held) => held.type === 'link')
const target = mark === undefined ? undefined : linkAttrs(nodeAttrs(mark))
const url = shownUrl(node)
- if (['media', 'mediaInline'].includes(node.type) && target !== undefined && url !== undefined && target.href !== url) {
- const linkText = visibleText(nodeAttrs(node)['alt']) ?? visibleText(target.href) ?? url
- return `${linked(shownValue(linkText, lineFeed), target)} (${linked('image', { href: url })})`
- }
+ if (['media', 'mediaInline'].includes(node.type) && target !== undefined && url !== undefined && target.href !== url) return linkedImage(node, target, url, lineFeed)
const link = target ?? (url === undefined ? undefined : { href: url })
const text = node.text ?? shownText(node)
if (text !== undefined) return linked(shownValue(text, lineFeed), link)
@@ -74,6 +62,12 @@ function shownPiece(node: AdfNode, lineFeed: string): string {
return name === undefined ? '' : linked(`(${shownValue(name, lineFeed)})`, link)
}
+// The image's alt, else the link's target, else its URL, linked to the target, then a link to the image, as portable markdown writes it inline.
+function linkedImage(media: AdfNode, target: LinkAttrs, url: string, lineFeed: string): string {
+ const text = visibleText(nodeAttrs(media)['alt']) ?? visibleText(target.href) ?? url
+ return `${linked(shownValue(text, lineFeed), target)} (${linked('image', { href: url })})`
+}
+
// The shown HTML inside an `` where it links and HTML can hold the link.
function linked(shown: string, link: LinkAttrs | undefined): string {
const anchor = link === undefined ? undefined : anchorOpener(link)
diff --git a/todo.md b/todo.md
index b88ad8f..fce1247 100644
--- a/todo.md
+++ b/todo.md
@@ -6,7 +6,7 @@
`Bar = 0.1`
-`Next ID = 143`
+`Next ID = 144`
## Items
@@ -28,6 +28,7 @@
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
| 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 |
| 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 |
+| 143 | 0.4.0 | | defect | **Keep the link around a note in `adfToPortableMarkdown`, so a linked stored image with no alt writes its note linked.** | 1 | 1 | 2 | 2 | 2 | 7 by 1 | 8.7 |
| 112 | 0.4.0 | | | **Pad a GFM pipe table's short rows with empty cells, read its body rows holding no pipe, and read a one-column table without leading pipes in `portableMarkdownToAdf`.** | 2 | 1 | 2 | 3 | 3 | 4 by 4 | 6.2 |
| 80 | 0.4.0 | | | **Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.** | 2 | 1 | 2 | 3 | 3 | 6 by 4 | 6.0 |
| 81 | 0.4.0 | | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 1 | 1 | 1 | 2 | 3 by 2 | 4.2 |
@@ -210,6 +211,12 @@ Three tests read source text:
Item 81 changes `result.test.ts` too. Found by the technical-principles audit of
item 60, 2026-10-22.
+### 143. Keep the link around a note in `adfToPortableMarkdown`, so a linked stored image with no alt writes its note linked.
+
+`noteLeaves` (`src/markdown/portable/inline-reduction.ts`) drops the node's marks, so a stored
+image a link marks writes `_(image not included)_` without the link's target, which Goal 7 counts as
+content. `adfToHtml`'s carry element keeps it, `(image)`.
+
### 112. Pad a GFM pipe table's short rows with empty cells, read its body rows holding no pipe, and read a one-column table without leading pipes in `portableMarkdownToAdf`.
GFM pads a short row with empty cells, reads a line holding no `|` after a table as a row, and