mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Write and read a table as its elements in the HTML dialect
This commit is contained in:
+4
-4
@@ -11,13 +11,13 @@
|
||||
need. The README's first comparison table lists the npm packages running in Node that convert ADF
|
||||
to or from markdown, wrappers and forks aside; its second table lists other ecosystems'
|
||||
libraries and Atlassian's own paths.
|
||||
- `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, code blocks, rules, hard
|
||||
breaks, a centered, uncaptioned external image holding no attribute but its URL and alt, and
|
||||
- `adfToHtml` writes ADF as HTML: paragraphs, headings, lists, quotes, tables, code blocks, rules,
|
||||
hard breaks, a centered, uncaptioned external image holding no attribute but its URL and alt, and
|
||||
`em`, `strong`, `code` and links as their elements. An unmarked emoji, mention, status, date
|
||||
and card holding a URL, or `data` holding one, not both, are `<span class="adf-emoji">`,
|
||||
`<span class="adf-mention">`, `<span class="adf-status">`, `<time class="adf-date">` and
|
||||
`<a class="adf-inline-card">`. A paragraph, heading, quote, list, list item, code block, rule,
|
||||
emoji, mention, status, date or card writes its other attributes as `data-*` attributes,
|
||||
`<a class="adf-inline-card">`. A paragraph, heading, quote, list, list item, table, row, cell,
|
||||
code block, rule, emoji, mention, status, date or card 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
|
||||
|
||||
@@ -17,7 +17,7 @@ products store as ADF.
|
||||
| Markdown from a bot, a model or a person | ADF | `portableMarkdownToAdf` | reads CommonMark plus GFM's tables, task lists and alerts, `~~`, and Obsidian's callouts and `==`; `commonMarkToAdf` reads strict CommonMark alone |
|
||||
| ADF | markdown to edit, then ADF again | `adfToLosslessMarkdown`, then `losslessMarkdownToAdf` | the pair gives back the document it started from, panels, mentions, statuses and node types this version does not know included |
|
||||
| ADF | markdown that GitHub, GitLab and Obsidian render | `adfToPortableMarkdown` | keeps the text, images by URL and link targets and drops the rest |
|
||||
| ADF | HTML | `adfToHtml` | writes paragraphs, headings, lists, quotes, code blocks, rules, breaks, a plain external image, links, and code, emphasis and strong text as HTML elements, and every other node as an element holding its JSON; `htmlToAdf` reads that HTML back to the same document, and refuses any other HTML |
|
||||
| ADF | HTML | `adfToHtml` | writes paragraphs, headings, lists, quotes, tables, code blocks, rules, breaks, a plain external image, links, and code, emphasis and strong text as HTML elements, and every other node as an element holding its JSON; `htmlToAdf` reads that HTML back to the same document, and refuses any other HTML |
|
||||
|
||||
Every call returns a result, never a throw. A refusal carries a code from a closed list, and the same input gets the same refusal, except
|
||||
for `engine-limit` ([The guarantees](#the-guarantees)).
|
||||
@@ -524,8 +524,9 @@ content. No stylesheet ships. The complete dialect:
|
||||
| `mediaSingle` holding an external `media`, centered, uncaptioned, holding no other attribute such as `width` or `localId` | `<p><img src="…" alt="…"></p>`, the image inside `<a>` where a link marks it; an image holding such an attribute, as editor-written ones do, rides the carry element |
|
||||
| `paragraph` | `<p>` |
|
||||
| `rule` | `<hr>` |
|
||||
| `table`, `tableRow`, `tableCell`, `tableHeader` | `<table>`, `<thead>` for a first row of `tableHeader` cells alone, `<tbody>`, `<tr>`, `<td>` and `<th>`; a cell's first paragraph stands as an item's does, and `<td colspan="2" rowspan="2" style="background-color:#fffae6" data-colwidth="120,80">` writes its attributes |
|
||||
| `text` marked `code`, `em`, `strong` | `<code>`, `<em>`, `<strong>`, the first mark outermost |
|
||||
| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block, rule, emoji, mention, status, date or card | `data-*` named as HTML's `dataset` names it: `<p data-local-id="a1f3">` |
|
||||
| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, table, row, cell, code block, rule, emoji, mention, status, date or card | `data-*` named as HTML's `dataset` names it: `<p data-local-id="a1f3">` |
|
||||
| any other node, or a node holding what its element cannot, except an inline card a link marks, which is refused ([The errors](#the-errors)) | `<div class="adf-carry" data-adf="{…}"><p>its content</p></div>`, a `<span>` inline |
|
||||
|
||||
- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, a
|
||||
|
||||
+13
-1
@@ -402,7 +402,7 @@ for HTML+, its Rovo MCP server's HTML for agents; a published spec reopens the g
|
||||
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 five writer panels' (2026-10-23, -24 and 2026-11-04), each of a
|
||||
The spellings, `spec/html.md`, are six writer panels' (2026-10-23, -24, 2026-11-04 and -05), each of a
|
||||
product manager, an engineer and a support agent. The first picked each of these 3 of 3:
|
||||
|
||||
- an image standing alone as `<p><img …></p>`, over `<figure>` and a bare `<img>`;
|
||||
@@ -463,6 +463,18 @@ and `<span … data-data>` 2. The maintainer took the leading spelling of each,
|
||||
- a mention is `<span class="adf-mention" data-access-level="CONTAINER" data-id="5b10a2" data-user-type="DEFAULT">@Mikael</span>`, its `text` the content;
|
||||
- a card holding `data` and no URL is `<a class="adf-inline-card" href="https://example.com/plan" data-data="{…}">Release plan</a>`, `href` the URL its `data` holds and the text its name, else that URL. A card holding both `data` and a `url` rides the carry: its `href`, read back, could not say which of the two URLs it spells.
|
||||
|
||||
A sixth panel (2026-11-05, three readers in the same roles) picked each of these 3 of 3 for a table,
|
||||
which until then rode the carry:
|
||||
|
||||
- a first row of header cells alone in `<thead>` and the rest in `<tbody>`, a row to a line, over a
|
||||
bare `<table>` of `<tr>` rows and over a line per cell;
|
||||
- a cell's first paragraph bare, `<td>Bolt M8</td>`, as a list item's;
|
||||
- a cell's attributes as `<td colspan="2" rowspan="2" style="background-color:#fffae6" data-colwidth="120,80">`,
|
||||
over `data-*` for all four.
|
||||
|
||||
A `background` holding anything but letters and digits after an optional `#` rides the carry, so no
|
||||
value can end the `style` declaration.
|
||||
|
||||
## `htmlToAdf` also reads the dialect as a browser serializes it
|
||||
|
||||
2026-10-28, the maintainer. Goal 6. Valid while a viewer/editor app may show `adfToHtml`'s HTML in a
|
||||
|
||||
+30
-7
@@ -20,13 +20,30 @@ Blocks are separated by a line feed, and a document holding any ends with one. A
|
||||
| `orderedList` | as `bulletList` with `<ol>`; `<ol start="3">` for an `order` of 3, a safe integer other than `-0`; no `start` where the list holds no `order` |
|
||||
| `paragraph` | `<p>…</p>` |
|
||||
| `rule` | `<hr>` |
|
||||
| `table` | `<table>`, a line feed, a `<thead>` where its first row holds cells that are all `tableHeader`, then a `<tbody>` for the other rows where there are any, a line feed, `</table>`. Each section is its tag, a line feed, its rows a line each, a line feed, its closing tag |
|
||||
| `tableCell`, `tableHeader` | `<td>`, `<th>`, each holding its blocks as a `listItem` does: `<td>Bolt M8</td>`; attributes `colspan`, `rowspan`, `style="background-color:#fffae6"` for `background`, and `data-colwidth="120,80"` for `colwidth`, first and in that order |
|
||||
| `tableRow` | `<tr>`, its cells with no line feed between them, `</tr>` |
|
||||
|
||||
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>`.
|
||||
`<ul></ul>`, `<li></li>`, `<table></table>`, `<tr></tr>`, `<td></td>`; so is a paragraph or heading,
|
||||
`<p></p>`.
|
||||
|
||||
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
|
||||
A table and each of its rows count one nesting level; a cell counts none, as a list item does.
|
||||
|
||||
```html
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Part</th><th>Stock</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Bolt M8</td><td colspan="2" rowspan="2" style="background-color:#fffae6" data-colwidth="120,80">Low</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
On a paragraph, heading, quote, list, list item, table, row, cell, code block or rule, each attribute its type holds
|
||||
(`spec/flavour.md` §Block nodes) that the element does not write as `level`, `order`, `language`,
|
||||
`colspan`, `rowspan`, `background` or `colwidth` 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: `<ol start="3" data-local-id="91177a27-b0ff">`, `<pre data-wrap="true"><code>`. A code
|
||||
block's `data-*` attributes stand on its `<pre>`.
|
||||
@@ -124,9 +141,15 @@ later release writes it as its own element.
|
||||
- a code block holding more than one text node, a marked one, or a language holding ASCII
|
||||
whitespace;
|
||||
- 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;
|
||||
- a `listItem` outside a list, an inline node in block position and a block node inline.
|
||||
- a list holding a node that is no `listItem`, or an item holding a mark, an empty key or an
|
||||
attribute its type does not hold: the whole list rides the carry;
|
||||
- a table holding a node that is no `tableRow`, a row holding a node that is no cell, or a table,
|
||||
row or cell holding a mark, an empty key or an attribute its type does not hold: the whole table
|
||||
rides the carry. So does a cell whose `colspan` or `rowspan` is no positive safe integer, whose
|
||||
`colwidth` is no list of numbers, or whose `background` holds anything but letters and digits
|
||||
after an optional `#`;
|
||||
- a `listItem`, `tableRow` or cell outside its parent, an inline node in block position and a block
|
||||
node inline.
|
||||
- An inline card a link marks, a block or non-text inline node holding `text`, and a leaf block or
|
||||
non-text inline node holding `content` are refused as `unsupported-node-shape`, carried or not, by
|
||||
`adfToHtml` and by `htmlToAdf` reading a carry element: Atlassian's schema gives no node those.
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export function attributeNestingMessage(key: string, type: string): string {
|
||||
}
|
||||
|
||||
export function holdsOnlyAttributes(node: AdfNode, attributes: readonly string[]): boolean {
|
||||
if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
|
||||
if (nodeMarks(node).length > 0 || emptyKeys(node).length > 0) return false
|
||||
return holdsOnly(nodeAttrs(node), attributes)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AdfAttributes, AdfNode } from '../adf/document.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import type { Read } from '../result.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { dataAttributes, dataValue, readDataAttributes } from './data-attributes.ts'
|
||||
import { isNumberList } from '../adf/attribute-vocabulary.ts'
|
||||
import { nodeAttrs } from '../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
const elementWritten = ['background', 'colspan', 'colwidth', 'rowspan']
|
||||
const leading = ['colspan', 'rowspan', 'style', 'data-colwidth']
|
||||
const span = /^[1-9][0-9]*$/
|
||||
// Letters and digits alone, so no value can end the `background-color` declaration or call `url()`.
|
||||
const styled = /^background-color:(#?[0-9a-zA-Z]+)$/
|
||||
|
||||
export function isHeaderRow(row: { content?: AdfNode[] }): boolean {
|
||||
const cells = row.content ?? []
|
||||
return cells.length > 0 && cells.every(({ type }) => type === 'tableHeader')
|
||||
}
|
||||
|
||||
// A cell's attributes in the order `readCellAttributes` takes them; `undefined` where one spells nothing its element writes, so the whole table rides the carry.
|
||||
export function cellAttributes(cell: AdfNode): string | undefined {
|
||||
const { background, colspan, colwidth, rowspan } = nodeAttrs(cell)
|
||||
const own = [spanAttribute('colspan', colspan), spanAttribute('rowspan', rowspan), styleAttribute(background), widthsAttribute(colwidth), dataAttributes(cell, blockNodes.tableCell.attributes, elementWritten)]
|
||||
return own.every((attribute) => attribute !== undefined) ? own.join('') : undefined
|
||||
}
|
||||
|
||||
// `style` and `colspan`, `rowspan` and `data-colwidth` stand first, each once, in the order `cellAttributes` writes them.
|
||||
export function readCellAttributes(pairs: readonly (readonly [string, string])[], element: string): Read<AdfAttributes> {
|
||||
const attrs: AdfAttributes = {}
|
||||
let rest = pairs
|
||||
for (const name of leading) {
|
||||
const [first] = rest
|
||||
if (first?.[0] !== name) continue
|
||||
const value = ownValue(name, first[1])
|
||||
if (value === undefined) return { fault: { code: name === 'data-colwidth' ? 'unsupported-node-shape' : 'unmappable-html', message: `${name} on <${element}> spells no value adfToHtml writes there` } }
|
||||
attrs[ownKey(name)] = value
|
||||
rest = rest.slice(1)
|
||||
}
|
||||
const misplaced = rest.find(([name]) => leading.includes(name))
|
||||
if (misplaced !== undefined) return { fault: { code: 'unmappable-html', message: `<${element}> takes colspan, rowspan, style and data-colwidth first, each once and in that order: found ${misplaced[0]} after them` } }
|
||||
const data = readDataAttributes(rest, element, blockNodes.tableCell.attributes, elementWritten)
|
||||
return data.fault === undefined ? { value: { ...attrs, ...data.value } } : data
|
||||
}
|
||||
|
||||
function spanAttribute(name: string, value: JsonValue | undefined): string | undefined {
|
||||
if (value === undefined) return ''
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? ` ${name}="${value}"` : undefined
|
||||
}
|
||||
|
||||
function styleAttribute(background: JsonValue | undefined): string | undefined {
|
||||
if (background === undefined) return ''
|
||||
return typeof background === 'string' && styled.test(`background-color:${background}`) ? ` style="background-color:${background}"` : undefined
|
||||
}
|
||||
|
||||
function widthsAttribute(colwidth: JsonValue | undefined): string | undefined {
|
||||
if (colwidth === undefined) return ''
|
||||
return isNumberList(colwidth) ? ` data-colwidth="${serializeCanonicalJson(colwidth, 'compact').slice(1, -1)}"` : undefined
|
||||
}
|
||||
|
||||
function ownKey(name: string): string {
|
||||
if (name === 'style') return 'background'
|
||||
return name === 'data-colwidth' ? 'colwidth' : name
|
||||
}
|
||||
|
||||
function ownValue(name: string, spelled: string): JsonValue | undefined {
|
||||
if (name === 'style') return styled.exec(spelled)?.[1]
|
||||
if (name === 'data-colwidth') return dataValue(`[${spelled}]`, 'numbers')
|
||||
const value = Number(spelled)
|
||||
return span.test(spelled) && Number.isSafeInteger(value) ? value : undefined
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { optionalAttribute } from './escaping.ts'
|
||||
import { parseJsonText } from '../json-text.ts'
|
||||
import { serializeCanonicalJson, spellsCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
// The attributes a node holds besides those its element writes, as `data-*` attributes sorted by key, a value that is no string as JSON; `undefined` where the node holds what no attribute writes: a mark, text, an empty key, or an attribute the vocabulary does not hold as that kind (docs/decisions.md §The HTML dialect).
|
||||
// The attributes a node holds besides those its element writes, as `data-*` attributes sorted by key, a value that is no string as JSON; `undefined` where the node holds what no attribute writes: a mark, an empty key, or an attribute the vocabulary does not hold as that kind (docs/decisions.md §The HTML dialect).
|
||||
export function dataAttributes(node: AdfNode, vocabulary: AttributeVocabulary, written: readonly string[]): string | undefined {
|
||||
if (!holdsOnlyAttributes(node, Object.keys(vocabulary))) return undefined
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), vocabulary, written)
|
||||
@@ -47,7 +47,7 @@ function dataName(key: string): string {
|
||||
return `data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`
|
||||
}
|
||||
|
||||
function dataValue(spelled: string, kind: AttributeKind): JsonValue | undefined {
|
||||
export function dataValue(spelled: string, kind: AttributeKind): JsonValue | undefined {
|
||||
if (kind === 'string') return spelled
|
||||
const { value } = parseJsonText(spelled)
|
||||
return value !== undefined && holdsKind(value, kind) && spellsCanonicalJson(value, 'compact', spelled) ? value : undefined
|
||||
|
||||
@@ -298,6 +298,64 @@ test('shows a note naming what a carried node references where it shows no text,
|
||||
assert.equal(shown(html(adfToHtml(document({ attrs: { resourceId: ids.resource }, type: 'syncBlock' })))), '<p><em>(synced block)</em></p>')
|
||||
})
|
||||
|
||||
test('writes a table as its elements, a first row of header cells in <thead>, a cell\'s first paragraph on its line', () => {
|
||||
const row = (...content: AdfNode[]): AdfNode => ({ content, type: 'tableRow' })
|
||||
const cell = (...content: AdfNode[]): AdfNode => ({ content, type: 'tableCell' })
|
||||
const header = (...content: AdfNode[]): AdfNode => ({ content, type: 'tableHeader' })
|
||||
const table = (...content: AdfNode[]): AdfNode => ({ content, type: 'table' })
|
||||
const head = row(header(paragraph(text('Part'))), header(paragraph(text('Note'))))
|
||||
const body = row(cell(paragraph(text('Bolt M8'))), cell(paragraph(text('Grade 8.8'))))
|
||||
assert.equal(html(adfToHtml(document(table(head, body)))), '<table>\n<thead>\n<tr><th>Part</th><th>Note</th></tr>\n</thead>\n<tbody>\n<tr><td>Bolt M8</td><td>Grade 8.8</td></tr>\n</tbody>\n</table>\n')
|
||||
assert.equal(html(adfToHtml(document(table(body, head)))), '<table>\n<tbody>\n<tr><td>Bolt M8</td><td>Grade 8.8</td></tr>\n<tr><th>Part</th><th>Note</th></tr>\n</tbody>\n</table>\n')
|
||||
assert.equal(html(adfToHtml(document(table(head)))), '<table>\n<thead>\n<tr><th>Part</th><th>Note</th></tr>\n</thead>\n</table>\n')
|
||||
assert.equal(html(adfToHtml(document(table(row(header(paragraph(text('a'))), cell(paragraph(text('b')))))))), '<table>\n<tbody>\n<tr><th>a</th><td>b</td></tr>\n</tbody>\n</table>\n')
|
||||
assert.equal(html(adfToHtml(document({ type: 'table' }))), '<table></table>\n')
|
||||
assert.equal(html(adfToHtml(document(table({ type: 'tableRow' })))), '<table>\n<tbody>\n<tr></tr>\n</tbody>\n</table>\n')
|
||||
assert.equal(html(adfToHtml(document(table(row({ type: 'tableCell' }, { type: 'tableHeader' }))))), '<table>\n<tbody>\n<tr><td></td><th></th></tr>\n</tbody>\n</table>\n')
|
||||
assert.equal(html(adfToHtml(document(table(row(cell(paragraph(text('a')), paragraph(text('b'))), cell({ type: 'rule' })))))), '<table>\n<tbody>\n<tr><td>a\n<p>b</p>\n</td><td>\n<hr>\n</td></tr>\n</tbody>\n</table>\n')
|
||||
})
|
||||
|
||||
test('writes a table\'s attributes as the writer panel picked them', () => {
|
||||
const attrs = { background: '#fffae6', colspan: 2, colwidth: [120, 80], localId: 'c1', rowspan: 3, valign: 'top' }
|
||||
const table: AdfNode = { attrs: { isNumberColumnEnabled: true, layout: 'wide', width: 760 }, content: [{ attrs: { localId: 'r1' }, content: [{ attrs, content: [paragraph(text('Sold as a pair'))], type: 'tableCell' }], type: 'tableRow' }], type: 'table' }
|
||||
assert.equal(
|
||||
html(adfToHtml(document(table))),
|
||||
'<table data-is-number-column-enabled="true" data-layout="wide" data-width="760">\n<tbody>\n<tr data-local-id="r1"><td colspan="2" rowspan="3" style="background-color:#fffae6" data-colwidth="120,80" data-local-id="c1" data-valign="top">Sold as a pair</td></tr>\n</tbody>\n</table>\n',
|
||||
)
|
||||
assert.equal(html(adfToHtml(document({ content: [{ content: [{ attrs: { background: 'red', colwidth: [0.5] }, type: 'tableHeader' }], type: 'tableRow' }], type: 'table' }))), '<table>\n<thead>\n<tr><th style="background-color:red" data-colwidth="0.5"></th></tr>\n</thead>\n</table>\n')
|
||||
})
|
||||
|
||||
test('carries the whole table where it, a row or a cell holds what its element cannot', () => {
|
||||
const cell = (attrs: AdfAttributes): AdfNode => ({ attrs, content: [paragraph(text('x'))], type: 'tableCell' })
|
||||
const inRow = (node: AdfNode): AdfNode => ({ content: [{ content: [node], type: 'tableRow' }], type: 'table' })
|
||||
for (const attrs of [{ background: 'url(x)' }, { background: 'red;color:blue' }, { background: '' }, { background: 7 }, { colspan: 0 }, { colspan: 1.5 }, { colspan: '2' }, { rowspan: -1 }, { colwidth: [] }, { colwidth: ['1'] }, { colwidth: 120 }, { valign: 7 }, { size: 'big' }]) {
|
||||
assert.equal(carriedBlock(inRow(cell(attrs))), true, JSON.stringify(attrs))
|
||||
}
|
||||
assert.equal(carriedBlock({ content: [paragraph(text('x'))], type: 'table' }), true)
|
||||
assert.equal(carriedBlock(inRow(paragraph(text('x')))), true)
|
||||
assert.equal(carriedBlock({ content: [], type: 'table' }), true)
|
||||
assert.equal(carriedBlock({ content: [{ content: [], type: 'tableRow' }], type: 'table' }), true)
|
||||
assert.equal(carriedBlock(inRow({ content: [], type: 'tableCell' })), true)
|
||||
assert.equal(carriedBlock({ attrs: { size: 'big' }, type: 'table' }), true)
|
||||
assert.equal(carriedBlock({ content: [{ attrs: { size: 'big' }, type: 'tableRow' }], type: 'table' }), true)
|
||||
assert.equal(carriedBlock({ content: [{ marks: [{ type: 'x' }], type: 'tableRow' }], type: 'table' }), true)
|
||||
assert.equal(carriedBlock({ type: 'tableRow' }), true)
|
||||
assert.equal(carriedBlock({ type: 'tableCell' }), true)
|
||||
assert.equal(html(adfToHtml(document(inRow(cell({ colspan: 1 }))))).includes('adf-carry'), false)
|
||||
})
|
||||
|
||||
test('counts a table and its rows each a level, and a cell none, as a list and its items', () => {
|
||||
const row: AdfNode = { type: 'tableRow' }
|
||||
const cell: AdfNode = { content: [paragraph(text('x'))], type: 'tableCell' }
|
||||
const holding = (levels: number, inner: AdfNode): AdfDocument => document(nested('blockquote', levels, inner))
|
||||
const table = (...content: AdfNode[]): AdfNode => ({ content, type: 'table' })
|
||||
assert.equal(adfToHtml(holding(499, table(row))).ok, true)
|
||||
assert.equal(refusal(adfToHtml(holding(500, table(row)))).startsWith('unsupported-nesting-depth'), true)
|
||||
assert.equal(adfToHtml(holding(500, { type: 'table' })).ok, true)
|
||||
assert.equal(adfToHtml(holding(498, table({ content: [cell], type: 'tableRow' }))).ok, true)
|
||||
assert.equal(refusal(adfToHtml(holding(499, table({ content: [cell], type: 'tableRow' })))).startsWith('unsupported-nesting-depth'), true)
|
||||
})
|
||||
|
||||
test('carries a node holding what its element cannot', () => {
|
||||
assert.equal(carriedBlock({ attrs: { localId: 7 }, content: [text('x')], type: 'paragraph' }), true)
|
||||
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [text('x')], type: 'paragraph' }), true)
|
||||
@@ -312,7 +370,6 @@ test('carries a node holding what its element cannot', () => {
|
||||
assert.equal(carriedBlock({ attrs: { localId: 7 }, content: [item(paragraph(text('x')))], type: 'orderedList' }), true)
|
||||
assert.equal(carriedBlock({ content: [item(paragraph(text('x'))), paragraph(text('y'))], type: 'bulletList' }), true)
|
||||
assert.equal(carriedBlock({ content: [{ attrs: {}, content: [paragraph(text('x'))], type: 'listItem' }], type: 'bulletList' }), true)
|
||||
assert.equal(carriedBlock({ content: [{ content: [paragraph(text('x'))], text: 'y', type: 'listItem' }], type: 'bulletList' }), true)
|
||||
assert.equal(carriedBlock({ content: [], type: 'bulletList' }), true)
|
||||
assert.equal(carriedBlock({ attrs: { language: 'c sharp' }, content: [text('x')], type: 'codeBlock' }), true)
|
||||
assert.equal(carriedBlock({ attrs: { language: 5 }, content: [text('x')], type: 'codeBlock' }), true)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { anchorOpener } from '../anchor-element.ts'
|
||||
import { blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { carriedBlock, carryElement } from './carry-element.ts'
|
||||
import { carryContent } from '../carry-content.ts'
|
||||
import { cellAttributes, isHeaderRow } from '../cell-element.ts'
|
||||
import { containerElements, headingElement, listStart } from '../block-elements.ts'
|
||||
import { dataAttributes } from '../data-attributes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -16,10 +17,11 @@ 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; `attributes`: the `<li>`'s `data-*` attributes, built when its list checked the item.
|
||||
// depth: the nesting level of the block list holding the node, as `largestNesting` counts it; `attributes`: the `<li>`'s, `<td>`'s or `<th>`'s, built when its list or table checked it.
|
||||
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
|
||||
type Item = { attributes: string; depth: number; kind: 'item'; node: AdfNode; path: ConvertErrorPath; tag: string }
|
||||
type Row = { depth: number; kind: 'row'; path: ConvertErrorPath; pieces: Pending[] }
|
||||
type Pending = Block | Item | Row | string
|
||||
|
||||
export function adfToHtml(document: AdfDocument): Result<string> {
|
||||
return withinEngineLimits(() => writeHtml(document))
|
||||
@@ -40,7 +42,7 @@ function writeHtml(document: AdfDocument): Result<string> {
|
||||
continue
|
||||
}
|
||||
if (next.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, next.path)
|
||||
const pieces = next.kind === 'item' ? writeItem(next) : writeBlock(next)
|
||||
const pieces = next.kind === 'row' ? success(next.pieces) : next.kind === 'item' ? writeItem(next) : writeBlock(next)
|
||||
if (!pieces.ok) return pieces
|
||||
for (const piece of pieces.value.reverse()) pending.push(piece)
|
||||
}
|
||||
@@ -58,7 +60,7 @@ function blockPieces(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: n
|
||||
|
||||
function writeBlock(block: Block): Result<Pending[]> {
|
||||
const { depth, node, path } = block
|
||||
const container = node.type === 'blockquote' ? quotePieces(block) : listPieces(block)
|
||||
const container = node.type === 'blockquote' ? quotePieces(block) : node.type === 'table' ? tablePieces(block) : listPieces(block)
|
||||
if (container !== undefined) return success(container)
|
||||
const html = tryLeafBlock(node, path) ?? carriedBlock(node, path, depth)
|
||||
return html.ok ? success([html.value]) : html
|
||||
@@ -79,11 +81,51 @@ function listPieces({ depth, node, path }: Block): Pending[] | undefined {
|
||||
const attributes = item.type === 'listItem' ? dataAttributes(item, blockNodes.listItem.attributes, []) : undefined
|
||||
if (attributes === undefined) return undefined
|
||||
if (index > 0) items.push('\n')
|
||||
items.push({ attributes, depth: depth + 1, kind: 'item', node: item, path: [...path, 'content', index] })
|
||||
items.push({ attributes, depth: depth + 1, kind: 'item', node: item, path: [...path, 'content', index], tag: 'li' })
|
||||
}
|
||||
return containerPieces(element.tag, element.attributes, node.content === undefined ? undefined : items)
|
||||
}
|
||||
|
||||
// `undefined` where the table, a row or a cell holds what the elements cannot, so the whole table rides the carry.
|
||||
function tablePieces({ depth, node, path }: Block): Pending[] | undefined {
|
||||
const attributes = dataAttributes(node, blockNodes.table.attributes, [])
|
||||
if (attributes === undefined) return undefined
|
||||
const rows: Row[] = []
|
||||
for (const [index, held] of nodeContent(node).entries()) {
|
||||
const rowPath = [...path, 'content', index]
|
||||
const pieces = held.type === 'tableRow' ? rowPieces(held, rowPath, depth + 2) : undefined
|
||||
if (pieces === undefined) return undefined
|
||||
rows.push({ depth: depth + 1, kind: 'row', path: rowPath, pieces })
|
||||
}
|
||||
return containerPieces('table', attributes, node.content === undefined ? undefined : sectionPieces(rows, isHeaderRow(nodeContent(node)[0] ?? {})))
|
||||
}
|
||||
|
||||
function rowPieces(row: AdfNode, path: ConvertErrorPath, depth: number): Pending[] | undefined {
|
||||
const attributes = dataAttributes(row, blockNodes.tableRow.attributes, [])
|
||||
if (attributes === undefined) return undefined
|
||||
const cells: Pending[] = [`<tr${attributes}>`]
|
||||
for (const [index, cell] of nodeContent(row).entries()) {
|
||||
const tag = cellTag(cell)
|
||||
const held = tag === undefined ? undefined : cellAttributes(cell)
|
||||
if (tag === undefined || held === undefined) return undefined
|
||||
cells.push({ attributes: held, depth, kind: 'item', node: cell, path: [...path, 'content', index], tag })
|
||||
}
|
||||
cells.push('</tr>')
|
||||
return cells
|
||||
}
|
||||
|
||||
function cellTag({ type }: AdfNode): string | undefined {
|
||||
if (type === 'tableCell') return 'td'
|
||||
return type === 'tableHeader' ? 'th' : undefined
|
||||
}
|
||||
|
||||
function sectionPieces(rows: readonly Row[], headed: boolean): Pending[] {
|
||||
const body = headed ? rows.slice(1) : rows
|
||||
const head = headed ? ['<thead>\n', ...rows.slice(0, 1), '\n</thead>'] : []
|
||||
const parted = body.flatMap((row, index) => (index > 0 ? ['\n', row] : [row]))
|
||||
return [...head, ...(headed && parted.length > 0 ? ['\n'] : []), ...(parted.length > 0 ? ['<tbody>\n', ...parted, '\n</tbody>'] : [])]
|
||||
}
|
||||
|
||||
function listElement(node: AdfNode): { attributes: string; tag: string } | undefined {
|
||||
if (node.type === 'bulletList') {
|
||||
const attributes = dataAttributes(node, blockNodes.bulletList.attributes, [])
|
||||
@@ -101,15 +143,15 @@ function containerPieces(tag: string, attributes: string, children: Pending[] |
|
||||
return [`<${tag}${attributes}>\n`, ...children, `\n</${tag}>`]
|
||||
}
|
||||
|
||||
function writeItem({ attributes, depth, node, path }: Item): Result<Pending[]> {
|
||||
if (node.content === undefined) return success([`<li${attributes}></li>`])
|
||||
function writeItem({ attributes, depth, node, path, tag }: Item): Result<Pending[]> {
|
||||
if (node.content === undefined) return success([`<${tag}${attributes}></${tag}>`])
|
||||
const blocks = blockPieces(node.content, path, depth)
|
||||
const [first] = node.content
|
||||
const line = first?.type === 'paragraph' ? tryFirstParagraph(first, [...path, 'content', 0]) : undefined
|
||||
if (line === undefined) return success([`<li${attributes}>\n`, ...blocks, '\n</li>'])
|
||||
if (line === undefined) return success([`<${tag}${attributes}>\n`, ...blocks, `\n</${tag}>`])
|
||||
if (!line.ok) return line
|
||||
// The first paragraph, written on the item's line, stands in for its block.
|
||||
return success(blocks.length === 1 ? [`<li${attributes}>${line.value}</li>`] : [`<li${attributes}>${line.value}`, ...blocks.slice(1), '\n</li>'])
|
||||
return success(blocks.length === 1 ? [`<${tag}${attributes}>${line.value}</${tag}>`] : [`<${tag}${attributes}>${line.value}`, ...blocks.slice(1), `\n</${tag}>`])
|
||||
}
|
||||
|
||||
// `undefined` where the paragraph takes a line of its own.
|
||||
|
||||
@@ -200,6 +200,72 @@ test('refuses a carry element holding a shape every writer refuses, at any depth
|
||||
}
|
||||
})
|
||||
|
||||
test('reads a table back, its rows, cells and attributes as adfToHtml writes them', () => {
|
||||
const row = (...content: AdfNode[]): AdfNode => ({ content, type: 'tableRow' })
|
||||
const body = '<tr><td>Bolt M8</td></tr>'
|
||||
const boltRow = row({ content: [paragraph(text('Bolt M8'))], type: 'tableCell' })
|
||||
const partRow = row({ content: [paragraph(text('Part'))], type: 'tableHeader' })
|
||||
assert.deepEqual(read(htmlToAdf(`<table>\n<thead>\n<tr><th>Part</th></tr>\n</thead>\n<tbody>\n${body}\n${body}\n</tbody>\n</table>\n`)), document({ content: [partRow, boltRow, boltRow], type: 'table' }))
|
||||
assert.deepEqual(read(htmlToAdf('<table>\n<thead>\n<tr><th>Part</th></tr>\n</thead>\n</table>\n')), document({ content: [partRow], type: 'table' }))
|
||||
assert.deepEqual(read(htmlToAdf(`<table>\n<tbody>\n${body}\n<tr><th>Part</th></tr>\n</tbody>\n</table>\n`)), document({ content: [boltRow, partRow], type: 'table' }))
|
||||
assert.deepEqual(read(htmlToAdf('<table></table>\n')), document({ type: 'table' }))
|
||||
assert.deepEqual(read(htmlToAdf('<table>\n<tbody>\n<tr></tr>\n</tbody>\n</table>\n')), document({ content: [{ type: 'tableRow' }], type: 'table' }))
|
||||
assert.deepEqual(read(htmlToAdf('<table>\n<tbody>\n<tr><td></td><th></th></tr>\n</tbody>\n</table>\n')), document({ content: [row({ type: 'tableCell' }, { type: 'tableHeader' })], type: 'table' }))
|
||||
assert.deepEqual(read(htmlToAdf('<table>\n<tbody>\n<tr><td>a\n<p>b</p>\n</td><td>\n<hr>\n</td></tr>\n</tbody>\n</table>\n')), document({ content: [row({ content: [paragraph(text('a')), paragraph(text('b'))], type: 'tableCell' }, { content: [{ type: 'rule' }], type: 'tableCell' })], type: 'table' }))
|
||||
assert.deepEqual(
|
||||
read(htmlToAdf('<table data-layout="wide" data-width="760">\n<tbody>\n<tr data-local-id="r1"><td colspan="2" rowspan="3" style="background-color:#fffae6" data-colwidth="120,80" data-local-id="c1" data-valign="top">x</td></tr>\n</tbody>\n</table>\n')),
|
||||
document({ attrs: { layout: 'wide', width: 760 }, content: [{ attrs: { localId: 'r1' }, content: [{ attrs: { background: '#fffae6', colspan: 2, colwidth: [120, 80], localId: 'c1', rowspan: 3, valign: 'top' }, content: [paragraph(text('x'))], type: 'tableCell' }], type: 'tableRow' }], type: 'table' }),
|
||||
)
|
||||
assert.deepEqual(read(htmlToAdf('<ul>\n<li>\n<table></table>\n</li>\n</ul>\n')), document({ content: [item({ type: 'table' })], type: 'bulletList' }))
|
||||
})
|
||||
|
||||
const tableRefusals: readonly (readonly [string, string])[] = [
|
||||
['<table><tr></tr></table>\n', 'unmappable-html'],
|
||||
['<table>\n<tr></tr>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<thead>\n</thead>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<thead>\n<tr></tr>\n</thead>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<thead>\n<tr><td>a</td></tr>\n</thead>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<thead>\n<tr><th>a</th><td>b</td></tr>\n</thead>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<thead>\n<tr><th>a</th></tr>\n<tr><th>b</th></tr>\n</thead>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><th>a</th></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<td>a</td>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><p>a</p></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td>a</td>\n<td>b</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td><p>a</p></td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td rowspan="2" colspan="2">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td colspan="2" colspan="2">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td data-local-id="c" colspan="2">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td data-local-id="c" data-colwidth="1">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td colspan="0">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td colspan="02">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td rowspan="x">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td style="color:red">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td style="background-color:url(x)">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
|
||||
['<table>\n<tbody>\n<tr><td data-colwidth="[1]">a</td></tr>\n</tbody>\n</table>\n', 'unsupported-node-shape'],
|
||||
['<table>\n<tbody>\n<tr><td data-colwidth="01">a</td></tr>\n</tbody>\n</table>\n', 'unsupported-node-shape'],
|
||||
['<table data-size="big"></table>\n', 'unsupported-node-shape'],
|
||||
['<table>\n<tbody>\n<tr data-size="big"></tr>\n</tbody>\n</table>\n', 'unsupported-node-shape'],
|
||||
['<table>\n<tbody>\n<tr><td data-size="big"></td></tr>\n</tbody>\n</table>\n', 'unsupported-node-shape'],
|
||||
]
|
||||
|
||||
for (const [html, code] of tableRefusals) {
|
||||
test(`refuses a table not written as adfToHtml writes it: ${JSON.stringify(html)}`, () => {
|
||||
assert.equal(refusal(htmlToAdf(html)).split(' ')[0], code)
|
||||
})
|
||||
}
|
||||
|
||||
test('counts a table and its rows each a level, and a cell none, as adfToHtml does', () => {
|
||||
const holding = (levels: number, inner: string): string => `${'<blockquote>\n'.repeat(levels)}${inner}${'\n</blockquote>'.repeat(levels)}\n`
|
||||
const row = '<table>\n<tbody>\n<tr></tr>\n</tbody>\n</table>'
|
||||
const cell = '<table>\n<tbody>\n<tr><td>x</td></tr>\n</tbody>\n</table>'
|
||||
assert.equal(htmlToAdf(holding(499, row)).ok, true)
|
||||
assert.equal(refusal(htmlToAdf(holding(500, row))).split(' ')[0], 'unsupported-nesting-depth')
|
||||
assert.equal(htmlToAdf(holding(500, '<table></table>')).ok, true)
|
||||
assert.equal(htmlToAdf(holding(498, cell)).ok, true)
|
||||
assert.equal(refusal(htmlToAdf(holding(499, cell))).split(' ')[0], 'unsupported-nesting-depth')
|
||||
})
|
||||
|
||||
test('reads a carry element from its data-adf, what it shows read back as adfToHtml writes it', () => {
|
||||
assert.deepEqual(read(htmlToAdf(`${carry('div', panel, '<p>Check the collation.</p>')}\n<p>Ask ${carry('span', mention, '@Mikael')}</p>\n`)), document(panel, paragraph(text('Ask '), mention)))
|
||||
assert.deepEqual(read(htmlToAdf(`<p>${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')}</p>\n`)), document(paragraph(text('a\u0085b', { type: 'underline' }))))
|
||||
@@ -421,14 +487,14 @@ const messages: readonly (readonly [string, string])[] = [
|
||||
['<p>a </p>\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found " </p>\\n"'],
|
||||
['<pre><code>a</code></pre\n', 'a code block\'s text ends at </code></pre>: found "</code></pre\\n"'],
|
||||
['<pre><code class="hljs language-js">x</code></pre>\n', '<code> as written here is no code element of the dialect, which writes <code> or <code class="language-…">: found "<code class=\\"hljs langua"…'],
|
||||
['<ul>\n<li>a</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block, and its text spells < as <: found "</ul>\\n"'],
|
||||
['<ul>\n<li>a</ul>\n', 'the line ends at </li>, or at a line feed before the next block, and its text spells < as <: found "</ul>\\n"'],
|
||||
['<ul>\n<li>a<b>b</b></li>\n</ul>\n', '<b> as written here is no inline element of the dialect: found "<b>b</b></li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li>if a < b</li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block, and its text spells < as <: found "< b</li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li>if a < b</li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block, and its text spells < as <: found "< b</li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li>see <a href="https://x.test/?a=1&b=2">x</a></li>\n</ul>\n', '<a> holds its attributes as name="value", each value escaping & as & and " as ", then >: found "&b=2\\">x</a></li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li><p>a</p><strong>x</strong></li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<strong>x</strong></li>\\n"…'],
|
||||
['<ul>\n<li>Bolt<ul>\n<li>a</li>\n</ul></li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<ul>\\n<li>a</li>\\n</ul></l"…'],
|
||||
['<ul>\n<li>a<li>b</li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<li>b</li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li>a\n<li>b</li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<li>b</li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li><p>a</p><strong>x</strong></li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<strong>x</strong></li>\\n"…'],
|
||||
['<ul>\n<li>Bolt<ul>\n<li>a</li>\n</ul></li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<ul>\\n<li>a</li>\\n</ul></l"…'],
|
||||
['<ul>\n<li>a<li>b</li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<li>b</li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li>a\n<li>b</li>\n</ul>\n', 'the line ends at </li>, or at a line feed before the next block: found "<li>b</li>\\n</ul>\\n"'],
|
||||
['<p><strong><b>x</b></strong></p>\n', '<b> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<b>x</b></strong></p>\\n"'],
|
||||
['<p><strong><br>x</strong></p>\n', '<br> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<br>x</strong></p>\\n"'],
|
||||
['<p><em></em></p>\n', 'a mark element holds text, or one other mark element around text: found "</em></p>\\n"'],
|
||||
@@ -438,9 +504,9 @@ const messages: readonly (readonly [string, string])[] = [
|
||||
['<div class="adf-carry" data-adf="{ "type":"x"}"></div>\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ "type":""…'],
|
||||
[`${documentCarry}\n<hr>\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n<hr>\\n"'],
|
||||
[`<hr>\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "<div class=\\"adf-carry\\" d"…'],
|
||||
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', 'an item\'s first paragraph holding content and no attribute stands bare on the item\'s line, as in <li>Bolt</li>: found "<p>Bolt</p></li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li><p></p></li>\n</ul>\n', 'an item\'s first paragraph holding neither content nor an attribute stands on a line of its own: found "<p></p></li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li>\n<p data-local-id="x"></p>\n</li>\n</ul>\n', 'an item\'s first paragraph holding an attribute stands as its <p> on the item\'s line, straight after <li>: found "<p data-local-id=\\"x\\"></p"…'],
|
||||
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', 'the first paragraph of an item or cell holding content and no attribute stands bare on its line, as in <li>Bolt</li>: found "<p>Bolt</p></li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li><p></p></li>\n</ul>\n', 'the first paragraph of an item or cell holding neither content nor an attribute stands on a line of its own: found "<p></p></li>\\n</ul>\\n"'],
|
||||
['<ul>\n<li>\n<p data-local-id="x"></p>\n</li>\n</ul>\n', 'the first paragraph of an item or cell holding an attribute stands as its <p> on its line, straight after <li> or <td>: found "<p data-local-id=\\"x\\"></p"…'],
|
||||
['<p><img src="a">b</p>\n', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it: found "b</p>\\n"'],
|
||||
['<p><img src="a" alt=""></p>\n', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it: found "<img src=\\"a\\" alt=\\"\\"></p>"…'],
|
||||
['<ol start="03">\n<li>a</li>\n</ol>\n', 'an ordered list starts at an integer, written as JavaScript writes it: found "03\\">\\n<li>a</li>\\n</ol>\\n"'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ParseError, Result } from '../../result.ts'
|
||||
import { blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { carryContent } from '../carry-content.ts'
|
||||
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
|
||||
import { isHeaderRow, readCellAttributes } from '../cell-element.ts'
|
||||
import { containerElements, headingElements, headingLevel, readListStart, type ContainerType } from '../block-elements.ts'
|
||||
import { firstParagraphForm } from '../first-paragraph.ts'
|
||||
import { foundAt, misread, misspelledText, positionIn, readStartTag, refused, startTagName, type Place, type ReadNode, type StartTag } from './source.ts'
|
||||
@@ -27,10 +28,13 @@ type BlockReader = (html: string, tag: StartTag, place: Place, open: Open) => Re
|
||||
|
||||
type BlockStep = { end: number; kind: 'node'; node: AdfNode } | { end: number; kind: 'opened'; open: Open }
|
||||
|
||||
// A container whose blocks or items are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it.
|
||||
type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'list'; node: AdfNode; place: Place | undefined }
|
||||
// A container whose blocks, items, rows or cells are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it. A table's rows are `rows` and its closer turns from `headCloser` to `bodyCloser` at `</thead>`.
|
||||
type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'list' | 'row' | 'rows'; node: AdfNode; place: Place | undefined }
|
||||
|
||||
// No element name opens both a block and an inline node, so the name alone tells an item's line from its next block.
|
||||
const bodyCloser = '\n</tbody>\n</table>'
|
||||
const headCloser = '\n</thead>\n</table>'
|
||||
const headToBody = '</thead>\n<tbody>\n'
|
||||
const blockReaders: ReadonlyMap<string, BlockReader> = new Map([
|
||||
[containerElements.blockquote, containerReader('blockquote')],
|
||||
[containerElements.bulletList, containerReader('bulletList')],
|
||||
@@ -39,6 +43,7 @@ const blockReaders: ReadonlyMap<string, BlockReader> = new Map([
|
||||
['hr', readRule],
|
||||
['p', readParagraph],
|
||||
['pre', readCodeBlock],
|
||||
['table', readTable],
|
||||
...headingElements.map((name): [string, BlockReader] => [name, readHeading]),
|
||||
])
|
||||
const codeCloser = '</code></pre>'
|
||||
@@ -46,9 +51,9 @@ const documentCarryAlone = 'a carry element holding the document stands alone, a
|
||||
const documentCarryEnd = `${carryContent({ type: 'doc' }, 'div')}</div>`
|
||||
const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact')
|
||||
const formRefusals: Readonly<Record<FirstParagraphForm, string>> = {
|
||||
bare: "an item's first paragraph holding content and no attribute stands bare on the item's line, as in <li>Bolt</li>",
|
||||
block: "an item's first paragraph holding neither content nor an attribute stands on a line of its own",
|
||||
element: "an item's first paragraph holding an attribute stands as its <p> on the item's line, straight after <li>",
|
||||
bare: "the first paragraph of an item or cell holding content and no attribute stands bare on its line, as in <li>Bolt</li>",
|
||||
block: 'the first paragraph of an item or cell holding neither content nor an attribute stands on a line of its own',
|
||||
element: 'the first paragraph of an item or cell holding an attribute stands as its <p> on its line, straight after <li> or <td>',
|
||||
}
|
||||
|
||||
// Reads back what `adfToHtml` writes, as it or a browser spells it.
|
||||
@@ -90,7 +95,7 @@ function readBlocks(html: string): Result<AdfDocument, ParseError> {
|
||||
for (;;) {
|
||||
const place = { index: open.content.length, parent: open.place }
|
||||
if (open.depth > largestNesting) return refused('unsupported-nesting-depth', `blocks nest deeper than ${largestNesting} levels`, html, index, place)
|
||||
const step = open.kind === 'list' ? readItem(html, index, place, open.depth) : readBlock(html, index, place, open)
|
||||
const step = readStep(html, index, place, open)
|
||||
if (!step.ok) return step
|
||||
index = step.value.end
|
||||
if (step.value.kind === 'opened') {
|
||||
@@ -102,9 +107,12 @@ function readBlocks(html: string): Result<AdfDocument, ParseError> {
|
||||
while (open !== root && html.startsWith(open.closer, index)) {
|
||||
index += open.closer.length
|
||||
const closed: AdfNode = { ...open.node, content: open.content }
|
||||
const closedPlace = open.place
|
||||
open = opened.pop() ?? root
|
||||
if (open.kind === 'rows' && open.content.length === 0 && isHeaderRow(closed) !== (open.closer === headCloser)) return firstRowRefusal(html, index, closedPlace)
|
||||
open.content.push(closed)
|
||||
}
|
||||
if (open.kind === 'row') continue
|
||||
if (open === root && index === html.length - 1 && html.endsWith('\n')) return success({ content: root.content, type: 'doc', version: 1 })
|
||||
if (!html.startsWith('\n', index)) return partedRefusal(html, index, open.place)
|
||||
index += 1
|
||||
@@ -117,6 +125,12 @@ function partedRefusal<T>(html: string, end: number, place: Place | undefined):
|
||||
return refused('unmappable-html', `a line feed parts each block from the next, and ends the HTML: ${foundAt(html, at)}`, html, at, place)
|
||||
}
|
||||
|
||||
function readStep(html: string, index: number, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
if (open.kind === 'list') return readItem(html, index, place, open.depth)
|
||||
if (open.kind === 'rows') return readRow(html, index, place, open)
|
||||
return open.kind === 'row' ? readCell(html, index, place, open.depth) : readBlock(html, index, place, open)
|
||||
}
|
||||
|
||||
function readBlock(html: string, index: number, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
const reader = tag === undefined ? undefined : blockReaders.get(tag.name)
|
||||
@@ -237,6 +251,49 @@ function readContainer(html: string, tag: StartTag, type: ContainerType, place:
|
||||
return success({ end: tag.end + 1, kind: 'opened', open: { closer: `\n${closer}`, content: [], depth: open.depth + 1, kind: type === 'blockquote' ? 'blocks' : 'list', node, place } })
|
||||
}
|
||||
|
||||
function readTable(html: string, tag: StartTag, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
const data = readDataAttributes(tag.attributes, tag.name, blockNodes.table.attributes, [])
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const node = built('table', data.value, [])
|
||||
if (html.startsWith('</table>', tag.end)) return success({ end: tag.end + '</table>'.length, kind: 'node', node })
|
||||
const headed = html.startsWith('\n<thead>\n', tag.end)
|
||||
if (!headed && !html.startsWith('\n<tbody>\n', tag.end)) return refused('unmappable-html', `<table> holds </table> straight after it, or a line feed and <thead> or <tbody> on a line of its own: ${foundAt(html, tag.end)}`, html, tag.end, place)
|
||||
return success({ end: tag.end + '\n<thead>\n'.length, kind: 'opened', open: { closer: headed ? headCloser : bodyCloser, content: [], depth: open.depth + 1, kind: 'rows', node, place } })
|
||||
}
|
||||
|
||||
// `<thead>` holds one row, so a second row stands past `</thead>`.
|
||||
function readRow(html: string, index: number, place: Place, table: Open): Result<BlockStep, ParseError> {
|
||||
const pastHead = table.closer === headCloser && table.content.length > 0
|
||||
if (pastHead && !html.startsWith(headToBody, index)) return refused('unmappable-html', `<thead> holds one row, then </thead>: ${foundAt(html, index)}`, html, index, place)
|
||||
const start = pastHead ? index + headToBody.length : index
|
||||
if (pastHead) table.closer = bodyCloser
|
||||
const tag = readStartTag(html, start)
|
||||
if (tag?.name !== 'tr') {
|
||||
const stop = misread(html, start, 'a table section holds <tr> rows, one to a line', 'no table row of the dialect')
|
||||
return refused('unmappable-html', stop.message, html, stop.at, place)
|
||||
}
|
||||
const data = readDataAttributes(tag.attributes, tag.name, blockNodes.tableRow.attributes, [])
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const node = built('tableRow', data.value, [])
|
||||
if (!html.startsWith('</tr>', tag.end)) return success({ end: tag.end, kind: 'opened', open: { closer: '</tr>', content: [], depth: table.depth + 1, kind: 'row', node, place } })
|
||||
return table.closer === headCloser ? firstRowRefusal(html, tag.end, place) : success({ end: tag.end + '</tr>'.length, kind: 'node', node })
|
||||
}
|
||||
|
||||
function firstRowRefusal<T>(html: string, index: number, place: Place | undefined): Result<T, ParseError> {
|
||||
return refused('unmappable-html', `a table's first row stands in <thead> where every cell is a <th> and it holds one, and in <tbody> otherwise: ${foundAt(html, index)}`, html, index, place)
|
||||
}
|
||||
|
||||
function readCell(html: string, index: number, place: Place, depth: number): Result<BlockStep, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name !== 'td' && tag?.name !== 'th') {
|
||||
const stop = misread(html, index, 'a row holds <td> and <th> cells on its line, then </tr>', 'no table cell of the dialect')
|
||||
return refused('unmappable-html', stop.message, html, stop.at, place)
|
||||
}
|
||||
const attrs = readCellAttributes(tag.attributes, tag.name)
|
||||
if (attrs.fault !== undefined) return refused(attrs.fault.code, attrs.fault.message, html, tag.start, place)
|
||||
return readHolder(html, tag, built(tag.name === 'td' ? 'tableCell' : 'tableHeader', attrs.value, []), place, depth)
|
||||
}
|
||||
|
||||
function readItem(html: string, index: number, place: Place, depth: number): Result<BlockStep, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name !== 'li') {
|
||||
@@ -245,30 +302,36 @@ function readItem(html: string, index: number, place: Place, depth: number): Res
|
||||
}
|
||||
const data = readDataAttributes(tag.attributes, tag.name, blockNodes.listItem.attributes, [])
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const item = built('listItem', data.value, [])
|
||||
if (html.startsWith('</li>', tag.end)) return success({ end: tag.end + '</li>'.length, kind: 'node', node: item })
|
||||
const open: Open = { closer: '\n</li>', content: [], depth, kind: 'item', node: item, place }
|
||||
return html.startsWith('\n', tag.end) && isBlockStart(html, tag.end + 1) ? success({ end: tag.end + 1, kind: 'opened', open }) : readItemLine(html, tag.end, open)
|
||||
return readHolder(html, tag, built('listItem', data.value, []), place, depth)
|
||||
}
|
||||
|
||||
function readItemLine(html: string, index: number, open: Open): Result<BlockStep, ParseError> {
|
||||
// An item or cell: its element holding nothing, its first paragraph on its line, or its blocks a line each.
|
||||
function readHolder(html: string, tag: StartTag, node: AdfNode, place: Place, depth: number): Result<BlockStep, ParseError> {
|
||||
const closer = `</${tag.name}>`
|
||||
if (html.startsWith(closer, tag.end)) return success({ end: tag.end + closer.length, kind: 'node', node })
|
||||
const open: Open = { closer: `\n${closer}`, content: [], depth, kind: 'item', node, place }
|
||||
return html.startsWith('\n', tag.end) && isBlockStart(html, tag.end + 1) ? success({ end: tag.end + 1, kind: 'opened', open }) : readItemLine(html, tag.end, tag.name, open)
|
||||
}
|
||||
|
||||
function readItemLine(html: string, index: number, element: string, open: Open): Result<BlockStep, ParseError> {
|
||||
const place = { index: 0, parent: open.place }
|
||||
const closer = `</${element}>`
|
||||
const line = readLineParagraph(html, index, place)
|
||||
if (!line.ok) return line
|
||||
const { end, node } = line.value
|
||||
if (!html.startsWith('</li>', end) && !html.startsWith('\n', end)) {
|
||||
const lineEnd = "an item's line ends at </li>, or at a line feed before the item's next block"
|
||||
if (!html.startsWith(closer, end) && !html.startsWith('\n', end)) {
|
||||
const lineEnd = `the line ends at ${closer}, or at a line feed before the next block`
|
||||
// After a line's `</p>`, or before a block or the next item, no inline element can stand.
|
||||
const stop = line.value.form === 'bare' && !isBlockStart(html, end) && startTagName(html, end) !== 'li' ? misread(html, end, `${lineEnd}, and its text spells < as <`, 'no inline element of the dialect') : { at: end, message: `${lineEnd}: ${foundAt(html, end)}` }
|
||||
const stop = line.value.form === 'bare' && !isBlockStart(html, end) && startTagName(html, end) !== element ? misread(html, end, `${lineEnd}, and its text spells < as <`, 'no inline element of the dialect') : { at: end, message: `${lineEnd}: ${foundAt(html, end)}` }
|
||||
return refused('unmappable-html', stop.message, html, stop.at, open.place)
|
||||
}
|
||||
const paragraph = asFirstParagraph({ end, node }, line.value.form, html, index, place)
|
||||
if (!paragraph.ok) return paragraph
|
||||
if (html.startsWith('</li>', end)) return success({ end: end + '</li>'.length, kind: 'node', node: { ...open.node, content: [node] } })
|
||||
if (html.startsWith(closer, end)) return success({ end: end + closer.length, kind: 'node', node: { ...open.node, content: [node] } })
|
||||
return success({ end: end + 1, kind: 'opened', open: { ...open, content: [node] } })
|
||||
}
|
||||
|
||||
// The paragraph on an item's line and the form it stands in there, up to `</li>` or to the line feed before the item's next block.
|
||||
// The paragraph on an item's or cell's line and the form it stands in there, up to its closer or to the line feed before its next block.
|
||||
function readLineParagraph(html: string, index: number, place: Place): Result<{ end: number; form: FirstParagraphForm; node: AdfNode }, ParseError> {
|
||||
const tag = readStartTag(html, index)
|
||||
if (tag?.name === 'p') {
|
||||
|
||||
Reference in New Issue
Block a user