diff --git a/AGENTS.md b/AGENTS.md index 2872d3f..3763fb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,7 +225,8 @@ The comprehension panel's fill-ins: - Language: TypeScript. - Kind: a pure-function document converter with hand-written parsers and emitters. -- Domain: Atlassian Document Format and CommonMark parsing. -- Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`. +- Domain: Atlassian Document Format, CommonMark and HTML. +- Domain docs: the CommonMark spec, the WHATWG HTML standard, ADF's JSON schema, `spec/flavour.md` and + `spec/html.md`. - 3am question: a viewer/editor app reports that a document it saved comes back with two text nodes merged and a mark gone after `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6b186..e5ff994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ - `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, code blocks, rules, hard 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, + rule writes its other attributes as `data-*` attributes, `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` or `mediaInline` only the marks Atlassian's schema lets it hold: `**!adf:mention[@A]{id=…}**` now diff --git a/README.md b/README.md index ffb8317..eae7e36 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 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. +- **HTML out:** `adfToHtml` writes paragraphs, headings, lists, quotes, code blocks, rules, line + breaks, emphasis, inline code and links as their HTML elements. Every other node, an image + Atlassian's editor wrote among them, becomes 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. @@ -70,37 +70,6 @@ if (portable.ok) console.log(portable.value) // > Check the collation before importing. ``` -### ADF to HTML - -Full reference: [HTML](#html). - -```ts -import { adfToHtml, type AdfDocument } from '@larvit/adf-codec' - -const page: AdfDocument = { - content: [ - { attrs: { level: 2, localId: 'a1f3' }, content: [{ text: 'Release notes', type: 'text' }], type: 'heading' }, - { - content: [ - { text: 'Read the ', type: 'text' }, - { marks: [{ attrs: { href: 'https://spec.commonmark.org/' }, type: 'link' }], text: 'spec', type: 'text' }, - { text: ', then ask ', type: 'text' }, - { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }, - ], - type: 'paragraph', - }, - ], - type: 'doc', - version: 1, -} - -// URLs are written as the ADF holds them, javascript: included: sanitize HTML from an author you do not trust before showing it. -const html = adfToHtml(page) -if (html.ok) console.log(html.value) -//

Release notes

-//

Read the spec, then ask @Mikael

-``` - ### Markdown to ADF Full reference: [The calls](#the-calls) and [Portable markdown](#portable-markdown). @@ -156,6 +125,37 @@ if (!markdown.ok) { Unedited, the markdown reads back to a document that deep-equals `doc`, node types this version does not know included. +### ADF to HTML + +Full reference: [HTML](#html). + +```ts +import { adfToHtml, type AdfDocument } from '@larvit/adf-codec' + +const page: AdfDocument = { + content: [ + { attrs: { level: 2, localId: 'a1f3' }, content: [{ text: 'Release notes', type: 'text' }], type: 'heading' }, + { + content: [ + { text: 'Read the ', type: 'text' }, + { marks: [{ attrs: { href: 'https://spec.commonmark.org/' }, type: 'link' }], text: 'spec', type: 'text' }, + { text: ', then ask ', type: 'text' }, + { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }, + ], + type: 'paragraph', + }, + ], + type: 'doc', + version: 1, +} + +// URLs are written as the ADF holds them, javascript: included: sanitize HTML from an author you do not trust before showing it. +const html = adfToHtml(page) +if (html.ok) console.log(html.value) +//

Release notes

+//

Read the spec, then ask @Mikael

+``` + ### Handle a result's error Full reference: [The errors](#the-errors). @@ -465,8 +465,8 @@ saving what this pair read replaces mentions, attachments and macros with text. ## HTML -Serves [Goals](#goals) 1, 6 and 7. `adfToHtml` writes the HTML element for each node HTML has one -for, and keeps every other node whole: an element of class `adf-carry` holds the node's JSON in +Serves [Goals](#goals) 1, 6 and 7. `adfToHtml` writes the nodes and marks the table lists as their +HTML elements, and keeps every other node whole: an element of class `adf-carry` holds the node's JSON in `data-adf` and shows the node's content, its links included, so a reader of the page loses no content. No stylesheet ships. The complete dialect: [`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md). @@ -483,21 +483,21 @@ content. No stylesheet ships. The complete dialect: | `paragraph` | `

` | | `rule` | `


` | | `text` marked `code`, `em`, `strong` | ``, ``, ``, the first mark outermost | -| any other attribute the node's type holds, on a paragraph, heading, quote, list, list item, code block or rule | `data-*` named as HTML's `dataset` names it: `

` | +| an attribute its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block or rule | `data-*` named as HTML's `dataset` names it: `

` | | any other node, or a node holding what its element cannot | `

its content

`, a `` inline | +- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, and a + card's or a carried link's in the carry element. Sanitize the HTML before showing a document from + an author you do not trust. - Text and attribute values are escaped: `&`, `<`, `>` and `"`. A node holding what HTML cannot hold, such as a carriage return or a control character, rides the carry element. - In a `
`, the carry element shows each block's content as a `

`, and a code block's as `

` keeping its indentation; in a ``, it parts blocks with `
`. It shows text a link marks as a link, a mention, status or emoji by its text, a date as `2026-09-13` in UTC, a - card as a link to its URL, an external image as a link to it, a stored one by its `alt`, and an - expand by its title. An image a link to another target marks shows that link, then `(image)` - linking to the image. What it cannot show leaves a note, `(image)`, inside the node's - link where it has one. -- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, and a - card's or a carried link's in the carry element. Sanitize the HTML before showing a document from - an author you do not trust. + card as a link to its URL, or to the target of a link marking it, an external image as a link to + it, a stored one by its `alt`, and an expand by its title. An image marked by a link to another + target shows that link, then `(image)` linking to the image. A node it cannot show leaves a note + instead, such as `(image)`; a link marking the node goes around the note. ## The errors diff --git a/ci.sh b/ci.sh index 7a16935..c6ff1dd 100755 --- a/ci.sh +++ b/ci.sh @@ -7,7 +7,7 @@ leg "install ($node_image)" in_image "$node_image" npm ci leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck leg "lint ($node_image)" in_image "$node_image" npm run lint -# The boundaries pass by finding nothing, so probe that each still fires: oxlint bumps automerge on green. Each line is a probe and the import a boundary refuses it. +# The boundaries pass by finding nothing, so probe that each still fires: oxlint bumps automerge on green. Each line names a probe file and an import its boundary refuses. boundary_probes="src/adf/boundary-probe.ts ../markdown/flavour.ts src/adf/boundary-probe-html.ts ../html/emit/adf-to-html.ts src/html/boundary-probe.ts ../markdown/flavour.ts diff --git a/docs/decisions.md b/docs/decisions.md index 33bd4b1..b3de160 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -398,19 +398,25 @@ themselves. The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*` for what HTML cannot express, text always escaped. No stylesheet ships. -The spellings, `spec/html.md`, are a writer panel's (2026-10-23): a product manager, an engineer and -a support agent picked, 3 of 3, an image standing alone as `

` over `
` and a -bare ``, a list item's first paragraph bare, `
  • Bolt
  • `, over `
  • Bolt

  • `, -`class="language-js"` over `lang-js`, and `
    ` over `
    `. For the carry element, seven readers -picked `data-adf` 6 of 7 over `data-adf-node`. Four of them wanted the class `adf-carry` and three -no class; this entry's stable `adf-*` classes settle it. +The spellings, `spec/html.md`, are four writer panels' (2026-10-23 and -24), each of a product +manager, an engineer and a support agent. The first picked each of these 3 of 3: -A second panel of the same three roles picked, 3 of 3: an attribute the element cannot express as -`data-local-id` over `data-adf-local-id` and `id`, since Atlassian's editor puts a `localId` on every -block and a block carried for it would show no element at all; inside a carry element a link as -`` over `text (url)` and over its text alone, a carried node's blocks as `

    ` over `
    ` and a -line feed, and media it cannot show as `(image)` over `(image not shown)` and -nothing. +- an image standing alone as `

    `, over `
    ` and a bare ``; +- a list item's first paragraph bare, `
  • Bolt
  • `, over `
  • Bolt

  • `; +- `class="language-js"`, over `lang-js`; +- `
    `, over `
    `. + +Seven readers picked `data-adf` for the carry element, 6 of 7, over `data-adf-node`. Four wanted the +class `adf-carry` and three no class; this entry's stable `adf-*` classes settle it. + +Atlassian's editor puts a `localId` on every block, so carrying each block holding one would leave +no element at all. The second panel picked each of these 3 of 3: + +- an attribute the element cannot express as `data-local-id`, over `data-adf-local-id` and `id`; +- a link inside a carry element as `
    `, over `text (url)` and its text alone; +- a carried node's blocks as `

    `, over `
    ` and a line feed; +- media a carry element cannot show as `(image)`, over `(image not shown)` and + nothing. A third panel, seven readers in the same three roles, was asked how to write a list item whose first paragraph holds an attribute, the shape of every item Atlassian's editor writes. It picked @@ -420,13 +426,14 @@ the `

    ` on a line of its own, 1 of 7, and over A fourth panel of the same three roles picked, 3 of 3, a code block inside a carry element as `

    …
    ` over `
    …
    ` and over a `

    ` whose lines part at `
    `, which -loses the indentation. All three wrote ``, which no option held. +loses the indentation. All three wrote ``, which no option held, so +the shown code keeps the class its code block's own element takes. -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. +Inside a carry element, an image marked by a link to another target shows as portable markdown +writes it inline (§A linked image standing inline in portable markdown, its link to another target, +is written `[alt](href) ([image](url))`), so both targets stay; Goal 7 counts them as content. A link +marking a node that shows a note goes around the note. Recorded 2026-10-24 without a panel of its +own: it joins the second panel's `` to that portable spelling. ## No runtime dependencies @@ -573,8 +580,8 @@ handles one cause alike whichever node, attribute or direction raised it. emitter carries such a node (2026-10-14). - A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document` emitting — no document holds one, so no round-trip crosses them (2026-09-23). -- A document whose markdown would be longer than V8's longest string is `unsupported-node-shape` - at the document's path, in either writer, and so is a directive `losslessMarkdownToAdf` reads +- A document whose output would be longer than V8's longest string is `unsupported-node-shape` + at the document's path, in every writer, and so is a directive `losslessMarkdownToAdf` reads whose CommonMark spelling would be: the document does not convert, and no spelling in the input causes the bound (§The code list). A carried node passes it on about 1 MB of deeply nested JSON, since the block carry indents two spaces a level; `todo.md` item 107 respells the carry in a @@ -831,10 +838,10 @@ that, JavaScriptCore throws `Out of memory`, which is `engine-limit` (§Which co `adfToHtml` writes every URL exactly as the ADF holds it, escaped as every attribute value is, `javascript:`, `vbscript:`, `file:` and `data:` included. That covers a link's `href`, an image's `src`, and the `href` of each link the carry element shows: a carried node's link mark, a card's -`url` or `data.url`, and an external image's `url`. Carrying such a link or image in the carry -element, as cmark's default leaves these URLs out of its HTML, was proposed and not taken: item 142 -weighs that against writing the URL. Carrying alone no longer keeps the URL out of the HTML, since -the carry element links what it shows, so that remedy would also drop the shown `href`. +`url` or `data.url`, and an external image's `url`. Carrying such a link or image, to leave the URL +out of the HTML as cmark does by default, was proposed and not taken; item 142 weighs it against +writing the URL. Carrying alone would not keep the URL out, since the carry element links what it +shows: the carry would also have to drop the shown `href`. ## Nothing recurses unbounded diff --git a/spec/html.md b/spec/html.md index b15b8ea..631d13d 100644 --- a/spec/html.md +++ b/spec/html.md @@ -6,39 +6,39 @@ for every other node (`docs/decisions.md` §The HTML dialect). The written form ## Blocks -One block a line: blocks are separated by a line feed, and a document holding any ends with one. A -document whose `content` is an empty array is the empty string. +Blocks are separated by a line feed, and a document holding any ends with one. A document whose +`content` is an empty array is the empty string. | ADF | HTML | | --- | --- | -| `paragraph` | `

    …

    ` | -| `heading`, `level` 1 to 6 | `

    …

    ` to `
    …
    ` | | `blockquote` | `
    `, a line feed, its blocks, a line feed, `
    ` | | `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 `
      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 ASCII whitespace (a tab, line feed, form feed, carriage return or space); no `class` where it holds no `language` | -| `rule` | `
        ` | +| `heading`, `level` 1 to 6 | `

        …

        ` to `
        …
        ` | +| `listItem` | its first paragraph straight after `
      8. `: bare where it holds content and no attribute, `
      9. Bolt
      10. `, and as its `

        ` element where it holds an attribute, `

      11. Bolt

      12. `; where other blocks follow, each after a line feed, and a line feed before ``. Where the first block is no paragraph, holds neither content nor an attribute, or rides the carry: `
      13. `, a line feed, its blocks, a line feed, `
      14. ` | | `mediaSingle`, centered, holding one external `media` | `

        …

        `, no `alt` where the media holds none; `

        ` where a link marks it | +| `orderedList` | as `bulletList` with `
          `; `
            ` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` | +| `paragraph` | `

            …

            ` | +| `rule` | `
            ` | A container holding no `content` key is its element holding nothing: `
            `, `
              `, `
            • `; so is a paragraph or heading, `

              `. -For a paragraph, heading, quote, list, list item, code block or rule, every other attribute its type -holds (`spec/flavour.md` §Block nodes) is a `data-*` attribute on its element, named as HTML's -`dataset` names it, `localId` as `data-local-id`, after the element's own attributes and sorted by -name. A string is its value; any other value is its JSON, compact: -`
                `, `
                `. A code block's
                -`data-*` attributes stand on its `
                `.
                +On a paragraph, heading, quote, list, list item, code block or rule, each attribute its type holds
                +(`spec/flavour.md` §Block nodes) that the element does not write as `level`, `order` or `language`
                +is a `data-*` attribute, named as HTML's `dataset` names it, `localId` as `data-local-id`, after the
                +element's own attributes and sorted by name. A string is its value; any other value is its JSON,
                +compact: `
                  `, `
                  `. A code
                  +block's `data-*` attributes stand on its `
                  `.
                   
                   ## Inline
                   
                   | ADF | HTML |
                   | --- | --- |
                  -| `text` | its text, escaped |
                   | `em`, `strong`, `code` | ``, ``, `` around the text |
                  -| `link`, holding `href` and an optional `title` | `` around the text |
                   | `hardBreak` | `
                  ` | +| `link`, holding `href` and an optional `title` | `
                  ` around the text | +| `text` | its text, escaped | A text node's marks nest in the order its `marks` array lists them, the first outermost: `manual`. Each text node writes its own elements, so two text nodes a link @@ -51,10 +51,10 @@ Attribute values are double-quoted. Every URL is written as the ADF holds it, wh (`docs/decisions.md` §HTML writes a URL as the ADF holds it, whatever its scheme): a link's `href`, an image's `src`, and the `href` of each link a carry element shows. -HTML text and attribute values cannot hold a carriage return, which a parser reads as a line feed, -a control other than a tab, line feed or form feed, a noncharacter, or a lone surrogate, which -UTF-8 cannot encode (WHATWG HTML §13.1.3). A node holding one in a value its element writes rides -the carry. +HTML text and attribute values cannot hold these (WHATWG HTML §13.1.3): any control but a tab, line +feed or form feed, so a carriage return too, which a parser reads as a line feed; a noncharacter; +and a lone surrogate, which UTF-8 cannot encode. A node holding one in a value its element writes +rides the carry. ## The carry element @@ -73,19 +73,24 @@ 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 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 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 - `

                  …
                  `, keeping its line feeds and spaces. Its class - is the one the code block's element takes, and it has none where the language is no class - token. In a ``, `
                  ` parts two blocks'. + `
                  …
                  `, keeping its line feeds and spaces. The `` + takes the class the code block's own element takes, and none where the language is not one + token free of ASCII whitespace. In a ``, a `
                  ` parts each block's content from the + next. - A hard break is `
                  `, and so is a line feed in text outside a `
                  `'s code block. - - Text a link marks is `` around it, and a card or an external image is a - link to its URL. - - An image a link to another target marks shows as portable markdown writes it inline, the link - around its `alt`, else the link's target, else its URL, then `image` linking to the image in - parentheses: `The moon (image)`. + - Text a link marks is `` around it, or the text alone where HTML cannot + hold the link's `href` or `title`. A card is a link to the target of a link marking it, else to + its URL. + - 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)`, + the word `image` linking to the image: + `The moon (image)`. - A node referencing what it cannot show is a note naming it, `(image)`: `image` for media with no `alt` or URL, `link card` for a card with neither URL nor name, an extension's key, else `extension`, for an extension with no text, and `synced block` for a `syncBlock`. A @@ -96,14 +101,15 @@ later release writes it as its own element. element cannot hold: - text holding no character, or a character HTML cannot hold; text holding a mark twice, or a mark with attributes besides a link's `href` and `title`; - - an unmarked text node straight after another, which would read back as one; + - an unmarked text node straight after one written as plain text, which would read back as one; - a link whose `href` or `title` holds a character HTML cannot hold, and an image whose `url` or `alt` holds one or whose link's `href` or `title` does; - an image holding an attribute besides its `center` layout and its media's `type`, `url` and 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 ASCII 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/document.ts b/src/adf/document.ts index 7ee8dc0..478dc0e 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -55,7 +55,7 @@ export function writableContent(document: AdfDocument): Result longestOutput ? tooLong() : success(document.content) } diff --git a/src/conformance/corpus.test.ts b/src/conformance/corpus.test.ts index ce0d033..6b4fa7c 100644 --- a/src/conformance/corpus.test.ts +++ b/src/conformance/corpus.test.ts @@ -150,7 +150,7 @@ for (const name of names(realPayloadsRoot, '.json')) { }) } -// The element each block an editor writes with no attribute but `localId`, and `level` or `order`, opens with; every other block is carried. +// The opening tag of each block type whose only attributes are `localId`, `level` and `order`, as the editor writes them. const blockElements: Readonly> = { blockquote: '') + 2).replace(/<\/(?:div|span)>(?:<\/p>)?\n$/, '') } -test('writes paragraphs, headings and rules as their elements, one block a line', () => { +test('writes paragraphs, headings and rules as their elements, separated by line feeds', () => { const heading = (level: number): AdfNode => ({ attrs: { level }, content: [text(`Level ${level}`)], type: 'heading' }) assert.equal(html(adfToHtml(document(heading(1), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6)))), '

                  Level 1

                  \n

                  Read the manual.

                  \n
                  \n
                  Level 6
                  \n') assert.equal(html(adfToHtml(document(paragraph(), { attrs: { level: 2 }, type: 'heading' }))), '

                  \n

                  \n') @@ -75,7 +75,7 @@ test('writes the marks CommonMark spells, the first mark outermost', () => { assert.equal(html(adfToHtml(document(paragraph(text('Line one'), { type: 'hardBreak' }, text('Line two'))))), '

                  Line one
                  Line two

                  \n') }) -test('escapes text and attribute values, and keeps tabs, line feeds and form feeds', () => { +test('escapes text, and keeps tabs, line feeds and form feeds', () => { assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\tf\ng\fh'))))), '

                  a < b && c > "d" \'e\'\tf\ng\fh

                  \n') }) @@ -88,7 +88,7 @@ test('carries text holding a character HTML text cannot hold, escaping it in the assert.equal(html(adfToHtml(document(paragraph(text('a\u{1fffe}b', { type: 'underline' }))))).includes('"text":"a\\ud83f\\udffeb"'), true) }) -test('writes lists tight, an item holding more than one paragraph or a list in elements after the first', () => { +test('writes lists tight, and an item\'s blocks after its first paragraph as elements on lines of their own', () => { assert.equal(html(adfToHtml(document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))), '
                    \n
                  • Bolt
                  • \n
                  • Nut
                  • \n
                  \n') const listed: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' } assert.equal(html(adfToHtml(document(listed))), '
                    \n
                  1. Bolt\n
                      \n
                    • M8
                    • \n
                    \n
                  2. \n
                  3. a\n

                    b

                    \n
                  4. \n
                  \n') @@ -195,7 +195,7 @@ test('shows a code block in a span carry as text, its line feeds line breaks', ( assert.equal(shown(html(adfToHtml(document(paragraph({ attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }))))), 'if ready:
                  deploy()') }) -test('shows the content a carried node holds in an attribute, a link to what it links to', () => { +test('shows the content a carried node holds in an attribute, and links a card or an external image to its URL', () => { const inline = (node: AdfNode): string => shown(html(adfToHtml(document(paragraph(node))))) assert.equal(inline({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14') assert.equal(inline({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄') @@ -210,7 +210,7 @@ test('shows the content a carried node holds in an attribute, a link to what it assert.equal(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { title: 'No target' }, type: 'link' }], type: 'media' }), '

                  The moon

                  ') }) -test('shows a carried image a link to another target marks as that link, then a link to the image reading image', () => { +test('shows a carried image marked by a link to another target as that link, then a link to the image reading image', () => { const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' } const image = (attrs: AdfAttributes): string => shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs, marks: [link], type: 'media' }], type: 'mediaSingle' })))) assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }), '

                  The moon (image)

                  ') @@ -269,7 +269,7 @@ test('carries a node holding what its element cannot', () => { assert.equal(carriedBlock({ attrs: { layout: 'center' }, content: [{ attrs: { alt: '', type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true) }) -test('carries inline what its element cannot hold, and a text node an unmarked one before it would join', () => { +test('carries inline what its element cannot hold, and a text node that would join the unmarked one before it', () => { assert.equal(carriedSpans(text('a'), text('b')), 1) assert.equal(carriedSpans(text('a'), text('b'), text('c')), 1) assert.equal(carriedSpans(text('a'), text('b', { type: 'em' }), text('c')), 0) diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index e949c04..ee9e00a 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -11,7 +11,7 @@ import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.t import { plainImage } from '../../adf/plain-image.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' -// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; an item's attributes are its element's, checked by its list. +// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; `attributes`: the `
                1. `'s `data-*` attributes, built when its list checked the item. type Block = { depth: number; kind: 'block'; node: AdfNode; path: ConvertErrorPath } type Item = { attributes: string; depth: number; kind: 'item'; node: AdfNode; path: ConvertErrorPath } type Pending = Block | Item | string @@ -99,7 +99,7 @@ function containerPieces(tag: string, attributes: string, children: Pending[] | return [`<${tag}${attributes}>\n`, ...children, `\n`] } -// An item's blocks sit at its own level, one below its list; its first paragraph stands on the item's line, panel picks, docs/decisions.md §The HTML dialect. +// An item's first paragraph stands on the item's line, as the writer panels picked (docs/decisions.md §The HTML dialect). function writeItem({ attributes, depth, node, path }: Item): Result { if (node.content === undefined) return success([`
                2. `]) const blocks = blockPieces(node.content, path, depth) @@ -111,7 +111,7 @@ function writeItem({ attributes, depth, node, path }: Item): Result { return success(blocks.length === 1 ? [`${line.value}`] : [`${line.value}`, ...blocks.slice(1), '\n']) } -// `undefined` where the paragraph takes a line of its own: where it rides the carry, or holds neither content nor an attribute, which bare would read back as no paragraph. +// `undefined` where the paragraph takes a line of its own: where it rides the carry, or holds neither content nor an attribute: written bare, it would read back as no paragraph. function tryFirstParagraph(paragraph: AdfNode, path: ConvertErrorPath): Result | undefined { const attributes = dataAttributes(paragraph, blockNodes.paragraph.attributes, []) if (attributes !== '') return inlineElement('p', attributes, nodeContent(paragraph), path) @@ -126,7 +126,7 @@ function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result | u return html === undefined ? undefined : success(html) } -// `undefined` where the attributes are, so the node rides the carry. +// `undefined` where `attributes` is undefined, so the node rides the carry. function inlineElement(tag: string, attributes: string | undefined, nodes: readonly AdfNode[], path: ConvertErrorPath): Result | undefined { if (attributes === undefined) return undefined const inline = inlineHtml(nodes, path) diff --git a/src/html/emit/carry-element.ts b/src/html/emit/carry-element.ts index 4e0bfd5..e06ffb3 100644 --- a/src/html/emit/carry-element.ts +++ b/src/html/emit/carry-element.ts @@ -30,7 +30,7 @@ export function carryElement(tag: string, json: string, content: string): string function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] { const shown: ShownBlock[] = [] let current: ShownBlock = { code: undefined, html: '' } - // Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, after which the content stands in `code` again. + // Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, and what follows stands in that entry's `code`, the code block around the block that ended. const pending: { code: AdfNode | undefined; node: AdfNode | null }[] = [{ code: undefined, node: root }] for (let next = pending.pop(); next !== undefined; next = pending.pop()) { const { node } = next @@ -48,7 +48,7 @@ function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] { return shown } -// 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. +// The node's own part of what its carry element shows. function shownPiece(node: AdfNode, lineFeed: string): string { if (node.type === 'hardBreak') return '
                  ' const mark = nodeMarks(node).find((held) => held.type === 'link') @@ -62,7 +62,7 @@ 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. +// As portable markdown writes it inline (docs/decisions.md §The HTML dialect). 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 })})` diff --git a/src/html/emit/escaping.ts b/src/html/emit/escaping.ts index 101ac51..e0e706e 100644 --- a/src/html/emit/escaping.ts +++ b/src/html/emit/escaping.ts @@ -1,7 +1,7 @@ import type { JsonValue } from '../../json-value.ts' import type { LinkAttrs } from '../../adf/mark-attributes.ts' -// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): a control other than ASCII whitespace or a carriage return, which a parser reads as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode. +// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): any control but a tab, line feed or form feed, a carriage return among them, since a parser reads it as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode. const unwritable = /(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u const unwritables = new RegExp(unwritable.source, 'gu') @@ -29,7 +29,6 @@ export function optionalAttribute(name: string, value: string | undefined): stri return escaped === undefined ? undefined : ` ${name}="${escaped}"` } -// The text a carry element shows, dropping what HTML cannot hold, each line feed spelled `lineFeed`. export function shownValue(value: string, lineFeed: string): string { return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', lineFeed) } diff --git a/todo.md b/todo.md index fce1247..e043637 100644 --- a/todo.md +++ b/todo.md @@ -242,14 +242,14 @@ the union. Found by the technical-principles audit of item 78, 2026-10-05. ### 141. Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element. -GFM spells `strike`, and no other HTML item writes it: item 130 wrote the marks CommonMark spells. -A writer panel picks `` or ``. +GFM spells `strike`, and no other HTML item writes it: `adfToHtml` writes only the marks CommonMark +spells. A writer panel picks `` or ``. ### 135. Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element. -A writer panel picks the spellings; an external image already has one from item 130. Atlassian's -editor puts `localId`, `width` and `widthType` on every `mediaSingle`, so every editor-written -image rides the carry until this item. +A writer panel picks the spellings; a plain external image already has one, `

                  `. +Atlassian's editor puts `localId`, `width` and `widthType` on every `mediaSingle`, so every +editor-written image rides the carry until this item. ### 136. Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.