From fd5b2fb86cfc2459bd3a6e94ca9ffa091511cc6d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 24 Oct 2026 01:20:03 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20principles=20round=203:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=201=20=F0=9F=9F=A1=20(0=20misread);=20the=20carry?= =?UTF-8?q?=20walk's=20entries=20hold=20their=20code=20block,=20the=20link?= =?UTF-8?q?ed=20image=20its=20own=20function,=20item=20143=20filed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++--- README.md | 8 +++--- docs/decisions.md | 5 ++-- spec/html.md | 6 ++--- src/adf/shown-text.test.ts | 36 ++++++++++++++++++++++++++ src/html/emit/carry-element.ts | 46 +++++++++++++++------------------- todo.md | 9 ++++++- 7 files changed, 77 insertions(+), 39 deletions(-) create mode 100644 src/adf/shown-text.test.ts 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` | `

` to `

` | | `link` | `` | -| `mediaSingle` holding an external `media`, centered, uncaptioned | `

…

`, the image inside `
` where a link marks it | +| `mediaSingle` holding an external `media`, centered, uncaptioned, holding no other attribute such as `width` or `localId` | `

…

`, the image inside `
` where a link marks it; an image holding such an attribute, as editor-written ones do, rides the carry element | | `paragraph` | `

` | | `rule` | `


` | | `text` marked `code`, `em`, `strong` | ``, ``, ``, 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` | `

` | | `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 `
    1. `: bare where it holds content and no attribute, `
    2. Bolt
    3. `, and as its `

      ` element where it holds an attribute, `

    4. Bolt

    5. `; where other blocks follow, each after a line feed, and a line feed before ``. Otherwise `
    6. `, a line feed, its blocks, a line feed, `
    7. ` | -| `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