mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Refuse the schema-forbidden shapes in the first step, carried or not
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Breaking:** `adfToHtml`, `adfToPortableMarkdown`, `htmlToAdf` and `losslessMarkdownToAdf` refuse
|
||||
a block or non-text inline node holding `text`, and a leaf block or non-text inline node holding
|
||||
`content`, as `adfToLosslessMarkdown` already did outside a carried node, which it now also
|
||||
refuses. `MIGRATION.md` says how to convert a stored document.
|
||||
- The package's description and keywords say it needs no React, Atlaskit or runtime dependency and
|
||||
runs in Node, Forge, Deno, Bun and browsers, and the README opens with the call to take for each
|
||||
need. The README's first comparison table lists the npm packages running in Node that convert ADF
|
||||
|
||||
@@ -27,6 +27,13 @@ the error's `path` naming the card, since Atlassian's schema gives a card no lin
|
||||
`link` mark from that card before you write it with `0.3.0`; to keep the link's target, put the
|
||||
link on a `text` node beside the card.
|
||||
|
||||
`0.3.0` refuses, as `unsupported-node-shape`, a block or non-text inline node holding `text`, and a
|
||||
leaf block or non-text inline node holding non-empty `content`. Atlassian's schema gives no node
|
||||
those. `adfToHtml` and `adfToPortableMarkdown` wrote such a node in `0.2.0`, and `adfToLosslessMarkdown`
|
||||
wrote one inside a carried node; all three now refuse it, and `losslessMarkdownToAdf` and `htmlToAdf`
|
||||
refuse a stored carry holding one. Read a stored document with `0.2.0`, remove what the error's
|
||||
`path` names and write it with `0.3.0`, once.
|
||||
|
||||
## From `0.1.0` to `0.2.0`
|
||||
|
||||
Each call names the markdown it reads or writes:
|
||||
|
||||
@@ -632,7 +632,7 @@ Every call can raise these:
|
||||
- writes as a directive a node or mark the lossless flavour spells as CommonMark;
|
||||
- puts `!adf:textBreak{}` where it parts nothing, or `!adf:doc` anywhere but as the whole
|
||||
document;
|
||||
- carries an inline card a link marks, as the writers' cause below says.
|
||||
- carries a node the writers' cause below names, such as an inline card a link marks.
|
||||
- **Reading, in `htmlToAdf`.** Fix what the message names;
|
||||
[`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md) lists each element's
|
||||
attributes. The HTML:
|
||||
@@ -641,16 +641,17 @@ Every call can raise these:
|
||||
`data-wrap="yes"`;
|
||||
- holds a carry element whose `data-adf` holds JSON that is no ADF node, is not written compact
|
||||
with its keys sorted, or holds a number JSON cannot spell;
|
||||
- holds a carry element carrying an inline card a link marks, as the writers' cause below says;
|
||||
- holds a carry element carrying a node the writers' cause below names, such as an inline card a
|
||||
link marks;
|
||||
- holds the document's own carry element beside another block.
|
||||
- **Writing, in `adfToLosslessMarkdown`**: a node not riding the carry that carries one mark type
|
||||
twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node
|
||||
holding `content`. Keep the ADF and show the document read-only; where you built the ADF, fix
|
||||
twice. Keep the ADF and show the document read-only; where you built the ADF, fix
|
||||
what the message names.
|
||||
- **Writing, in `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown`**: an inline card
|
||||
marked by a link, even inside a node that rides the carry: Atlassian's schema gives a card no link
|
||||
mark. Keep the ADF and pass the document over or show it read-only; where you built the ADF,
|
||||
remove the link mark from the card the error's `path` names.
|
||||
- **Writing, in `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown`**: what Atlassian's
|
||||
schema gives no node, even inside a node that rides the carry: an inline card marked by a link, a
|
||||
block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding
|
||||
`content`. Keep the ADF and pass the document over or show it read-only; where you built the ADF,
|
||||
remove what the error's `path` and message name.
|
||||
- **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**:
|
||||
|
||||
+3
-6
@@ -436,8 +436,7 @@ the shown code keeps the class its code block's own element takes.
|
||||
Inside a carry element, an image marked by a link to another target shows as portable markdown
|
||||
writes it inline (§A linked image standing inline in portable markdown, its link to another target,
|
||||
is written `[alt](href) ([image](url))`), so both targets stay; Goal 7 counts them as content. A link
|
||||
marking a node that shows a note goes around the note, and an image's own `text`, outside Atlassian's
|
||||
schema, shows first, as every carried node's does. Recorded 2026-10-24 without a panel of its own:
|
||||
marking a node that shows a note goes around the note. Recorded 2026-10-24 without a panel of its own:
|
||||
it joins the second panel's `<a>` to that portable spelling.
|
||||
|
||||
HTML nests no `<a>` in an `<a>`, so no element can hold both a card's URL and the target of a link
|
||||
@@ -762,10 +761,8 @@ writer refuses alike, with one code, message and path, wherever the shape stands
|
||||
too, and a carry reader refuses it with the same code and the writers' message after its holder's
|
||||
name. The first step refuses an `inlineCard` with a `link` mark as `unsupported-node-shape`: the
|
||||
schema gives a card the `annotation` mark alone (§The node tables answer to Atlassian's schema),
|
||||
and HTML nests no `<a>` in an `<a>`. `adfToLosslessMarkdown` still refuses two more, on a node not
|
||||
riding the carry: a block or non-text inline node holding `text`, and a leaf block or non-text
|
||||
inline node holding a non-empty `content`. `todo.md` item 167 moves them, and until it ships this entry does
|
||||
not hold for them.
|
||||
and HTML nests no `<a>` in an `<a>`. It also refuses a block or non-text inline node holding `text`,
|
||||
and a leaf block or non-text inline node holding a non-empty `content`.
|
||||
|
||||
Nothing else joins the first step. Nothing refuses a node type, mark or attribute the schema does
|
||||
not name (§Unknown nodes ride the carry), a known node where no section spells it, or a mark a node
|
||||
|
||||
@@ -667,3 +667,20 @@ Read most (opens and searches):
|
||||
- `src/adf/shown-text.ts` 3
|
||||
- `src/html/parse/inline.ts` 3
|
||||
- `src/html/parse/source.ts` 3
|
||||
|
||||
## Reading cost, chunk 6b022a1028b9, finished 2026-11-05T09:52:21Z
|
||||
|
||||
253907 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `ci.sh` 8
|
||||
- `docs/decisions.md` 8
|
||||
- `src/conformance/corpus.test.ts` 8
|
||||
- `todo.md` 8
|
||||
- `AGENTS.md` 6
|
||||
- `README.md` 5
|
||||
- `browser-tests/run.js` 4
|
||||
- `.gitignore` 3
|
||||
- `corpus/README.md` 3
|
||||
- `package.json` 3
|
||||
|
||||
+4
-3
@@ -582,9 +582,10 @@ open or close where the run sits (`un**-real**istic`), or one CommonMark's match
|
||||
the merged run's pairing to another delimiter — rides the inline carry whole. So does every node
|
||||
holding `em`, `strike` or `strong` in a line still read back wrong after 32 tries. An opaque carry inside
|
||||
a mark spelling is a named error in input: the carry restores its node exactly, marks included
|
||||
(`docs/decisions.md` §Unknown nodes ride the carry). An inline card a link marks rides no carry:
|
||||
`adfToLosslessMarkdown` refuses it as `unsupported-node-shape`, and so does `losslessMarkdownToAdf`
|
||||
reading a carry that holds one, since Atlassian's schema gives a card no link mark.
|
||||
(`docs/decisions.md` §Unknown nodes ride the carry). 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` ride no carry:
|
||||
`adfToLosslessMarkdown` refuses each as `unsupported-node-shape`, and so does `losslessMarkdownToAdf`
|
||||
reading a carry that holds one, since Atlassian's schema gives no node those.
|
||||
|
||||
```
|
||||
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
||||
|
||||
+4
-3
@@ -123,12 +123,13 @@ later release writes it as its own element.
|
||||
alone whose `url` is missing or holds only whitespace;
|
||||
- a code block holding more than one text node, a marked one, or a language holding ASCII
|
||||
whitespace;
|
||||
- a rule or hard break holding `content`, and a hard break holding an attribute;
|
||||
- a 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.
|
||||
- An inline card a link marks is refused as `unsupported-node-shape`, carried or not, by `adfToHtml`
|
||||
and by `htmlToAdf` reading a carry element: Atlassian's schema gives a card no link mark.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -152,6 +152,41 @@ test('refuses an inline card a link marks, wherever the document holds it and wh
|
||||
}
|
||||
})
|
||||
|
||||
test('refuses what Atlassian\'s schema gives no node, wherever the document holds it and whichever call writes it', () => {
|
||||
const shapes: { message: string; node: AdfNode }[] = [
|
||||
{ message: 'a panel carries no text: this one holds text', node: { text: 'x', type: 'panel' } },
|
||||
{ message: 'a paragraph carries no text: this one holds text', node: { text: 'x', type: 'paragraph' } },
|
||||
{ message: 'a media holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'media' } },
|
||||
{ message: 'a rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } },
|
||||
{ message: 'a status node holds neither content nor text: this one holds content', node: { content: [{ text: 'x', type: 'text' }], type: 'status' } },
|
||||
{ message: 'a status node holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } },
|
||||
{ message: 'a hardBreak node holds neither content nor text: this one holds text', node: { text: 'x', type: 'hardBreak' } },
|
||||
]
|
||||
for (const { message, node } of shapes) {
|
||||
const holders = [
|
||||
{ content: [node], type: 'doc' },
|
||||
{ content: [{ content: [node], type: 'paragraph' }], type: 'doc' },
|
||||
{ content: [{ content: [node], type: 'futureBlock' }], type: 'doc' },
|
||||
]
|
||||
const paths = [['content', 0], ['content', 0, 'content', 0], ['content', 0, 'content', 0]]
|
||||
for (const [index, { content }] of holders.entries()) {
|
||||
for (const write of [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]) {
|
||||
const refused = write({ content, type: 'doc', version: 1 })
|
||||
assert.deepEqual(refused.ok ? 'written' : refused.error, { code: 'unsupported-node-shape', message, path: paths[index] })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('writes what the schema refusals leave to the carry', () => {
|
||||
const carried: AdfNode[] = [
|
||||
{ content: [], type: 'rule' },
|
||||
{ content: [{ text: 'x', type: 'text' }], text: 'y', type: 'futureBlock' },
|
||||
{ content: [{ content: [{ type: 'paragraph' }], type: 'text' }], type: 'paragraph' },
|
||||
]
|
||||
for (const node of carried) for (const write of [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]) assert.equal(write({ content: [node], type: 'doc', version: 1 }).ok, true)
|
||||
})
|
||||
|
||||
test('names the first link-marked card in document order', () => {
|
||||
const card = (url: string): AdfNode => ({ attrs: { url }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' })
|
||||
const content = [{ content: [card('https://a.example/'), card('https://b.example/')], type: 'paragraph' }, { content: [card('https://c.example/')], type: 'paragraph' }]
|
||||
|
||||
+19
-3
@@ -1,4 +1,6 @@
|
||||
import { blockNodeModel } from './block-nodes.ts'
|
||||
import { failure, faulted, success, type ConvertError, type ConvertFault, type Result } from '../result.ts'
|
||||
import { inlineNodeModel } from './inline-nodes.ts'
|
||||
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { withinLongest } from '../length-bound.ts'
|
||||
@@ -147,22 +149,36 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
|
||||
return node.marks ?? []
|
||||
}
|
||||
|
||||
// Walks every node, carried or of an unknown type, so a link-marked card is refused wherever it stands (docs/decisions.md §Schema refusals are made in the first step alone).
|
||||
// Walks every node, carried or of an unknown type, so a shape is refused wherever it stands (docs/decisions.md §Schema refusals are made in the first step alone).
|
||||
export function refusedShape(content: readonly AdfNode[]): ConvertError | undefined {
|
||||
type Step = { index: number; node: AdfNode; parent: Step | undefined }
|
||||
const pending: Step[] = content.map((node, index) => ({ index, node, parent: undefined })).reverse()
|
||||
for (let step = pending.pop(); step !== undefined; step = pending.pop()) {
|
||||
if (step.node.type === 'inlineCard' && nodeMarks(step.node).some(({ type }) => type === 'link')) {
|
||||
const message = shapeRefusal(step.node)
|
||||
if (message !== undefined) {
|
||||
const path: (number | string)[] = []
|
||||
for (let up: Step | undefined = step; up !== undefined; up = up.parent) path.push(up.index, 'content')
|
||||
path.reverse()
|
||||
return { code: 'unsupported-node-shape', message: 'an inlineCard takes no link mark: this one holds one', path }
|
||||
return { code: 'unsupported-node-shape', message, path }
|
||||
}
|
||||
for (const child of nodeContent(step.node).map((node, index) => ({ index, node, parent: step })).reverse()) pending.push(child)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function shapeRefusal(node: AdfNode): string | undefined {
|
||||
if (node.type === 'inlineCard' && nodeMarks(node).some(({ type }) => type === 'link')) return 'an inlineCard takes no link mark: this one holds one'
|
||||
const holdsContent = nodeContent(node).length > 0
|
||||
if (inlineNodeModel(node.type) !== undefined) {
|
||||
if (holdsContent) return `a ${node.type} node holds neither content nor text: this one holds content`
|
||||
return node.text === undefined ? undefined : `a ${node.type} node holds neither content nor text: this one holds text`
|
||||
}
|
||||
const model = blockNodeModel(node.type)
|
||||
if (model === undefined) return undefined
|
||||
if (node.text !== undefined) return `a ${node.type} carries no text: this one holds text`
|
||||
return model.contentModel === 'none' && holdsContent ? `a ${node.type} holds no content: this one holds some` : undefined
|
||||
}
|
||||
|
||||
// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever.
|
||||
function holdsItself(root: object): boolean {
|
||||
const pending = [{ depth: 0, item: root }]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AdfNode } from './document.ts'
|
||||
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||
import { nodeMarks } from './document.ts'
|
||||
|
||||
export const codeTextMarkTypes: readonly string[] = ['annotation', 'code', 'link']
|
||||
export const formattedTextMarkTypes: readonly string[] = ['annotation', 'backgroundColor', 'em', 'link', 'strike', 'strong', 'subsup', 'textColor', 'underline']
|
||||
@@ -40,7 +39,7 @@ export function inlineNodeModel(type: string): InlineNodeModel | undefined {
|
||||
|
||||
// Undefined means every mark: a node type outside this module's tables, which the schema restricts nowhere.
|
||||
export function takenMarkTypes(node: AdfNode): readonly string[] | undefined {
|
||||
if (node.type === 'text') return nodeMarks(node).some((mark) => mark.type === 'code') ? codeTextMarkTypes : formattedTextMarkTypes
|
||||
if (node.type === 'text') return node.marks?.some((mark) => mark.type === 'code') === true ? codeTextMarkTypes : formattedTextMarkTypes
|
||||
return inlineNodeModel(node.type)?.markTypes
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ function shownPiece(node: AdfNode, lineFeed: string): string {
|
||||
return linkedText(node, shownText(node), shownUrl(node), lineFeed)
|
||||
}
|
||||
|
||||
// As portable markdown writes it inline, except that a link around a note stays and an image's own `text`, outside ADF's schema, shows first (docs/decisions.md §The HTML dialect).
|
||||
// As portable markdown writes it inline, except that a link around a note stays (docs/decisions.md §The HTML dialect).
|
||||
function linkedImage(media: AdfNode, lineFeed: string): string {
|
||||
const image = shownImage(media, shownLink(media)?.href)
|
||||
const shown = linkedText(media, image?.text, image?.link, lineFeed)
|
||||
|
||||
@@ -163,7 +163,6 @@ test('carries an emoji, mention, status, date or card holding what its element c
|
||||
assert.equal(carried({ attrs: { size: 'big', text: '🎉' }, type: 'emoji' }), true)
|
||||
assert.equal(carried({ attrs: { id: 5, text: '🎉' }, type: 'emoji' }), true)
|
||||
assert.equal(carried({ attrs: { text: '🎉' }, content: [], type: 'emoji' }), true)
|
||||
assert.equal(carried({ attrs: { text: '🎉' }, content: [text('x')], type: 'emoji' }), true)
|
||||
assert.equal(carried({ attrs: { color: 'red' }, type: 'status' }), true)
|
||||
assert.equal(carried({ attrs: { timestamp: 'soon' }, type: 'date' }), true)
|
||||
assert.equal(carried({ attrs: { timestamp: 1756080000000 }, type: 'date' }), true)
|
||||
@@ -184,7 +183,6 @@ test('carries an emoji, mention, status, date or card holding what its element c
|
||||
assert.equal(carried({ attrs: { data: { name: 'Roadmap\u0001', url: 'https://example.com/' } }, type: 'inlineCard' }), true)
|
||||
assert.equal(carried({ attrs: { id: '5b10a2' }, type: 'mention' }), true)
|
||||
assert.equal(carried({ attrs: { id: '5b10a2', text: '' }, type: 'mention' }), true)
|
||||
assert.equal(carried({ attrs: { id: '5b10a2', text: '@Mikael' }, content: [text('x')], type: 'mention' }), true)
|
||||
assert.equal(carried({ attrs: { id: '5b10a2', text: '@Mikael' }, marks: [comment], type: 'mention' }), true)
|
||||
assert.equal(carried({ attrs: { id: 7, text: '@Mikael' }, type: 'mention' }), true)
|
||||
assert.equal(carried({ attrs: { id: '5b10a2', text: 7 }, type: 'mention' }), true)
|
||||
@@ -233,7 +231,6 @@ test('shows a code block in a div carry as pre and code, keeping its line feeds
|
||||
|
||||
test('shows a block inside a code block in a div carry as a paragraph, and the text after it as code', () => {
|
||||
assert.equal(shown(html(adfToHtml(document({ content: [text('x'), paragraph(text('a\nb')), text('z')], type: 'codeBlock' })))), '<pre><code>x</code></pre><p>a<br>b</p><pre><code>z</code></pre>')
|
||||
assert.equal(shown(html(adfToHtml(document({ content: [text('x'), { attrs: { id: 'u' }, content: [paragraph(text('a'))], type: 'mention' }, text('z')], type: 'codeBlock' })))), '<pre><code>x@u</code></pre><p>a</p><pre><code>z</code></pre>')
|
||||
})
|
||||
|
||||
test('gives a code block in a div carry the class its element takes, and none where its language is no class token', () => {
|
||||
@@ -270,8 +267,6 @@ test('shows a carried image marked by a link to another target as that link, the
|
||||
assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/' }), '<p><a href="https://example.com/">The moon</a></p>')
|
||||
const blank = shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { href: ' ' }, type: 'link' }], type: 'media' }], type: 'mediaSingle' }))))
|
||||
assert.equal(blank, '<p><a href=" ">https://example.com/moon.jpg</a> (<a href="https://example.com/moon.jpg">image</a>)</p>')
|
||||
const texted = shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [link], text: 'Moon', type: 'media' }], type: 'mediaSingle' }))))
|
||||
assert.equal(texted, '<p><a href="https://example.com/">Moon</a> (<a href="https://example.com/moon.jpg">image</a>)</p>')
|
||||
const inline = (mark: AdfMark): string => shown(html(adfToHtml(document(paragraph({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [mark], type: 'mediaInline' })))))
|
||||
assert.equal(inline(link), '<a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)')
|
||||
assert.equal(inline({ attrs: { href: 'https://example.com/', title: 5 }, type: 'link' }), '<a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)')
|
||||
@@ -308,7 +303,6 @@ test('carries a node holding what its element cannot', () => {
|
||||
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [text('x')], type: 'paragraph' }), true)
|
||||
assert.equal(carriedBlock({ content: [], type: 'paragraph' }), true)
|
||||
assert.equal(carriedBlock({ attrs: {}, type: 'rule' }), true)
|
||||
assert.equal(carriedBlock({ content: [text('x')], type: 'rule' }), true)
|
||||
assert.equal(carriedBlock({ attrs: { color: 'a\u0001' }, type: 'rule' }), true)
|
||||
assert.equal(carriedBlock({ attrs: { level: 1 }, type: 'rule' }), true)
|
||||
assert.equal(carriedBlock({ type: 'unknownBlock' }), true)
|
||||
@@ -346,7 +340,6 @@ test('carries inline what its element cannot hold, and a text node that would jo
|
||||
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/' }, type: 'underline' })), 1)
|
||||
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/', id: ids.item }, type: 'link' })), 1)
|
||||
assert.equal(carriedSpans({ attrs: { text: '\n' }, type: 'hardBreak' }), 1)
|
||||
assert.equal(carriedSpans({ content: [text('x')], type: 'hardBreak' }), 1)
|
||||
assert.equal(carriedSpans(paragraph(text('x'))), 1)
|
||||
})
|
||||
|
||||
|
||||
@@ -155,6 +155,6 @@ function tryImage(node: AdfNode): string | undefined {
|
||||
}
|
||||
|
||||
function tryRule(node: AdfNode): string | undefined {
|
||||
const attributes = node.type === 'rule' && nodeContent(node).length === 0 ? dataAttributes(node, blockNodes.rule.attributes, []) : undefined
|
||||
const attributes = node.type === 'rule' ? dataAttributes(node, blockNodes.rule.attributes, []) : undefined
|
||||
return attributes === undefined ? undefined : `<hr${attributes}>`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { anchorOpener } from '../anchor-element.ts'
|
||||
import { carriedInline } from './carry-element.ts'
|
||||
import { elementOfMark } from '../mark-elements.ts'
|
||||
import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { linkAttrs } from '../../adf/mark-attributes.ts'
|
||||
import { nodeElement } from './node-element.ts'
|
||||
import { success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -25,7 +25,7 @@ export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): R
|
||||
}
|
||||
|
||||
function tryInlineNode(node: AdfNode): string | undefined {
|
||||
if (node.type === 'hardBreak') return holdsOnlyAttributes(node, []) && nodeContent(node).length === 0 ? '<br>' : undefined
|
||||
if (node.type === 'hardBreak') return holdsOnlyAttributes(node, []) ? '<br>' : undefined
|
||||
const element = nodeElement(node)
|
||||
if (element !== undefined) return element
|
||||
const text = isSpellableText(node) ? writableText(node.text) : undefined
|
||||
|
||||
@@ -187,6 +187,19 @@ test('refuses a carry element holding an inline card a link marks, at any depth
|
||||
}
|
||||
})
|
||||
|
||||
test('refuses a carry element holding a shape every writer refuses, at any depth in the node it carries', () => {
|
||||
const shapes = [
|
||||
{ message: 'a panel carries no text: this one holds text', node: { text: 'x', type: 'panel' } },
|
||||
{ message: 'a rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } },
|
||||
{ message: 'a status node holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } },
|
||||
]
|
||||
for (const { message: refusal, node } of shapes) {
|
||||
for (const carried of [node, { content: [node], type: 'futureBlock' }]) {
|
||||
assert.equal(message(htmlToAdf(`${carry('div', carried, '')}\n`)), `a carry element's data-adf holds a node every writer refuses: ${refusal}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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' }))))
|
||||
|
||||
@@ -174,8 +174,6 @@ function directivePair(node: AdfNode, opener: string, body: BlockText, headroom:
|
||||
}
|
||||
|
||||
function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
|
||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
||||
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
|
||||
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
|
||||
const opener = spellBlockDirectiveOpener(node, model, path)
|
||||
if (opener === undefined) return carriedFence(carriedBlock(node, path, depth))
|
||||
|
||||
@@ -15,7 +15,7 @@ import { highlightDelimiter } from '../inline-tokens.ts'
|
||||
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts'
|
||||
import { inlineRuns } from './inline-runs.ts'
|
||||
import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { isSpellableText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
@@ -143,7 +143,7 @@ function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): bo
|
||||
const model = inlineNodeModel(node.type)
|
||||
if (model === undefined) return true
|
||||
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
|
||||
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined
|
||||
}
|
||||
|
||||
export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result<null> {
|
||||
@@ -225,11 +225,6 @@ function bracketedLinkTarget(target: string): InlineSegment {
|
||||
return { escaping: 'bracketed-link-target', text: escapeUnbalanced(target, '[', ']') }
|
||||
}
|
||||
|
||||
function heldContentOrText(node: AdfNode): 'content' | 'text' | undefined {
|
||||
if (nodeContent(node).length > 0) return 'content'
|
||||
return node.text === undefined ? undefined : 'text'
|
||||
}
|
||||
|
||||
function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, context: InlineContext): Result<Emission> {
|
||||
if (depth > largestNesting) {
|
||||
return failure('unsupported-nesting-depth', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
|
||||
@@ -268,8 +263,6 @@ function emitLeaf({ carried, index, node }: PlainRun, context: InlineContext): R
|
||||
const types = nodeMarks(node).map((mark) => mark.type)
|
||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, nodePath(context, index))
|
||||
const model = inlineNodeModel(node.type)
|
||||
const held = model === undefined ? undefined : heldContentOrText(node)
|
||||
if (held !== undefined) return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, nodePath(context, index))
|
||||
const segments = model === undefined ? textSegments(node, context) : node.type === 'hardBreak' ? hardBreakSegments(node, model, context) : inlineDirectiveSegments(node, model)
|
||||
return segments === undefined ? carriedRun({ first: index, last: index }, context) : success({ segments })
|
||||
}
|
||||
|
||||
@@ -497,6 +497,16 @@ test('refuses a carry holding an inline card a link marks, whether it stands alo
|
||||
}
|
||||
})
|
||||
|
||||
test('refuses a carry holding a shape every writer refuses, whether it stands alone, in a block or in a fence', () => {
|
||||
const refused = 'unsupported-node-shape: the opaque carry holds a node every writer refuses: '
|
||||
const refusal = (markdown: string): string => String(content(losslessMarkdownToAdf(markdown))).replace(/; .*$/, '')
|
||||
const status = { text: 'x', type: 'status' }
|
||||
const json = JSON.stringify(status).replaceAll('"', '\\"')
|
||||
assert.equal(refusal(`!adf:carry{json="${json}"}\n`), `${refused}a status node holds neither content nor text: this one holds text`)
|
||||
assert.equal(refusal(`\`\`\`adf:futureBlock\n${JSON.stringify({ content: [status] }, null, 2)}\n\`\`\`\n`), `${refused}a status node holds neither content nor text: this one holds text`)
|
||||
assert.equal(refusal(`\`\`\`adf:panel\n${JSON.stringify({ text: 'x' }, null, 2)}\n\`\`\`\n`), `${refused}a panel carries no text: this one holds text`)
|
||||
})
|
||||
|
||||
test('names the shape the inline carry reads alone', () => {
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:carry[x]{json="{}"}\n')), 'unsupported-node-shape: carry takes no content: this one holds some')
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:carry{}\n')), 'unsupported-node-shape: carry holds one json attribute alone: this one does not')
|
||||
|
||||
Reference in New Issue
Block a user