3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer principles round 3: 0 🔴, 1 🟡 (0 misread); the carry walk's entries hold their code block, the linked image its own function, item 143 filed

This commit is contained in:
2026-10-24 01:20:03 +02:00
committed by Lillem4n
parent 73363a6756
commit fd5b2fb86c
7 changed files with 77 additions and 39 deletions
+3 -3
View File
@@ -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`
+4 -4
View File
@@ -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` | `<br>` |
| `heading` | `<h1>` to `<h6>` |
| `link` | `<a href="…" title="…">` |
| `mediaSingle` holding an external `media`, centered, uncaptioned | `<p><img src="…" alt="…"></p>`, the image inside `<a>` where a link marks it |
| `mediaSingle` holding an external `media`, centered, uncaptioned, holding no other attribute such as `width` or `localId` | `<p><img src="…" alt="…"></p>`, the image inside `<a>` where a link marks it; an image holding such an attribute, as editor-written ones do, rides the carry element |
| `paragraph` | `<p>` |
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<code>`, `<em>`, `<strong>`, the first mark outermost |
+3 -2
View File
@@ -422,8 +422,9 @@ A fourth panel of the same three roles picked, 3 of 3, a code block inside a car
`<pre><code>…</code></pre>` over `<pre>…</pre>` and over a `<p>` whose lines part at `<br>`, which
loses the indentation. All three wrote `<code class="language-python">`, 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 `<a>` 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.
+3 -3
View File
@@ -17,7 +17,7 @@ document whose `content` is an empty array is the empty string.
| `bulletList` | `<ul>`, a line feed, its items a line each, a line feed, `</ul>` |
| `orderedList` | as `bulletList` with `<ol>`; `<ol start="3">` 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 `<li>`: bare where it holds content and no attribute, `<li>Bolt</li>`, and as its `<p>` element where it holds an attribute, `<li><p data-local-id="p1">Bolt</p></li>`; where other blocks follow, each after a line feed, and a line feed before `</li>`. Otherwise `<li>`, a line feed, its blocks, a line feed, `</li>` |
| `codeBlock` | `<pre><code class="language-js">…</code></pre>`, the language one token holding no whitespace; no `class` where it holds no `language` |
| `codeBlock` | `<pre><code class="language-js">…</code></pre>`, 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` | `<hr>` |
| `mediaSingle`, centered, holding one external `media` | `<p><img src="…" alt="…"></p>`, no `alt` where the media holds none; `<p><a href="…"><img …></a></p>` 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 `<div>`, each block's content is a `<p>`, 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;
+36
View File
@@ -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)
})
+20 -26
View File
@@ -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 ? '<br>' : 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 ? '<br>' : 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 '<br>'
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(`<em>(${shownValue(name, lineFeed)})</em>`, 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 `<a>` where it links and HTML can hold the link.
function linked(shown: string, link: LinkAttrs | undefined): string {
const anchor = link === undefined ? undefined : anchorOpener(link)
+8 -1
View File
@@ -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<AdfDocument>`.** | 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, `<a href="…"><em>(image)</em></a>`.
### 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