From d55a249ef367a962a189a82def650d6628d7c193 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 5 Nov 2026 11:02:45 +0100 Subject: [PATCH] Refuse the schema-forbidden shapes in the first step, carried or not --- CHANGELOG.md | 4 +++ MIGRATION.md | 7 +++++ README.md | 17 ++++++----- docs/decisions.md | 9 ++---- docs/reading-cost.md | 17 +++++++++++ spec/flavour.md | 7 +++-- spec/html.md | 7 +++-- src/adf/document.test.ts | 35 ++++++++++++++++++++++ src/adf/document.ts | 22 ++++++++++++-- src/adf/inline-nodes.ts | 3 +- src/html/carry-content.ts | 2 +- src/html/emit/adf-to-html.test.ts | 7 ----- src/html/emit/adf-to-html.ts | 2 +- src/html/emit/inline.ts | 4 +-- src/html/parse/html-to-adf.test.ts | 13 ++++++++ src/markdown/emit/adf-to-markdown.ts | 2 -- src/markdown/emit/inline-line.ts | 11 ++----- src/markdown/parse/markdown-to-adf.test.ts | 10 +++++++ 18 files changed, 132 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb791de..17d7a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md index 654a1d3..0a22b58 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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: diff --git a/README.md b/README.md index a4b8025..98d8f00 100644 --- a/README.md +++ b/README.md @@ -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**: diff --git a/docs/decisions.md b/docs/decisions.md index 876ac7b..17002e7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 `` to that portable spelling. HTML nests no `` in an ``, 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 `` in an ``. `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 `` in an ``. 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 diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 29efd84..c77123a 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -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 diff --git a/spec/flavour.md b/spec/flavour.md index 062a83c..7f7c7bb 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -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]. diff --git a/spec/html.md b/spec/html.md index 0789bba..457b84c 100644 --- a/spec/html.md +++ b/spec/html.md @@ -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. diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index ec3058c..88e6c4a 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -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' }] diff --git a/src/adf/document.ts b/src/adf/document.ts index eccc696..5120e0c 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -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 }] diff --git a/src/adf/inline-nodes.ts b/src/adf/inline-nodes.ts index 38322b5..a56eb9b 100644 --- a/src/adf/inline-nodes.ts +++ b/src/adf/inline-nodes.ts @@ -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 } diff --git a/src/html/carry-content.ts b/src/html/carry-content.ts index 1e2cd52..1207505 100644 --- a/src/html/carry-content.ts +++ b/src/html/carry-content.ts @@ -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) diff --git a/src/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts index 5c82dd7..44bead3 100644 --- a/src/html/emit/adf-to-html.test.ts +++ b/src/html/emit/adf-to-html.test.ts @@ -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' })))), '
x

a
b

z
') - assert.equal(shown(html(adfToHtml(document({ content: [text('x'), { attrs: { id: 'u' }, content: [paragraph(text('a'))], type: 'mention' }, text('z')], type: 'codeBlock' })))), '
x@u

a

z
') }) 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/' }), '

The moon

') 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, '

https://example.com/moon.jpg (image)

') - 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, '

Moon (image)

') 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), 'The moon (image)') assert.equal(inline({ attrs: { href: 'https://example.com/', title: 5 }, type: 'link' }), 'The moon (image)') @@ -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) }) diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index 1907b6f..aa155ee 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -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 : `` } diff --git a/src/html/emit/inline.ts b/src/html/emit/inline.ts index d84f4b6..2be4a11 100644 --- a/src/html/emit/inline.ts +++ b/src/html/emit/inline.ts @@ -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 ? '
' : undefined + if (node.type === 'hardBreak') return holdsOnlyAttributes(node, []) ? '
' : undefined const element = nodeElement(node) if (element !== undefined) return element const text = isSpellableText(node) ? writableText(node.text) : undefined diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts index 90ea354..b745e24 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -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, '

Check the collation.

')}\n

Ask ${carry('span', mention, '@Mikael')}

\n`)), document(panel, paragraph(text('Ask '), mention))) assert.deepEqual(read(htmlToAdf(`

${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')}

\n`)), document(paragraph(text('a\u0085b', { type: 'underline' })))) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index e9af1b5..178c120 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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): Result { - 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)) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index a7b3700..dff4b89 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -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, 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 { @@ -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 { 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 }) } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index c70fd6d..965c02c 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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')