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:
+3
-3
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user