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

Ship adfToHtml: semantic HTML for the CommonMark blocks and marks, an adf-carry element for the rest

This commit is contained in:
2026-10-23 12:50:48 +02:00
committed by Lillem4n
parent 33ef8db768
commit 6fe36d5947
33 changed files with 826 additions and 147 deletions
+2 -1
View File
@@ -66,7 +66,8 @@ In `docs/decisions.md`:
- The CommonMark suite checks three ways
- The flavour spec is read as a source
- The node tables answer to Atlassian's schema
- Markdown past V8's longest string is refused on every engine
- Output past V8's longest string is refused on every engine
- A URL a browser would run is carried, never written
- Nothing recurses unbounded
- JSON text is read by `parseJsonText`
- No value `JSON.parse` or `structuredClone` builds loops a call
+4
View File
@@ -2,6 +2,10 @@
## 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. Every other node
is an element of class `adf-carry` holding the node's JSON in `data-adf` and showing its text. A
link or image whose URL a browser would run, such as `javascript:`, rides that element too.
- **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
reads as a plain mention. A link or directive mark around only nodes that cannot take it is
+56 -7
View File
@@ -1,9 +1,9 @@
# @larvit/adf-codec — ADF to markdown, markdown to ADF
# @larvit/adf-codec — ADF to markdown and HTML, markdown to ADF
[![npm](https://img.shields.io/npm/v/@larvit/adf-codec)](https://www.npmjs.com/package/@larvit/adf-codec)
[![CI](https://github.com/larvit/adf-codec/actions/workflows/ci.yml/badge.svg)](https://github.com/larvit/adf-codec/actions/workflows/ci.yml)
Convert Atlassian Document Format (ADF), the rich text of Jira and Confluence, to and from markdown, with a lossless round-trip.
Convert Atlassian Document Format (ADF), the rich text of Jira and Confluence, to and from markdown, with a lossless round-trip, and to HTML.
For application developers whose app shows, edits, exports or generates the rich text Atlassian
products store as ADF.
@@ -19,6 +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
text.
- **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.
@@ -67,6 +70,18 @@ if (portable.ok) console.log(portable.value)
// > Check the collation before importing.
```
### ADF to HTML
Full reference: [HTML](#html).
```ts
import { adfToHtml } from '@larvit/adf-codec'
const html = adfToHtml(doc) // the panel above
if (html.ok) console.log(html.value)
// <div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;warning&quot;},…}">Check the collation before importing.</div>
```
### Markdown to ADF
Full reference: [The calls](#the-calls) and [Portable markdown](#portable-markdown).
@@ -150,6 +165,7 @@ Nothing throws. `code` is from a closed list, so a `switch` with no `default` co
- [The calls](#the-calls)
- [The lossless flavour](#the-lossless-flavour)
- [Portable markdown](#portable-markdown)
- [HTML](#html)
- [The errors](#the-errors)
- [The guarantees](#the-guarantees)
- [The package](#the-package)
@@ -217,7 +233,7 @@ same marks and link.
| Package | Converts | ADF to markdown to ADF | A node type it does not know | Formats | Direct runtime dependencies |
| --- | --- | --- | --- | --- | --- |
| `@larvit/adf-codec` | both ways | the same document | kept | markdown: lossless, portable; CommonMark in | 0 |
| `@larvit/adf-codec` | both ways | the same document | kept | markdown: lossless, portable; CommonMark in; HTML out | 0 |
| `@atlaskit/editor-markdown-transformer` 6.3.1 | markdown to ADF, through `@atlaskit/editor-json-transformer` 10.0.0; ADF to markdown throws "This is not implemented yet" | – | – | markdown | 5 |
| `adf-to-markdown` 1.0.1 | ADF to markdown | – | dropped with its text | markdown | 0 |
| `adf-to-md` 1.2.1 | ADF to markdown | – | dropped with its text, as are mentions, statuses and panels | markdown | 1 |
@@ -240,12 +256,15 @@ commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToPortableMarkdown(doc: AdfDocument): Result<string>
portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string>
```
Each call's name says which markdown it reads or writes:
Each call's name says which format it reads or writes:
| Call | Markdown | Refuses with |
| Call | Reads or writes | Refuses with |
| --- | --- | --- |
| `adfToHtml` | writes HTML: the elements [HTML](#html) lists, and an `adf-carry` element holding everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
@@ -425,6 +444,34 @@ saving what this pair read replaces mentions, attachments and macros with text.
position, the same on every read. Join markdown bound for one document and read it once: the same
markdown read twice into one document repeats its ids.
## 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
`data-adf` and shows the node's text, 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).
| ADF | HTML |
| --- | --- |
| `blockquote` | `<blockquote>` |
| `bulletList`, `orderedList`, `listItem` | `<ul>`, `<ol>`, `<ol start="3">` for an `order` of 3, and `<li>`; an item's first paragraph stands bare, `<li>Bolt</li>` |
| `codeBlock` | `<pre><code class="language-js">`, no `class` without a language |
| `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 |
| `paragraph` | `<p>` |
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<code>`, `<em>`, `<strong>`, the first mark outermost |
| any other node, or a node holding what its element cannot | `<div class="adf-carry" data-adf="{…}">its text</div>`, a `<span>` inline |
- Text and attribute values are escaped: `&`, `<`, `>`, `"` and a carriage return.
- The carry element shows a mention, status or emoji by its text, a date as `2026-09-13` in UTC, a
card by its URL, an image by its `alt`, else its URL, and an expand by its title.
- A link or image whose URL a browser would run or read as a file, such as `javascript:`, rides
the carry element, so the HTML follows no such URL.
## The errors
Serves [Goals](#goals) 1. An ADF node type this version does not know is not an error: the lossless
@@ -475,7 +522,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r
### Emitting
`adfToLosslessMarkdown` and `adfToPortableMarkdown` raise these, and the codes under
`adfToLosslessMarkdown`, `adfToPortableMarkdown` and `adfToHtml` raise these, and the codes under
[Either direction](#either-direction):
| Code | Fires when | What you can do |
@@ -514,7 +561,8 @@ Every call can raise these:
- **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell.
This is a bug in this library: report it.
- **Past the engine's longest string**:
- a writer's markdown would pass 536870888 characters, V8's longest string, on every engine;
- a writer's markdown or HTML would pass 536870888 characters, V8's longest string, on every
engine;
- in `adfToPortableMarkdown`, the document's text alone would, edge whitespace included;
- in `losslessMarkdownToAdf`, a directive's plain CommonMark would;
- in every call, a conversion passes V8's or SpiderMonkey's longest string.
@@ -616,6 +664,7 @@ Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/do
| `ParseError` | type | the error a markdown reader returns, its `position` always set |
| `Result` | type | every call's return value |
| `SourcePosition` | type | an error's place in the markdown |
| `adfToHtml` | function | ADF to HTML |
| `adfToLosslessMarkdown` | function | ADF to the lossless flavour |
| `adfToPortableMarkdown` | function | ADF to portable markdown, lossy |
| `commonMarkToAdf` | function | strict CommonMark to ADF |
+25 -5
View File
@@ -398,6 +398,13 @@ 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 `<p><img …></p>` over `<figure>` and a
bare `<img>`, a list item's first paragraph bare, `<li>Bolt</li>`, over `<li><p>Bolt</p></li>`,
`class="language-js"` over `lang-js`, and `<br>` over `<br />`. 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.
## No runtime dependencies
2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
@@ -775,13 +782,13 @@ node takes. Value sets stay documentation, since any value round-trips. What the
attribute as a gap, a type as carried — so a re-pin adding either goes red until someone spells it
or pins it.
## Markdown past V8's longest string is refused on every engine
## Output past V8's longest string is refused on every engine
2026-10-11, the maintainer. Goals 1 and 6. Valid while V8's longest string, 2^29 − 24 characters,
is the shortest of the engines the library claims.
2026-10-11, HTML 2026-10-23, the maintainer. Goals 1 and 6. Valid while V8's longest string,
2^29 − 24 characters, is the shortest of the engines the library claims.
Every writer, and `losslessMarkdownToAdf`'s `commonMarkSpelling` ask, refuses markdown longer than
2^29 − 24 characters (`longestMarkdown`), so the same document converts alike everywhere. A writer
Every writer, and `losslessMarkdownToAdf`'s `commonMarkSpelling` ask, refuses markdown or HTML
longer than 2^29 − 24 characters (`longestOutput`), so the same document converts alike everywhere. A writer
first refuses a document whose text alone passes the bound: the lossless writer spells every
character of it, and the portable reduction refuses on the caller's text, edge whitespace and all,
since merging that text first could build past the bound. Each block, the writer's result and the
@@ -794,6 +801,19 @@ carry's two-space JSON, which indents every value its depth over, stops at the b
JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. Past
that, JavaScriptCore throws `Out of memory`, which is `engine-limit` (§Which code a cause takes).
## A URL a browser would run is carried, never written
2026-10-23, written with `adfToHtml`, for the maintainer's review. Goals 1 and 6: an export tool's
HTML runs no script. Valid while a consumer may show the HTML in a browser without sanitizing it.
`adfToHtml` writes no `href` or `src` a browser would run as script or read as a local file: the
link's text or the image rides the carry element, whose `data-adf` no browser follows, so nothing
is lost. The list is cmark's, the CommonMark reference implementation's, which leaves these URLs
out of its HTML by default: `javascript:`, `vbscript:`, `file:`, and `data:` other than a PNG, GIF,
JPEG or WebP image, any case. A browser drops tabs and line breaks anywhere in a URL, and leading
control characters and spaces, before it reads the scheme (the WHATWG URL standard), so the check
reads the URL so too: a tab inside `javascript:` still runs.
## Nothing recurses unbounded
2026-08-25, the directive form's count 2026-09-18, the maintainer. Goal 1. Valid while an engine's
+4
View File
@@ -478,3 +478,7 @@ Read most (opens and searches):
## Reading cost, chunk fc577244d095, finished 2026-10-23T10:08:26Z
89461 new input tokens before the first commit.
## Reading cost, chunk f305730665ab, finished 2026-10-23T10:31:43Z
78044 new input tokens before the first commit.
+3 -2
View File
@@ -1,8 +1,9 @@
import { adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, isAdfDocument, losslessMarkdownToAdf, portableMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, isAdfDocument, losslessMarkdownToAdf, portableMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
const emitted: Result<string> = adfToLosslessMarkdown(document)
const htmlEmitted: Result<string> = adfToHtml(document)
const parsed: Result<AdfDocument, ParseError> = losslessMarkdownToAdf('x\n')
const portableEmitted: Result<string> = adfToPortableMarkdown(document)
const portableParsed: Result<AdfDocument, ParseError> = portableMarkdownToAdf('x\n')
@@ -11,4 +12,4 @@ const guarded: boolean = isAdfDocument(document)
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
export const surface = { code, commonMarkParsed, guarded, line, portableEmitted, portableParsed }
export const surface = { code, commonMarkParsed, guarded, htmlEmitted, line, portableEmitted, portableParsed }
+3 -1
View File
@@ -1,9 +1,10 @@
{
"name": "@larvit/adf-codec",
"version": "0.2.0",
"description": "Convert Atlassian Document Format (ADF), the rich text of Jira and Confluence, to and from markdown, with a lossless round-trip.",
"description": "Convert Atlassian Document Format (ADF), the rich text of Jira and Confluence, to and from markdown, with a lossless round-trip, and to HTML.",
"keywords": [
"adf",
"adf-to-html",
"adf-to-markdown",
"atlassian",
"atlassian-document-format",
@@ -11,6 +12,7 @@
"confluence",
"converter",
"gfm",
"html",
"jira",
"lossless",
"markdown",
+78
View File
@@ -0,0 +1,78 @@
# The HTML dialect
The HTML `adfToHtml` writes: semantic elements for the nodes and marks below, and the carry element
for every other node (`docs/decisions.md` §The HTML dialect). The written form is contract
(`docs/decisions.md` §The formats are API).
## 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.
| ADF | HTML |
| --- | --- |
| `paragraph` | `<p>…</p>` |
| `heading`, `level` 1 to 6 | `<h1>…</h1>` to `<h6>…</h6>` |
| `blockquote` | `<blockquote>`, a line feed, its blocks, a line feed, `</blockquote>` |
| `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; no `start` where the list holds no `order` |
| `listItem` | `<li>…</li>`: a first paragraph holding content bare, its inline content straight after `<li>`, then each other block after a line feed and a line feed before `</li>`; with no bare paragraph, `<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` |
| `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 |
A container holding no `content` key is its element holding nothing: `<blockquote></blockquote>`,
`<ul></ul>`, `<li></li>`; so is a paragraph or heading, `<p></p>`.
## Inline
| ADF | HTML |
| --- | --- |
| `text` | its text, escaped |
| `em`, `strong`, `code` | `<em>`, `<strong>`, `<code>` around the text |
| `link`, holding `href` and an optional `title` | `<a href="…" title="…">` around the text |
| `hardBreak` | `<br>` |
A text node's marks nest in the order its `marks` array lists them, the first outermost:
`<strong><em>manual</em></strong>`. Each text node writes its own elements, so two text nodes a link
marks are two `<a>` elements.
## Escaping
Text and attribute values escape `&`, `<`, `>` and `"` as `&amp;`, `&lt;`, `&gt;` and `&quot;`,
and a carriage return as `&#13;`, which an HTML parser would otherwise read as a line feed.
Attribute values are double-quoted.
## The carry element
A node the sections above cannot write where it stands is the carry element: `<div>` in block
position, `<span>` inline, of class `adf-carry`, holding the node's JSON in `data-adf` and showing
the node's text.
```html
<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},…,&quot;type&quot;:&quot;panel&quot;}">Check the collation.</div>
<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>
```
- `data-adf` holds the node's JSON compact, keys sorted, as `!adf:carry` does in markdown, its type
included. A document holding no `content` key is the block carry of `{"type":"doc","version":1}`.
- The text 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
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. A line feed parts two blocks and
stands for a hard break. Null characters are dropped.
- A node rides the carry where it holds an attribute, mark or key its element does not write, an
empty `attrs`, `content` or `marks`, or a value its element cannot hold:
- text holding no character, or a null character, which 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;
- a link or an image whose URL a browser would run or read as a file: a `javascript:`,
`vbscript:` or `file:` URL, or `data:` other than a PNG, GIF, JPEG or WebP image, read after
dropping tabs and line breaks and a leading control character or space (`docs/decisions.md`
§A URL a browser would run is carried, never written);
- a code block holding more than one text node, a marked one, or a language holding whitespace;
- a list holding a node that is no `listItem`, or an item holding attributes or marks: the whole
list rides the carry;
- a `listItem` outside a list, an inline node in block position and a block node inline.
- A block carry's JSON nests at most 500 levels less the depth it stands at, an inline carry's at
most 500, as in markdown.
+17
View File
@@ -0,0 +1,17 @@
import type { AdfNode } from './document.ts'
import type { JsonSpelling } from '../canonical-json.ts'
import { canonicalJsonPieces } from '../canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { isJsonValue, nestingDepth } from '../json-value.ts'
import { joinedWithin } from '../length-bound.ts'
// `spelled` is the node as the carry holds it, `type` dropped where the format names it apart; `levels` the nesting its position leaves.
export function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
const headroom = levels - nestingDepth(node)
if (!isJsonValue(spelled) || headroom < 0) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
}
// Two-space JSON indents each value by its depth, so a short compact value can spell past the bound: join only up to it.
const json = joinedWithin(canonicalJsonPieces(spelled, spelling))
return json.ok ? success({ headroom, json: json.value }) : json
}
+29
View File
@@ -0,0 +1,29 @@
import type { AdfNode } from './document.ts'
import { linkTarget, linkTargetMark, type LinkTarget } from './mark-attributes.ts'
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
// `alt` undefined where the media holds none: an empty alt builds no `alt`, so a media holding an empty one is no plain image.
export type ExternalImage = { alt: string | undefined; link: LinkTarget | undefined; url: string }
// The plain image, the one shape each format writes as its own image element: a centered `mediaSingle` holding one external `media`, which a link may mark.
export function centeredImage(content: AdfNode[]): AdfNode {
return { attrs: { layout: 'center' }, content, type: 'mediaSingle' }
}
export function externalMedia(alt: string, url: string, link?: LinkTarget): AdfNode {
const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
return link === undefined ? media : { ...media, marks: [linkTargetMark(link)] }
}
// `undefined` unless the node is exactly the plain image its parts build.
export function plainImage(node: AdfNode): ExternalImage | undefined {
const [media] = nodeContent(node)
if (media === undefined) return undefined
const { alt, url } = nodeAttrs(media)
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined
const [mark] = nodeMarks(media)
const link = mark === undefined ? undefined : linkTarget(nodeAttrs(mark))
const read = centeredImage([externalMedia(alt ?? '', url, link)])
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? { alt, link, url } : undefined
}
+15
View File
@@ -1,3 +1,4 @@
import type { AdfAttributes, AdfMark } from './document.ts'
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export const blockMarkAttributes = {
@@ -26,3 +27,17 @@ export type MarkType = keyof typeof markAttributes
export function isMarkType(type: string): type is MarkType {
return Object.hasOwn(markAttributes, type)
}
export type LinkTarget = { href: string; title?: string }
export function linkTargetMark({ href, title }: LinkTarget): AdfMark {
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
}
// `undefined` where `href` is no string, or `title` is there and no string; any other key is the caller's to check.
export function linkTarget(attrs: AdfAttributes): LinkTarget | undefined {
const { href, title } = attrs
if (typeof href !== 'string') return undefined
if (title === undefined) return { href }
return typeof title === 'string' ? { href, title } : undefined
}
+60
View File
@@ -0,0 +1,60 @@
import type { AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { nodeAttrs } from './document.ts'
// The text a node shows in place of itself, read from the attribute holding its content; `undefined` where it holds none.
export function shownText(node: AdfNode): string | undefined {
const attrs = nodeAttrs(node)
switch (node.type) {
case 'blockCard':
case 'embedCard':
case 'inlineCard':
return visibleText(attrs['url']) ?? visibleText(cardData(node)['name']) ?? cardUrl(node)
case 'date':
return isoDate(attrs['timestamp'])
case 'emoji':
return visibleText(attrs['text']) ?? stringValue(attrs['shortName'])
case 'expand':
case 'nestedExpand':
case 'status':
return stringValue(node.type === 'status' ? attrs['text'] : attrs['title'])
case 'extension':
case 'inlineExtension':
return visibleText(attrs['text'])
case 'media':
case 'mediaInline':
return visibleText(attrs['alt']) ?? (attrs['type'] === 'external' ? visibleText(attrs['url']) : undefined)
case 'mention': {
const id = attrs['id']
return visibleText(attrs['text']) ?? (typeof id === 'string' && id !== '' ? `@${id}` : undefined)
}
default:
return undefined
}
}
export function cardUrl(card: AdfNode): string | undefined {
return visibleText(nodeAttrs(card)['url']) ?? visibleText(cardData(card)['url'])
}
// `undefined` where the value is no string or holds only whitespace and null characters.
export function visibleText(value: unknown): string | undefined {
return typeof value === 'string' && /[^\s\0]/.test(value) ? value : undefined
}
function cardData(card: AdfNode): Readonly<Record<string, JsonValue>> {
const data = nodeAttrs(card)['data']
return typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
}
function isoDate(timestamp: unknown): string | undefined {
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
const date = new Date(milliseconds)
if (Number.isNaN(date.getTime())) return undefined
const iso = date.toISOString()
return iso.slice(0, iso.indexOf('T'))
}
function stringValue(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
+11
View File
@@ -4,6 +4,7 @@ import test from 'node:test'
import type { AdfNode } from '../adf/document.ts'
import { adfDocument, blockTaskItemChildren, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToHtml } from '../html/emit/adf-to-html.ts'
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPortableMarkdown, reduceToPortable } from '../markdown/portable/adf-to-portable-markdown.ts'
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
@@ -55,6 +56,16 @@ test('a generated document writes portable markdown refusing only what the guard
)
})
test('a generated document writes HTML', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const written = adfToHtml(document)
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
}),
propertyRuns(gateRuns),
)
})
test('a generated document writes the portable markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
+3 -2
View File
@@ -10,7 +10,8 @@ import type { LinkDefinition } from '../markdown/commonmark/link-syntax.ts'
import { blockArgument } from '../markdown/directive/block.ts'
import { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts'
import { blockNodes } from '../adf/block-nodes.ts'
import { centeredImage, externalMedia } from '../markdown/external-image.ts'
import { centeredImage, externalMedia } from '../adf/external-image.ts'
import { definitionTarget } from '../markdown/mark-spellings.ts'
import { directivePrefix } from '../markdown/directive/syntax.ts'
import { emptyKeys, mergeAdjacentText, nodeContent } from '../adf/document.ts'
import { inlineNodes } from '../adf/inline-nodes.ts'
@@ -95,7 +96,7 @@ function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]
}
function externalImage({ alt, link, url }: { alt: string | undefined; link: LinkDefinition | undefined; url: string }): AdfNode {
return centeredImage([externalMedia(alt ?? '', url, link)])
return centeredImage([externalMedia(alt ?? '', url, link === undefined ? undefined : definitionTarget(link))])
}
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { Result } from '../../result.ts'
import { adfToHtml } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function html(result: Result<string>): string {
return result.ok ? result.value : `${result.error.code}: ${result.error.message}`
}
function paragraph(...content: AdfNode[]): AdfNode {
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
function item(...content: AdfNode[]): AdfNode {
return { content, type: 'listItem' }
}
test('writes paragraphs, headings and rules as their elements, one block a line', () => {
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)))), '<h1>Level 1</h1>\n<p>Read the manual.</p>\n<hr>\n<h6>Level 6</h6>\n')
assert.equal(html(adfToHtml(document(paragraph(), { attrs: { level: 2 }, type: 'heading' }))), '<p></p>\n<h2></h2>\n')
})
test('writes the marks CommonMark spells, the first mark outermost', () => {
const link: AdfMark = { attrs: { href: 'https://example.com/a?b=1&c=2', title: 'The "A" page' }, type: 'link' }
const line = paragraph(text('Read '), text('the', { type: 'em' }), text(' '), text('manual', { type: 'strong' }, { type: 'em' }), text(': '), text('npm ci', { type: 'code' }, link), text(' or '), text('this', link))
assert.equal(
html(adfToHtml(document(line))),
'<p>Read <em>the</em> <strong><em>manual</em></strong>: <code><a href="https://example.com/a?b=1&amp;c=2" title="The &quot;A&quot; page">npm ci</a></code> or <a href="https://example.com/a?b=1&amp;c=2" title="The &quot;A&quot; page">this</a></p>\n',
)
assert.equal(html(adfToHtml(document(paragraph(text('Line one'), { type: 'hardBreak' }, text('Line two'))))), '<p>Line one<br>Line two</p>\n')
})
test('escapes text and attribute values, and a carriage return so an HTML parser keeps it', () => {
assert.equal(html(adfToHtml(document(paragraph(text('a < b && c > "d" \'e\'\r\nf'))))), '<p>a &lt; b &amp;&amp; c &gt; &quot;d&quot; \'e\'&#13;\nf</p>\n')
})
test('writes lists tight, an item holding more than one paragraph or a list in elements after the first', () => {
assert.equal(html(adfToHtml(document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))), '<ul>\n<li>Bolt</li>\n<li>Nut</li>\n</ul>\n')
const nested: 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(nested))), '<ol start="3">\n<li>Bolt\n<ul>\n<li>M8</li>\n</ul>\n</li>\n<li>a\n<p>b</p>\n</li>\n</ol>\n')
assert.equal(html(adfToHtml(document({ content: [item({ type: 'rule' }), { type: 'listItem' }], type: 'orderedList' }))), '<ol>\n<li>\n<hr>\n</li>\n<li></li>\n</ol>\n')
assert.equal(html(adfToHtml(document({ content: [item(paragraph())], type: 'bulletList' }))), '<ul>\n<li>\n<p></p>\n</li>\n</ul>\n')
})
test('writes quotes and code blocks, the language a class', () => {
assert.equal(html(adfToHtml(document({ content: [paragraph(text('Quoted'))], type: 'blockquote' }))), '<blockquote>\n<p>Quoted</p>\n</blockquote>\n')
assert.equal(html(adfToHtml(document({ attrs: { language: 'javascript' }, content: [text('if (a < b)\n run()')], type: 'codeBlock' }))), '<pre><code class="language-javascript">if (a &lt; b)\n run()</code></pre>\n')
assert.equal(html(adfToHtml(document({ content: [text('plain')], type: 'codeBlock' }, { type: 'codeBlock' }))), '<pre><code>plain</code></pre>\n<pre><code></code></pre>\n')
})
test('writes an external image standing alone in a paragraph, a link around it where one marks it', () => {
const image = (media: AdfNode): AdfNode => ({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' })
assert.equal(html(adfToHtml(document(image({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, type: 'media' })))), '<p><img src="https://example.com/moon.jpg" alt="The moon"></p>\n')
const linked = image({ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'media' })
assert.equal(html(adfToHtml(document(linked))), '<p><a href="https://example.com/"><img src="https://example.com/moon.jpg"></a></p>\n')
})
test('carries every other node in an adf-carry element holding its JSON and showing its text', () => {
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [paragraph(text('Check the collation.')), paragraph(text('Then import.'))], type: 'panel' }
assert.equal(
html(adfToHtml(document(panel))),
'<div class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;panelType&quot;:&quot;info&quot;},&quot;content&quot;:[{&quot;content&quot;:[{&quot;text&quot;:&quot;Check the collation.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;},{&quot;content&quot;:[{&quot;text&quot;:&quot;Then import.&quot;,&quot;type&quot;:&quot;text&quot;}],&quot;type&quot;:&quot;paragraph&quot;}],&quot;type&quot;:&quot;panel&quot;}">Check the collation.\nThen import.</div>\n',
)
const mention: AdfNode = { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }
assert.equal(html(adfToHtml(document(paragraph(text('Ask '), mention)))), '<p>Ask <span class="adf-carry" data-adf="{&quot;attrs&quot;:{&quot;id&quot;:&quot;5b10a2&quot;,&quot;text&quot;:&quot;@Mikael&quot;},&quot;type&quot;:&quot;mention&quot;}">@Mikael</span></p>\n')
})
test('shows the content a carried node holds in an attribute', () => {
const shown = (node: AdfNode): string => {
const written = html(adfToHtml(document(paragraph(node))))
return written.slice(written.indexOf('">') + 2, written.indexOf('</span>'))
}
assert.equal(shown({ attrs: { timestamp: '1700000000000' }, type: 'date' }), '2023-11-14')
assert.equal(shown({ attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }), '😄')
assert.equal(shown({ attrs: { id: '5b10a2' }, type: 'mention' }), '@5b10a2')
assert.equal(shown({ attrs: { color: 'green', text: 'Done' }, type: 'status' }), 'Done')
assert.equal(shown({ attrs: { url: 'https://example.com/<card>' }, type: 'inlineCard' }), 'https://example.com/&lt;card&gt;')
assert.equal(shown(text('underlined', { type: 'underline' })), 'underlined')
assert.equal(html(adfToHtml(document({ attrs: { title: 'Details' }, content: [paragraph(text('Inside'))], type: 'expand' }))).endsWith('">Details\nInside</div>\n'), true)
})
test('carries a node holding what its element cannot', () => {
const carried = (node: AdfNode): boolean => html(adfToHtml(document(node))).startsWith('<div class="adf-carry"')
assert.equal(carried({ attrs: { localId: 'p1' }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carried({ content: [], type: 'paragraph' }), true)
assert.equal(carried({ attrs: {}, type: 'rule' }), true)
assert.equal(carried({ attrs: { level: 7 }, content: [text('x')], type: 'heading' }), true)
assert.equal(carried({ attrs: { order: 1.5 }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true)
assert.equal(carried({ content: [item(paragraph(text('x'))), paragraph(text('y'))], type: 'bulletList' }), true)
assert.equal(carried({ content: [], type: 'bulletList' }), true)
assert.equal(carried({ attrs: { language: 'c sharp' }, content: [text('x')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('a'), text('b')], type: 'codeBlock' }), true)
assert.equal(carried({ content: [text('x\0')], type: 'codeBlock' }), true)
assert.equal(carried(item(paragraph(text('x')))), true)
assert.equal(carried(text('x')), true)
assert.equal(carried({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/a.png' }, type: 'media' }], type: 'mediaSingle' }), true)
assert.equal(carried({ 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', () => {
const carriedSpans = (...content: AdfNode[]): number => html(adfToHtml(document(paragraph(...content)))).split('<span class="adf-carry"').length - 1
assert.equal(carriedSpans(text('a'), text('b')), 1)
assert.equal(carriedSpans(text('a'), text('b', { type: 'em' }), text('c')), 0)
assert.equal(carriedSpans(text('')), 1)
assert.equal(carriedSpans(text('x\0y')), 1)
assert.equal(carriedSpans(text('x', { type: 'em' }, { type: 'em' })), 1)
assert.equal(carriedSpans({ marks: [], text: 'x', type: 'text' }), 1)
assert.equal(carriedSpans(text('x', { attrs: {}, type: 'em' })), 1)
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/', id: 'a1' }, type: 'link' })), 1)
assert.equal(carriedSpans({ attrs: { text: '\n' }, type: 'hardBreak' }), 1)
assert.equal(carriedSpans(paragraph(text('x'))), 1)
})
test('carries a link or image whose URL a browser would run as script, or read as a file or non-image data', () => {
const linked = (href: string): string => html(adfToHtml(document(paragraph(text('x', { attrs: { href }, type: 'link' })))))
for (const href of ['javascript:alert(1)', 'JavaScript:alert(1)', ' \u0001java\tscript:alert(1)', 'vbscript:x', 'file:///etc/passwd', 'data:text/html,x', 'data:image/svg+xml,x']) {
assert.equal(linked(href).startsWith('<p><span class="adf-carry"'), true, href)
}
for (const href of ['https://example.com/', '/relative', '#top', 'mailto:a@example.com', 'data:image/png;base64,AAAA', 'javascriptx:alert(1)']) {
assert.equal(linked(href).startsWith('<p><a href='), true, href)
}
const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { type: 'external', url: 'javascript:alert(1)' }, type: 'media' }], type: 'mediaSingle' }
assert.equal(html(adfToHtml(document(image))).startsWith('<div class="adf-carry"'), true)
})
test('writes a container holding no content key as its element holding nothing', () => {
assert.equal(html(adfToHtml(document({ type: 'blockquote' }, { type: 'bulletList' }, { type: 'orderedList' }))), '<blockquote></blockquote>\n<ul></ul>\n<ol></ol>\n')
})
test('carries a link or image whose target the anchor cannot hold', () => {
const carried = (mark: AdfMark): boolean => html(adfToHtml(document(paragraph(text('x', mark))))).startsWith('<p><span class="adf-carry"')
assert.equal(carried({ attrs: { title: 'No target' }, type: 'link' }), true)
assert.equal(carried({ attrs: { href: 'https://example.com/\0' }, type: 'link' }), true)
assert.equal(carried({ attrs: { href: 'https://example.com/', title: 'x\0' }, type: 'link' }), true)
const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { type: 'external', url: 'https://example.com/a.png' }, marks: [{ attrs: { href: 'javascript:alert(1)' }, type: 'link' }], type: 'media' }], type: 'mediaSingle' }
assert.equal(html(adfToHtml(document(image))).startsWith('<div class="adf-carry"'), true)
})
test('refuses an inline carry nesting past 500 levels, in a paragraph, a heading and a list item', () => {
let deep: AdfNode = text('x')
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'unknownInline' }
const refused = (block: AdfNode): string => {
const result = adfToHtml(document(block))
return result.ok ? 'written' : `${result.error.code} at ${result.error.path.join('/')}`
}
assert.equal(refused(paragraph(deep)), 'unsupported-nesting-depth at content/0/content/0')
assert.equal(refused({ attrs: { level: 1 }, content: [deep], type: 'heading' }), 'unsupported-nesting-depth at content/0/content/0')
assert.equal(refused({ content: [item(paragraph(deep))], type: 'bulletList' }), 'unsupported-nesting-depth at content/0/content/0/content/0/content/0')
})
test('refuses text past V8\'s longest string', () => {
const long = 'a'.repeat(2 ** 28)
assert.equal(html(adfToHtml(document(paragraph(text(long), text(long, { type: 'em' }), text(long))))), 'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes')
})
test('writes an empty document as nothing, and one holding no content key as its carry', () => {
assert.equal(html(adfToHtml(document())), '')
assert.equal(html(adfToHtml({ type: 'doc', version: 1 })), '<div class="adf-carry" data-adf="{&quot;type&quot;:&quot;doc&quot;,&quot;version&quot;:1}"></div>\n')
})
test('refuses what is no document, a version HTML cannot carry, and nesting past the levels a reader reads', () => {
assert.equal(html(adfToHtml({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN')
assert.equal(html(adfToHtml({ type: 'doc', version: 2 })), 'unsupported-document-version: no HTML spelling carries ADF version 2')
let deep: AdfNode = paragraph(text('x'))
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'blockquote' }
const refused = adfToHtml(document(deep))
assert.equal(refused.ok ? 'written' : refused.error.code, 'unsupported-nesting-depth')
let fits: AdfNode = paragraph(text('x'))
for (let level = 1; level < largestNesting; level += 1) fits = { content: [fits], type: 'blockquote' }
assert.equal(adfToHtml(document(fits)).ok, true)
let items: AdfNode = paragraph(text('x'))
for (let level = 0; level <= largestNesting; level += 1) items = { content: [item(items)], type: 'bulletList' }
const listed = adfToHtml(document(items))
assert.equal(listed.ok ? 'written' : listed.error.code, 'unsupported-nesting-depth')
let carried: AdfNode = paragraph(text('x'))
for (let level = 1; level < largestNesting; level += 1) carried = { content: [carried], type: 'panel' }
const json = adfToHtml(document(carried))
assert.equal(json.ok ? 'written' : json.error.code, 'unsupported-nesting-depth')
})
+249
View File
@@ -0,0 +1,249 @@
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, heldTextLength, holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { carriedJson } from '../../adf/carried-json.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { joinedWithin, longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { largestNesting } from '../../nesting.ts'
import { linkTarget, type LinkTarget } from '../../adf/mark-attributes.ts'
import { plainImage } from '../../adf/external-image.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { shownText } from '../../adf/shown-text.ts'
// depth: the nesting level of the block list holding the node, as `largestNesting` counts it.
type Block = { depth: number; node: AdfNode; path: ConvertErrorPath }
type Pending = Block | string
// Each names its element.
const elementMarks: readonly string[] = ['code', 'em', 'strong']
export function adfToHtml(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeHtml(document))
}
function writeHtml(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no HTML spelling carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(`${carryElement('div', serializeCanonicalJson(document, 'compact'), '')}\n`)
// Every character of text is written at least once, so this much text cannot fit, and none of it is written.
if (heldTextLength(document.content) > longestOutput) return tooLong()
const pending: Pending[] = []
scheduleBlocks(pending, document.content, [], 0, '', document.content.length === 0 ? '' : '\n')
const pieces: string[] = []
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === 'string') {
pieces.push(next)
continue
}
const written = writeBlock(next, pending)
if (!written.ok) return written
if (written.value !== undefined) pieces.push(written.value)
}
return joinedWithin(pieces)
}
// Pushed in reverse, so the walk pops them in document order: a stack rather than recursion, since each level would cost several frames.
function scheduleBlocks(pending: Pending[], nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, opener: string, closer: string): void {
const scheduled: Pending[] = [opener]
for (const [index, node] of nodes.entries()) {
if (index > 0) scheduled.push('\n')
scheduled.push({ depth, node, path: [...path, 'content', index] })
}
scheduled.push(closer)
for (const item of scheduled.reverse()) pending.push(item)
}
function tooDeep(path: ConvertErrorPath): Result<never> {
return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, path)
}
// `undefined` where the block scheduled its children rather than writing itself whole.
function writeBlock(block: Block, pending: Pending[]): Result<string | undefined> {
const { depth, node, path } = block
if (depth > largestNesting) return tooDeep(path)
const leaf = tryLeafBlock(node, path)
if (leaf !== undefined) return leaf
if (node.type === 'blockquote' && holdsOnlyAttributes(node, [])) return containerBlock(pending, block, 'blockquote', '')
const list = listElement(node)
if (list !== undefined) return scheduleList(pending, block, list)
return carriedBlock(node, path, depth)
}
function tryLeafBlock(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
if (node.type === 'paragraph' && holdsOnlyAttributes(node, [])) return inlineElement('p', '', nodeContent(node), path)
if (node.type === 'heading' && holdsOnlyAttributes(node, ['level'])) {
const level = nodeAttrs(node)['level']
if (typeof level === 'number' && Number.isInteger(level) && level >= 1 && level <= 6) return inlineElement(`h${level}`, '', nodeContent(node), path)
}
const text = tryCodeBlock(node) ?? tryImage(node) ?? (node.type === 'rule' && holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '<hr>' : undefined)
return text === undefined ? undefined : success(text)
}
function inlineElement(tag: string, attributes: string, nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
const inline = inlineHtml(nodes, path)
return inline.ok ? success(`<${tag}${attributes}>${inline.value}</${tag}>`) : inline
}
// A class holds the language as one token, so a language holding whitespace rides the carry.
function tryCodeBlock(node: AdfNode): string | undefined {
if (node.type !== 'codeBlock' || !holdsOnlyAttributes(node, ['language'])) return undefined
const language = nodeAttrs(node)['language']
if (language !== undefined && (typeof language !== 'string' || !/^[^\t\n\f\r ]+$/.test(language))) return undefined
const className = language === undefined ? '' : ` class="${escapeHtml(`language-${language}`)}"`
if (node.content === undefined) return `<pre><code${className}></code></pre>`
const [only, ...others] = node.content
if (only === undefined || others.length > 0 || !writableText(only) || !isUnmarkedSpellableText(only)) return undefined
return `<pre><code${className}>${escapeHtml(only.text)}</code></pre>`
}
function tryImage(node: AdfNode): string | undefined {
const image = node.type === 'mediaSingle' ? plainImage(node) : undefined
if (image === undefined || !writableUrl(image.url) || (image.alt !== undefined && image.alt.includes('\0'))) return undefined
const element = `<img src="${escapeHtml(image.url)}"${image.alt === undefined ? '' : ` alt="${escapeHtml(image.alt)}"`}>`
if (image.link === undefined) return `<p>${element}</p>`
const anchor = anchorOpener(image.link)
return anchor === undefined ? undefined : `<p>${anchor}${element}</a></p>`
}
function containerBlock(pending: Pending[], { depth, node, path }: Block, tag: string, attributes: string): Result<string | undefined> {
if (node.content === undefined) return success(`<${tag}${attributes}></${tag}>`)
scheduleBlocks(pending, node.content, path, depth + 1, `<${tag}${attributes}>\n`, `\n</${tag}>`)
return success(undefined)
}
// The list's element, `undefined` where the list or an item holds what the elements cannot, so the whole list rides the carry.
function listElement(node: AdfNode): { attributes: string; tag: string } | undefined {
if (node.type === 'bulletList' && holdsOnlyAttributes(node, [])) return listItemsWritable(node) ? { attributes: '', tag: 'ul' } : undefined
if (node.type !== 'orderedList' || !holdsOnlyAttributes(node, ['order']) || !listItemsWritable(node)) return undefined
const order = nodeAttrs(node)['order']
if (order === undefined) return { attributes: '', tag: 'ol' }
return typeof order === 'number' && Number.isSafeInteger(order) && !Object.is(order, -0) ? { attributes: ` start="${order}"`, tag: 'ol' } : undefined
}
function listItemsWritable(list: AdfNode): boolean {
return nodeContent(list).every((item) => item.type === 'listItem' && holdsOnlyAttributes(item, []))
}
function scheduleList(pending: Pending[], block: Block, { attributes, tag }: { attributes: string; tag: string }): Result<string | undefined> {
const { depth, node, path } = block
if (node.content === undefined) return success(`<${tag}${attributes}></${tag}>`)
const items: { opening: string; rest: Pending[] }[] = []
// The items' blocks sit one level below the list, as a markdown list's do.
for (const [index, item] of node.content.entries()) {
const rest: Pending[] = []
const opening = writeItem(rest, item, [...path, 'content', index], depth + 1)
if (!opening.ok) return opening
items.push({ opening: `${index === 0 ? '' : '\n'}${opening.value}`, rest })
}
pending.push(`\n</${tag}>`)
for (const { opening, rest } of items.reverse()) {
for (const piece of rest) pending.push(piece)
pending.push(opening)
}
pending.push(`<${tag}${attributes}>\n`)
return success(undefined)
}
// Returns the item's opening, its first paragraph written bare where it holds content, and pushes the rest; panel pick, docs/decisions.md §The HTML dialect.
function writeItem(pending: Pending[], item: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
if (depth > largestNesting) return tooDeep(path)
if (item.content === undefined) return success('<li></li>')
const [first] = item.content
const bare = first !== undefined && first.type === 'paragraph' && holdsOnlyAttributes(first, []) && nodeContent(first).length > 0
const rest = bare ? item.content.slice(1) : item.content
scheduleBlocks(pending, rest, path, depth, rest.length === 0 ? '' : '\n', rest.length === 0 ? '</li>' : '\n</li>')
if (!bare) return success('<li>')
const inline = inlineHtml(nodeContent(first), [...path, 'content', 0])
return inline.ok ? success(`<li>${inline.value}`) : inline
}
function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> {
let html = ''
let afterPlainText = false
for (const [index, node] of nodes.entries()) {
// Two unmarked text nodes side by side read back as one, so the second rides the carry.
const written: string | undefined = afterPlainText && isUnmarkedSpellableText(node) ? undefined : tryInlineNode(node)
if (written === undefined) {
const carried = carriedInline(node, [...path, 'content', index])
if (!carried.ok) return carried
html += carried.value
} else html += written
afterPlainText = written !== undefined && isUnmarkedSpellableText(node)
}
return success(html)
}
function tryInlineNode(node: AdfNode): string | undefined {
if (node.type === 'hardBreak') return holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '<br>' : undefined
if (!isSpellableText(node) || !writableText(node)) return undefined
const marks = nodeMarks(node)
const openers: string[] = []
const closers: string[] = []
for (const mark of marks) {
if (marks.filter(({ type }) => type === mark.type).length > 1) return undefined
const element = markElement(mark)
if (element === undefined) return undefined
openers.push(element.opener)
closers.unshift(element.closer)
}
return `${openers.join('')}${escapeHtml(node.text)}${closers.join('')}`
}
function markElement(mark: AdfMark): { closer: string; opener: string } | undefined {
const { type } = mark
if (elementMarks.includes(type)) return mark.attrs === undefined ? { closer: `</${type}>`, opener: `<${type}>` } : undefined
if (type !== 'link' || Object.keys(nodeAttrs(mark)).some((key) => key !== 'href' && key !== 'title')) return undefined
const target = linkTarget(nodeAttrs(mark))
const opener = target === undefined ? undefined : anchorOpener(target)
return opener === undefined ? undefined : { closer: '</a>', opener }
}
function anchorOpener({ href, title }: LinkTarget): string | undefined {
if (!writableUrl(href) || title?.includes('\0') === true) return undefined
return `<a href="${escapeHtml(href)}"${title === undefined ? '' : ` title="${escapeHtml(title)}"`}>`
}
// HTML holds no null character, and no element holds empty text apart from no text.
function writableText(node: AdfNode): node is AdfNode & { text: string } {
return typeof node.text === 'string' && node.text !== '' && !node.text.includes('\0')
}
// cmark's dangerous-URL rule: its schemes and its safe image data. A browser drops tabs and line breaks and a leading control or space before it reads the scheme, so the check does too.
function writableUrl(url: string): boolean {
if (url.includes('\0')) return false
const scheme = url.replace(/[\t\n\r]/g, '').replace(/^[\u0001- ]+/, '').toLowerCase()
if (/^(?:file|javascript|vbscript):/.test(scheme)) return false
return !scheme.startsWith('data:') || /^data:image\/(?:gif|jpeg|png|webp)/.test(scheme)
}
function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting - depth)
return json.ok ? success(carryElement('div', json.value.json, carriedText(node))) : json
}
function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, node, 'compact', path, largestNesting)
return json.ok ? success(carryElement('span', json.value.json, carriedText(node))) : json
}
function carryElement(tag: string, json: string, text: string): string {
return `<${tag} class="adf-carry" data-adf="${escapeHtml(json)}">${escapeHtml(text.replaceAll('\0', ''))}</${tag}>`
}
// The text a carried node shows, a line break between its blocks and for each hard break; a reader of the element reads the JSON alone.
function carriedText(node: AdfNode): string {
let text = ''
const pending: AdfNode[] = [node]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (text !== '' && (next.type === 'hardBreak' || blockNodeModel(next.type) !== undefined)) text += '\n'
text += next.text ?? shownText(next) ?? ''
for (const child of [...nodeContent(next)].reverse()) pending.push(child)
}
return text
}
// A carriage return is a character reference, since an HTML parser reads a raw one as a line feed.
function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('\r', '&#13;')
}
+1
View File
@@ -2,6 +2,7 @@
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts'
export { adfToHtml } from './html/emit/adf-to-html.ts'
export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { Result } from '../result.ts'
import type { Result } from './result.ts'
import { joinedWithin, tooLong, withinEngineLimits, withinLongest } from './length-bound.ts'
import { success } from '../result.ts'
import { success } from './result.ts'
function thrown(name: string, message: string): () => Result<string> {
return () => {
@@ -1,9 +1,9 @@
import { failure, success, type Result } from '../result.ts'
import { failure, success, type Result } from './result.ts'
// V8's longest string, refused on every engine so a document converts alike everywhere (docs/decisions.md §Markdown past V8's longest string is refused on every engine).
export const longestMarkdown = 2 ** 29 - 24
// V8's longest string, refused on every engine so a document converts alike everywhere (docs/decisions.md §Output past V8's longest string is refused on every engine).
export const longestOutput = 2 ** 29 - 24
// Measured on Firefox 155, Node 24 and Bun 1.4; SpiderMonkey's `invalid array length` is taken from its js.msg. SpiderMonkey's overflow and V8's string overflow both mean a string past longestMarkdown; the others name an engine's own limit or its memory running short.
// Measured on Firefox 155, Node 24 and Bun 1.4; SpiderMonkey's `invalid array length` is taken from its js.msg. SpiderMonkey's overflow and V8's string overflow both mean a string past longestOutput; the others name an engine's own limit or its memory running short.
const pastLongestString = ['InternalError: allocation size overflow', 'RangeError: Invalid string length']
const pastEngineLimit = [
'RangeError: Array buffer allocation failed',
@@ -15,11 +15,11 @@ const pastEngineLimit = [
]
export function tooLong<T>(): Result<T> {
return failure('unsupported-node-shape', `the markdown for this document, or for a directive's plain CommonMark, would be longer than ${longestMarkdown} characters, the longest this library writes`, [])
return failure('unsupported-node-shape', `the markdown or HTML for this document, or a directive's plain CommonMark, would be longer than ${longestOutput} characters, the longest this library writes`, [])
}
export function withinLongest(text: string): Result<string> {
return text.length > longestMarkdown ? tooLong() : success(text)
return text.length > longestOutput ? tooLong() : success(text)
}
export function joinedWithin(pieces: Iterable<string>): Result<string> {
@@ -27,7 +27,7 @@ export function joinedWithin(pieces: Iterable<string>): Result<string> {
let length = 0
for (const piece of pieces) {
length += piece.length
if (length > longestMarkdown) return tooLong()
if (length > longestOutput) return tooLong()
kept.push(piece)
}
return success(kept.join(''))
+1 -1
View File
@@ -44,7 +44,7 @@ test('refuses a value that is not an ADF document, naming the check it failed',
assert.equal(markdown(adfToLosslessMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN')
})
const tooLong = 'unsupported-node-shape: the markdown for this document, or for a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes'
const tooLong = 'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes'
// A code block of 300 lines; two of these together fall some 1000 characters short of V8's longest string, so only what their spelling adds passes it.
function nearlyHalfTheBound(): AdfNode {
+2 -2
View File
@@ -12,7 +12,7 @@ import { flavourClaims } from '../flavour.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { longestMarkdown, tooLong, withinEngineLimits, withinLongest } from '../length-bound.ts'
import { longestOutput, tooLong, withinEngineLimits, withinLongest } from '../../length-bound.ts'
import { spellBlockDirectiveOpener } from '../directive/block-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive/syntax.ts'
import { tryImage } from './image.ts'
@@ -55,7 +55,7 @@ export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Resu
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(`${documentSpelling}\n`)
// Every character of text is spelled at least once, so this much text cannot fit, and none of it is spelled.
if (heldTextLength(document.content) > longestMarkdown) return tooLong()
if (heldTextLength(document.content) > longestOutput) return tooLong()
const walk = walkBlocks(document.content, [], 0, { ...writtenAs, memo: undefined })
if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document')
+6 -12
View File
@@ -1,18 +1,12 @@
import type { AdfNode } from '../../adf/document.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { centeredImage, externalMedia } from '../external-image.ts'
import { linkDefinition } from '../mark-spellings.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { plainImage } from '../../adf/external-image.ts'
import { targetDefinition } from '../mark-spellings.ts'
import { tryImageLine } from './inline-line.ts'
export function tryImage(node: AdfNode, flavour: WrittenFlavour): string | undefined {
const [media] = nodeContent(node)
if (media === undefined) return undefined
const { alt, url } = nodeAttrs(media)
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined
const [mark] = nodeMarks(media)
const link = mark === undefined ? undefined : linkDefinition(nodeAttrs(mark))
const read = centeredImage([externalMedia(alt ?? '', url, link)])
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url, flavour, link) : undefined
const image = plainImage(node)
if (image === undefined) return undefined
const { alt, link, url } = image
return tryImageLine(alt, url, flavour, link === undefined ? undefined : targetDefinition(link))
}
-19
View File
@@ -1,19 +0,0 @@
import type { AdfNode } from '../adf/document.ts'
import type { LinkDefinition } from './commonmark/link-syntax.ts'
import { linkMark } from './mark-spellings.ts'
// spec/flavour.md, The CommonMark image: `![alt](url)` reads to `centeredImage([externalMedia(alt, url)])`, the only shape `adfToLosslessMarkdown` writes as `![alt](url)`.
export function centeredImage(content: AdfNode[]): AdfNode {
return { attrs: { layout: 'center' }, content, type: 'mediaSingle' }
}
// `![](url)` builds no `alt`, so a media holding an empty one takes the directive form.
export function externalMedia(alt: string, url: string, link?: LinkDefinition): AdfNode {
const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
return link === undefined ? media : { ...media, marks: [linkMark(link)] }
}
// The ` (image)` after a link for an image inside it, as the readers build it (spec/flavour.md, The CommonMark image) and `adfToPortableMarkdown` writes it; `link` builds the linked word `image`.
export function imageAside(link: (text: string) => AdfNode[]): AdfNode[] {
return [{ text: ' (', type: 'text' }, ...link('image'), { text: ')', type: 'text' }]
}
+6
View File
@@ -0,0 +1,6 @@
import type { AdfNode } from '../adf/document.ts'
// The ` (image)` after a link for an image inside it, as the readers build it (spec/flavour.md, The CommonMark image) and `adfToPortableMarkdown` writes it; `link` builds the linked word `image`.
export function imageAside(link: (text: string) => AdfNode[]): AdfNode[] {
return [{ text: ' (', type: 'text' }, ...link('image'), { text: ')', type: 'text' }]
}
+13 -8
View File
@@ -7,7 +7,7 @@ import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts'
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive/syntax.ts'
import { holdsEntityReference } from './commonmark/entity-references.ts'
import { isAutolink } from './commonmark/grammar.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { isMarkType, linkTarget, linkTargetMark, markAttributes, type LinkTarget } from '../adf/mark-attributes.ts'
import { spellEmptyKeys } from './directive/empty-keys.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
@@ -37,16 +37,21 @@ export function markSpelling(type: string): MarkSpelling | undefined {
return { attributes, kind: spelling.kind }
}
export function linkMark({ destination, title }: LinkDefinition): AdfMark {
return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' }
export function linkMark(definition: LinkDefinition): AdfMark {
return linkTargetMark(definitionTarget(definition))
}
export function definitionTarget({ destination, title }: LinkDefinition): LinkTarget {
return title === undefined ? { href: destination } : { href: destination, title }
}
export function linkDefinition(attrs: AdfAttributes): LinkDefinition | undefined {
const destination = linkHref(attrs)
const title = attrs['title']
if (destination === undefined) return undefined
if (title === undefined) return { destination }
return typeof title === 'string' ? { destination, title } : undefined
const target = linkTarget(attrs)
return target === undefined ? undefined : targetDefinition(target)
}
export function targetDefinition({ href, title }: LinkTarget): LinkDefinition {
return title === undefined ? { destination: href } : { destination: href, title }
}
export function linkHref(attrs: AdfAttributes): string | undefined {
+4 -14
View File
@@ -2,14 +2,14 @@ import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan } from './directive/syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts'
import type { JsonValue } from '../json-value.ts'
import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts'
import { carriedJson } from '../adf/carried-json.ts'
import { spellsCanonicalJson } from '../canonical-json.ts'
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive/syntax.ts'
import { failure, success, type ConvertErrorPath, type Read, type Result } from '../result.ts'
import { success, type ConvertErrorPath, type Read, type Result } from '../result.ts'
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { joinedWithin } from './length-bound.ts'
import { overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { parseJsonText } from '../json-text.ts'
@@ -54,16 +54,6 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
return readCarriedJson(spelled.value, 'compact', largestNesting)
}
function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
const headroom = levels - nestingDepth(node)
if (!isJsonValue(spelled) || headroom < 0) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
}
// Two-space JSON indents each value by its depth, so a short compact value can spell past the bound: join only up to it.
const json = joinedWithin(canonicalJsonPieces(spelled, spelling))
return json.ok ? success({ headroom, json: json.value }) : json
}
// `type` is the one the fence's info string names, `undefined` for the inline carry.
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
const wayOut = type === undefined ? directiveEscape : fenceEscape(type)
+4 -3
View File
@@ -5,8 +5,9 @@ import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { centeredImage, externalMedia, imageAside } from '../external-image.ts'
import { commonMarkLink, linkDefinition, linkHref, linkMark } from '../mark-spellings.ts'
import { centeredImage, externalMedia } from '../../adf/external-image.ts'
import { imageAside } from '../image-aside.ts'
import { commonMarkLink, definitionTarget, linkDefinition, linkHref, linkMark } from '../mark-spellings.ts'
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
@@ -318,7 +319,7 @@ function imageLink(definition: LinkDefinition): ImageLink {
function mediaSingle({ alt, definition, link }: Image): AdfNode {
const { title } = definition
const caption: AdfNode[] = title === undefined || title === '' ? [] : [{ content: [{ text: title, type: 'text' }], type: 'caption' }]
return centeredImage([externalMedia(alt, definition.destination, link), ...caption])
return centeredImage([externalMedia(alt, definition.destination, link === undefined ? undefined : definitionTarget(link)), ...caption])
}
// ADF holds no empty text node and a hard break takes no link, so the destination text carries the link ahead of the text's hard breaks; an empty destination leaves the breaks alone.
+1 -1
View File
@@ -364,7 +364,7 @@ test('refuses a blockquote directive whose CommonMark spelling would pass V8\'s
const fence = `\`\`\`\n${`${'a'.repeat(894_782)}\n`.repeat(300)}\`\`\`\n`
assert.equal(
content(losslessMarkdownToAdf(`!adf:blockquote\n${fence}\n${fence}!adf:/blockquote\n`)),
'unsupported-node-shape: the markdown for this document, or for a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes',
'unsupported-node-shape: the markdown or HTML for this document, or a directive\'s plain CommonMark, would be longer than 536870888 characters, the longest this library writes',
)
})
+1 -1
View File
@@ -17,7 +17,7 @@ import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from '../directive/node-reading.ts'
import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts'
import { spellsEmpty } from '../directive/empty-keys.ts'
import { withinEngineLimits } from '../length-bound.ts'
import { withinEngineLimits } from '../../length-bound.ts'
// A flavour's own reading of a quote, and of a bullet list into one or more blocks; `path` is the container's, `index` the list's place among the container's blocks.
export type FlavourBlockReaders = {
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
import { centeredImage, externalMedia } from '../external-image.ts'
import { centeredImage, externalMedia } from '../../adf/external-image.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
@@ -9,8 +9,8 @@ import { inlineLeaves, isBlockNodeType, oneLine, portableLink, reduceInline, wri
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { linkDefinition } from '../mark-spellings.ts'
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
import { linkTarget } from '../../adf/mark-attributes.ts'
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
import { portableWriting } from './block-spelling.ts'
import { splitTaskList } from './task-list.ts'
import { taskMarker } from './conventions.ts'
@@ -63,7 +63,7 @@ export function reduceToPortable(document: AdfDocument): Result<AdfDocument> {
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
// Measured on the caller's text, edge whitespace and all: `toEditorNormal`'s merge could build past the bound.
if (heldTextLength(nodeContent(document)) > longestMarkdown) return tooLong()
if (heldTextLength(nodeContent(document)) > longestOutput) return tooLong()
// Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep).
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) {
@@ -367,5 +367,5 @@ function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const alt = typeof held === 'string' ? oneLine(held).trim() : ''
const mark = nodeMarks(media).find(({ type }) => type === 'link')
const link = mark === undefined ? undefined : portableLink(mark, 'paragraph')
return success([centeredImage([externalMedia(alt, writableHref(url), link === undefined ? undefined : linkDefinition(nodeAttrs(link)))])])
return success([centeredImage([externalMedia(alt, writableHref(url), link === undefined ? undefined : linkTarget(nodeAttrs(link)))])])
}
+14 -37
View File
@@ -2,9 +2,10 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from '../emit/line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { cardUrl, shownText, visibleText } from '../../adf/shown-text.ts'
import { codeTextMarkTypes } from '../../adf/inline-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { imageAside } from '../external-image.ts'
import { imageAside } from '../image-aside.ts'
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
@@ -47,50 +48,38 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP
const attrs = nodeAttrs(node)
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
if (node.type === 'hardBreak') return success([lineBreak(container)])
if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container))
if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container))
if (['date', 'emoji', 'mention', 'status'].includes(node.type)) return success(textLeaves(shownText(node), marks, container))
if (node.type === 'placeholder') return success([])
if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container))
if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container))
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(shownText(node), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
if (node.type === 'syncBlock') return success(noteLeaves('synced block'))
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container))
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container))
const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container)
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(node, marks, container))
const own = textLeaves(node.text ?? shownText(node), marks, container)
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
if (!held.ok) return held
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
}
function cardLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
const data = attrs['data']
const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
const url = nonEmpty(attrs['url'])
const heldUrl = nonEmpty(held['url'])
const name = nonEmpty(held['name'])
const href = url ?? heldUrl
if (href === undefined) return textLeaves(name, marks, container, 'link card')
return linkedLeaves(url ?? name ?? href, href, marks, container)
function cardLeaves(card: AdfNode, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
const href = cardUrl(card)
if (href === undefined) return textLeaves(shownText(card), marks, container, 'link card')
return linkedLeaves(shownText(card) ?? href, href, marks, container)
}
function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container)
}
function idMention(id: unknown): string | undefined {
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
}
// An image standing inline is a link to it: the reader builds no node for an inline image, reading it as its alt text linked, and inside a link as the link's text, then (image).
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
const alt = nonEmpty(attrs['alt'])
const url = nonEmpty(attrs['url'])
const alt = visibleText(attrs['alt'])
const url = visibleText(attrs['url'])
if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image')
const link = marks.find((mark) => mark.type === 'link')
const href = link === undefined ? undefined : nodeAttrs(link)['href']
if (typeof href !== 'string') return linkedLeaves(alt ?? url, url, marks, container)
if (href === url) return textLeaves(alt ?? url, marks, container)
return [...textLeaves(alt ?? nonEmpty(href) ?? url, marks, container), ...imageAside((text) => linkedLeaves(text, url, marks, container))]
return [...textLeaves(alt ?? visibleText(href) ?? url, marks, container), ...imageAside((text) => linkedLeaves(text, url, marks, container))]
}
function noteLeaves(name: string): AdfNode[] {
@@ -98,25 +87,13 @@ function noteLeaves(name: string): AdfNode[] {
}
function noteName(value: unknown): string | undefined {
return nonEmpty(value) === undefined ? undefined : oneLine(String(value)).trim()
return visibleText(value) === undefined ? undefined : oneLine(String(value)).trim()
}
export function oneLine(text: string): string {
return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')
}
function nonEmpty(value: unknown): string | undefined {
return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined
}
function isoDate(timestamp: unknown): string | undefined {
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
const date = new Date(milliseconds)
if (Number.isNaN(date.getTime())) return undefined
const iso = date.toISOString()
return iso.slice(0, iso.indexOf('T'))
}
function lineBreak(container: LineContainer): AdfNode {
return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', [])
}
@@ -9,7 +9,7 @@ import { mintTaskIds } from './task-ids.ts'
import { parseInlineContent } from '../parse/inline-content.ts'
import { pathTo, splitTaskList } from './task-list.ts'
import { positioned, success } from '../../result.ts'
import { withinEngineLimits } from '../length-bound.ts'
import { withinEngineLimits } from '../../length-bound.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }>
+8 -16
View File
@@ -6,14 +6,13 @@
`Bar = 0.1`
`Next ID = 141`
`Next ID = 142`
## Items
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 130 | 0.3.0 | | | **Ship `adfToHtml`, writing paragraphs, headings, lists, quotes, code blocks, rules, hard breaks, external images and the marks CommonMark spells as semantic HTML, and every other node as a carry element showing its text.** | 3 | 2 | 2 | 5 | 6 | 3 by 6 | 2.1 |
| 131 | 0.3.0 | 130 | | **Ship `htmlToAdf`, reading back exactly what `adfToHtml` writes and refusing any other HTML as `unmappable-html`.** | 4 | 1 | 2 | 5 | 7 | 3 by 3 | 0.6 |
| 131 | 0.3.0 | | | **Ship `htmlToAdf`, reading back exactly what `adfToHtml` writes and refusing any other HTML as `unmappable-html`.** | 4 | 1 | 2 | 5 | 7 | 3 by 3 | 0.6 |
| 133 | 0.3.0 | 131 | | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
| 132 | 0.3.0 | 131 | | **Write tables as `<table>` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 |
| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
@@ -31,6 +30,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 |
| 141 | 0.4.0 | 131 | | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 4.0 |
| 135 | 0.4.0 | 131 | | **Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 |
| 136 | 0.4.0 | 131 | | **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.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 |
| 134 | 0.4.0 | 131 | | **Write panels, expands, task lists and decision lists as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 3 | 1.8 |
@@ -62,19 +62,6 @@
## Details
### 130. Ship `adfToHtml`, writing paragraphs, headings, lists, quotes, code blocks, rules, hard breaks, external images and the marks CommonMark spells as semantic HTML, and every other node as a carry element showing its text.
The first, limited HTML: the dialect (`docs/decisions.md` §The HTML dialect) specified for this set,
and a writer panel picking its spellings. A code block is `<pre><code>`, its language a class. Every
other node is a carry element holding the node's JSON in a `data-*` attribute and showing the node's
text, so an HTML reader loses no content (goal 7). The README documents HTML as it documents
markdown, its comparison table names HTML, and its tagline and `package.json`'s description and
keywords name HTML.
Whatever `html/` shares with the carry (`opaque-carry.ts`) or the mark spellings
(`mark-spellings.ts`) splits: the ADF question moves to `adf/`, and each format keeps its own
spelling.
### 131. Ship `htmlToAdf`, reading back exactly what `adfToHtml` writes and refusing any other HTML as `unmappable-html`.
A hand-written HTML tokenizer, linear in the input, its nesting bounded (`docs/decisions.md`
@@ -227,6 +214,11 @@ document's whole tree. Give the node's path, and name its type and the attribute
leaves the README's tables a false contract under a green gate. Assert that the tables list exactly
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 `<s>` or `<del>`.
### 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.