3
0
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:
2026-11-05 11:02:45 +01:00
committed by Lillem4n
parent 24247eff85
commit d55a249ef3
18 changed files with 132 additions and 47 deletions
+4
View File
@@ -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
+7
View File
@@ -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:
+9 -8
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+35
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
}
+1 -1
View File
@@ -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)
-7
View File
@@ -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)
})
+1 -1
View File
@@ -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 -2
View File
@@ -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
+13
View File
@@ -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' }))))
-2
View File
@@ -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))
+2 -9
View File
@@ -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')