diff --git a/AGENTS.md b/AGENTS.md index 199cc52..9a9073f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ In `docs/decisions.md`: - Posts announcing a release wait for its publish, and their drafts wait for the finished release - A chunk ruled worse merges where a plan item of its release already restores that code's reading, and that item takes the ruling's places - A chunk ruled worse that restructures the places takes a paired ruling after each restructure, against the same base, and merges once one is not worse -- No schema validation +- Writers refuse the shapes on a short list drawn from Atlassian's schema, before they write - The gate runs on Deno and Bun - The gate installs the tarball - Firefox reads the build diff --git a/CHANGELOG.md b/CHANGELOG.md index c2aed21..8d76d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,9 @@ `localId` as `data-local-id`. Every other node is an element of class `adf-carry` holding the node's JSON in `data-adf` and showing its content, links included. Every URL is written as the ADF holds it, `javascript:` included: sanitize HTML from an author you do not trust before - showing it. `adfToHtml` refuses an inline card a link marks, in a block written as an element, as - `unsupported-node-shape`: Atlassian's schema gives a card no link mark, and HTML nests no link in - a link. + showing it. **Breaking:** `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown` refuse an + inline card a link marks, wherever it stands, as `unsupported-node-shape` at the card's path, before + they write: Atlassian's schema gives a card no link mark. - `htmlToAdf` reads `adfToHtml`'s HTML back to the document it came from, a carry element from its `data-adf`. It reads that HTML as a browser's `innerHTML` writes it too (a raw `"` in text, ` `, a raw `<` or `>` in an attribute value) and as a form post sends it (`\r\n` for each diff --git a/MIGRATION.md b/MIGRATION.md index 55907f2..5df2367 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,6 +12,11 @@ also skips every other node Atlassian's schema withholds the mark from: | a code span inside any mark's spelling but a link's (``**`npm ci`**``) | the code text holds the mark | the code text holds only `code`, and a link where one wraps it | | text inside `!adf:border[…]` (`!adf:border[x]{color="#091e42" size=2}`) | the text holds `border` | the text holds none, and where nothing the spelling wraps takes `border`, `unsupported-node-shape`: delete the spelling and keep its text. Only a `mediaInline` takes `border` | +`adfToLosslessMarkdown` and `adfToPortableMarkdown` no longer write an `inlineCard` a link marks: +`0.3.0` refuses it as `unsupported-node-shape` at the card's path, as does `adfToHtml`, since +Atlassian's schema gives a card no link mark. Remove the link mark from the card, or wrap the card's +text in the link yourself. + `0.3.0`'s `adfToLosslessMarkdown` writes a node holding a withheld mark as `!adf:carry{json="…"}`, where `0.2.0` wrote these spellings. `0.3.0` reads markdown stored from such a document without the mark, and refuses it where the mark wrapped only those nodes. Keep the mark only in markdown diff --git a/README.md b/README.md index ac8752f..6a78f4b 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ content. No stylesheet ships. The complete dialect: | `rule` | `
`, ``, ``, the first mark outermost |
| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block, rule, emoji, mention, status, date or card | `data-*` named as HTML's `dataset` names it: `` |
-| any other node, or a node holding what its element cannot, except an inline card a link marks in a block written as an element, which is refused | `
its content
`, a `` inline |
+| any other node, or a node holding what its element cannot | `its content
`, a `` inline |
- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, a
card's, and a carried link's in the carry element. Sanitize the HTML before showing a document from
@@ -536,8 +536,7 @@ content. No stylesheet ships. The complete dialect:
- In a ``, the carry element shows each block's content as a ``, and a code block's as
`
` keeping its indentation; in a ``, it parts blocks with `
`. It shows text a
link marks as a link, a status or emoji by its text, a mention by its text, else `@` and its id,
- a date as `2026-09-13` in UTC, a card as a link to its URL, or to the target of a link marking
- it, an external image as a link to it, a stored one by its `alt`, and an expand by its title. An image marked by a link to another
+ a date as `2026-09-13` in UTC, a card as a link to its URL, an external image as a link to it, a stored one by its `alt`, and an expand by its title. An image marked by a link to another
target shows that link, then `(image)` linking to the image. A node it cannot show leaves a note
instead, such as `(image)`; a link marking the node goes around the note.
@@ -646,10 +645,10 @@ Every call can raise these:
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
what the message names.
-- **Writing, in `adfToHtml`**: an inline card marked by a link, in a block written as an element
- (in a carried block it rides the carry): Atlassian's schema gives a card no link mark, and HTML
- nests no link in a link. Keep the ADF and show the document 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`**: an inline card
+ marked by a link, wherever it stands: Atlassian's schema gives a card no link mark. Keep the ADF
+ and show the document read-only; where you built the ADF, remove the link mark from the card the
+ error's `path` names.
- **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 c7a20d0..e99a1cc 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -439,17 +439,11 @@ marking a node that shows a note goes around the note, and an image's own `text`
schema, shows first, as every carried node's does. Recorded 2026-10-24 without a panel of its own:
it joins the second panel's `` to that portable spelling.
-Inside a carry element, a card a link marks links to the link's target, and shows its own URL as its
-text where it has one, so both stay. A card showing only its name keeps its URL in `data-adf` alone.
-Recorded 2026-10-24 without a panel of its own: it puts the second panel's `` around the text a
-card shows.
-
-Atlassian's schema gives no card a link mark, so only a hand-built document holds one. HTML nests no
-`` in an ``, so no element can hold both the link's target and the card's URL: `adfToHtml`
-refuses such a card as `unsupported-node-shape` wherever it writes the card as its own element. The
-maintainer chose this on 2026-11-04 over a new spelling. Inside a carry element the card shows as
-above, since `adfToHtml` checks no carried node's JSON against the schema, and `htmlToAdf` still
-reads a carry element holding one for the same reason.
+Atlassian's schema gives no card a link mark, so only a hand-built document holds one, and no
+element can hold both the link's target and the card's URL. Every writer refuses such a card
+(§Writers refuse the shapes on a short list drawn from Atlassian's schema, before they write). The
+maintainer chose this on 2026-11-04 over a new spelling. `htmlToAdf` still reads a carry element
+holding one, since it checks no carried node's JSON against the schema.
A fifth panel (2026-11-04, three readers in the same roles, then four more for what the first three
split on) was asked how to write a mention, emoji, status, date and inline card, which until then
@@ -756,14 +750,24 @@ stays on `insideRun` alone, since it answers the parted line. A refactor that me
so the chunk's later rulings read against that merge, the commit `larv-review ruling-base` prints. A restructure past a
pass's round cap still goes unreviewed, so the PR summary names it.
-## No schema validation
+## Writers refuse the shapes on a short list drawn from Atlassian's schema, before they write
-2026-08-23, the mark refusal 2026-08-25, the maintainer. Goal 1. Valid while the site a document
-is saved to validates it.
+2026-11-04, replacing §No schema validation of 2026-08-23, the maintainer. Goals 1 and 6. Valid
+while Atlassian's schema forbids each shape on the list.
-No ADF schema validation or exported validator. A refusal that keeps the round-trip is not schema
-validation, so the one a spelled node carrying the same mark type twice earns stays, and input
-nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
+Every writer opens by refusing a document holding a listed shape, as `unsupported-node-shape` at
+the node's path, wherever the node stands: in a node a writer carries, or under a type the library
+does not know. The list is one shape, an `inlineCard` holding a `link` mark, since the schema
+gives a card the `annotation` mark alone (§The node tables answer to Atlassian's schema). A shape
+joins only where the schema forbids it and no format can spell it, so a node type, mark or
+attribute the schema does not name passes and rides the carry. There is no full schema validation
+and no exported validator: `isAdfDocument` checks the document's shape alone. A refusal that keeps
+the round-trip is not on the list: a spelled node carrying the same mark type twice earns its own,
+and input nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
+
+The earlier entry premised that the site a document is saved to validates it. A refusal before the
+save is what the viewer/editor persona relies on, and a hand-built document is the one place the
+site has not yet seen.
## The gate runs on Deno and Bun
diff --git a/spec/html.md b/spec/html.md
index bf201df..94b4e37 100644
--- a/spec/html.md
+++ b/spec/html.md
@@ -94,7 +94,7 @@ later release writes it as its own element.
- A hard break is `
`, and so is a line feed in text outside a ``'s code block.
- Text a link marks is `` around it, or the text alone where HTML cannot
hold the link's `href` or `title`; a `title` that is no string is left out. A card is a link to
- the target of a link marking it, else to its URL.
+ its URL.
- An external image with no link to another target is a link to its URL. An image marked by a
link to another target shows as portable markdown writes it inline. First comes the link,
around the image's `alt`, else the link's target, else the image's URL. Then comes ` (image)`,
@@ -127,9 +127,8 @@ later release writes it as its own element.
- 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, in a block written as an element, is refused as
- `unsupported-node-shape` instead: Atlassian's schema gives a card no link mark, and HTML nests no
- link in a link. In a carried block it rides the carry.
+- An inline card a link marks is refused as `unsupported-node-shape` wherever it stands, carried
+ or not: Atlassian's schema gives a card no link mark.
- 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 f262684..6f08fe9 100644
--- a/src/adf/document.test.ts
+++ b/src/adf/document.test.ts
@@ -4,6 +4,7 @@ import vm from 'node:vm'
import type { AdfDocument } from './document.ts'
import type { JsonValue } from '../json-value.ts'
+import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown } from '../index.ts'
import { adfDocumentFault, isAdfDocument, writableContent } from './document.ts'
import { largestNesting } from '../nesting.ts'
@@ -129,3 +130,31 @@ test('opens every writer with the same checks: a document, version 1, and no mor
const text = { content: [{ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' }], type: 'paragraph' }
assert.equal(checked({ content: [text], type: 'doc', version: 1 }), 'unsupported-node-shape')
})
+
+test('refuses an inline card a link marks, wherever the document holds it and whichever call writes it', () => {
+ const card = { attrs: { url: 'https://example.com/roadmap' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' }
+ const paragraph = { content: [{ text: 'See ', type: 'text' }, card], type: 'paragraph' }
+ const holders = [
+ { holder: paragraph, path: ['content', 0, 'content', 1] },
+ { holder: { content: [{ content: [paragraph], type: 'listItem' }], type: 'bulletList' }, path: ['content', 0, 'content', 0, 'content', 0, 'content', 1] },
+ { holder: { content: [paragraph], type: 'panel' }, path: ['content', 0, 'content', 0, 'content', 1] },
+ { holder: { content: [card, { text: 'x', type: 'text' }], marks: [{ attrs: { align: 'center' }, type: 'alignment' }], type: 'paragraph' }, path: ['content', 0, 'content', 0] },
+ { holder: { content: [{ content: [card], type: 'futureNode' }], type: 'futureBlock' }, path: ['content', 0, 'content', 0, 'content', 0] },
+ ]
+ const writers = [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]
+ for (const { holder, path } of holders) {
+ for (const write of writers) {
+ const refused = write({ content: [holder], type: 'doc', version: 1 })
+ assert.equal(!refused.ok && refused.error.code, 'unsupported-node-shape')
+ assert.deepEqual(!refused.ok && refused.error.path, path)
+ assert.match(!refused.ok ? refused.error.message : '', /inlineCard.*link/)
+ }
+ }
+})
+
+test('writes a card holding no link mark, and a node type it does not know holding one', () => {
+ const comment = { attrs: { annotationType: 'inlineComment', id: 'c1' }, type: 'annotation' }
+ const link = { attrs: { href: 'https://example.com/' }, type: 'link' }
+ const marked = { content: [{ attrs: { url: 'https://example.com/roadmap' }, marks: [comment], type: 'inlineCard' }, { marks: [link], type: 'futureInline' }], type: 'paragraph' }
+ for (const write of [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]) assert.equal(write({ content: [marked], type: 'doc', version: 1 }).ok, true)
+})
diff --git a/src/adf/document.ts b/src/adf/document.ts
index 7cc084e..c91858e 100644
--- a/src/adf/document.ts
+++ b/src/adf/document.ts
@@ -55,6 +55,8 @@ export function writableContent(document: AdfDocument): Result | 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 path: (number | string)[] = []
+ for (let up: Step | undefined = step; up !== undefined; up = up.parent) path.unshift('content', up.index)
+ return failure('unsupported-node-shape', 'an inlineCard takes no link mark: this one holds one', path)
+ }
+ const children = nodeContent(step.node)
+ for (let index = children.length - 1; index >= 0; index -= 1) {
+ const node = children[index]
+ if (node !== undefined) pending.push({ index, node, parent: step })
+ }
+ }
+ return 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/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts
index e9157ff..5c82dd7 100644
--- a/src/html/emit/adf-to-html.test.ts
+++ b/src/html/emit/adf-to-html.test.ts
@@ -277,12 +277,7 @@ test('shows a carried image marked by a link to another target as that link, the
assert.equal(inline({ attrs: { href: 'https://example.com/', title: 5 }, type: 'link' }), 'The moon (image)')
})
-test('links a card a link marks, inside a carried node, to that link\'s target alone', () => {
- const card: AdfNode = { attrs: { url: 'https://example.com/roadmap' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' }
- assert.equal(shown(html(adfToHtml(document({ content: [paragraph(card)], type: 'panel' })))), '')
-})
-
-test('refuses an inline card a link marks, which Atlassian\'s schema does not allow', () => {
+test('refuses an inline card a link marks, which Atlassian\'s schema does not allow, inside a carried node too', () => {
const marks: AdfMark[] = [{ attrs: { href: 'https://example.com/' }, type: 'link' }]
const cards: AdfNode[] = [{ attrs: { url: 'https://example.com/roadmap' }, marks, type: 'inlineCard' }, { attrs: { data: { name: 'Roadmap', url: 'https://example.com/roadmap' } }, marks, type: 'inlineCard' }]
for (const card of cards) {
@@ -291,6 +286,8 @@ test('refuses an inline card a link marks, which Atlassian\'s schema does not al
assert.deepEqual(!refused.ok && refused.error.path, ['content', 0, 'content', 1])
assert.match(!refused.ok ? refused.error.message : '', /inlineCard.*link/)
}
+ const carried = adfToHtml(document({ content: [paragraph(cards[0] ?? text('x'))], type: 'panel' }))
+ assert.deepEqual(!carried.ok && carried.error.path, ['content', 0, 'content', 0, 'content', 0])
const nested = adfToHtml(document({ content: [{ content: [paragraph(cards[0] ?? text('x'))], type: 'listItem' }], type: 'bulletList' }))
assert.deepEqual(!nested.ok && nested.error.path, ['content', 0, 'content', 0, 'content', 0, 'content', 0])
})
diff --git a/src/html/emit/inline.ts b/src/html/emit/inline.ts
index 2461979..d84f4b6 100644
--- a/src/html/emit/inline.ts
+++ b/src/html/emit/inline.ts
@@ -5,17 +5,13 @@ import { elementOfMark } from '../mark-elements.ts'
import { holdsOnlyAttributes, isSpellableText, isUnmarkedSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { linkAttrs } from '../../adf/mark-attributes.ts'
import { nodeElement } from './node-element.ts'
-import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
+import { success, type ConvertErrorPath, type Result } from '../../result.ts'
import { writableValue } from '../escaping.ts'
export function inlineHtml(nodes: readonly AdfNode[], path: ConvertErrorPath): Result {
let html = ''
let afterPlainText = false
for (const [index, node] of nodes.entries()) {
- // Refused, not carried: Atlassian's schema gives a card no link mark. A carried block never reaches here.
- if (node.type === 'inlineCard' && nodeMarks(node).some(({ type }) => type === 'link')) {
- return failure('unsupported-node-shape', 'an inlineCard takes no link mark: this one holds one', [...path, 'content', index])
- }
// Two unmarked text nodes side by side read back as one, so the second rides the carry.
const written: string | undefined = afterPlainText && isUnmarkedSpellableText(node) ? undefined : tryInlineNode(node)
if (written === undefined) {
diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts
index e43b53c..9d959c3 100644
--- a/src/markdown/portable/adf-to-portable-markdown.test.ts
+++ b/src/markdown/portable/adf-to-portable-markdown.test.ts
@@ -230,7 +230,6 @@ test('spells an inline node as its text', () => {
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
assert.equal(portable(paragraph(node('inlineCard', { url: 'https://example.com' }))), '\n')
- assert.equal(portable(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '****\n')
assert.equal(portable(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
assert.equal(portable(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) \n')
assert.equal(portable(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
diff --git a/todo.md b/todo.md
index 5005b9e..0387610 100644
--- a/todo.md
+++ b/todo.md
@@ -30,7 +30,7 @@
| 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 |
| 159 | 0.4.0 | | | **Delete `taskBlocks`'s branch that writes the task marker as its own paragraph, once a test shows no block task item reaches it.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 10.5 |
| 144 | 0.4.0 | | | **Give every container `docker-runner.sh` and `publish.sh` start a memory limit and an equal swap limit, sized from the largest a gate leg measurably needs.** | 1 | 1 | 1 | 1 | 2 | code health by 1 | 8.7 |
-| 143 | 0.4.0 | | defect | **Keep, in `adfToPortableMarkdown`, the link around a note and the target of a link marking a card, so a linked stored image or card writes every target.** | 2 | 1 | 3 | 4 | 3 | 7 by 1 | 6.5 |
+| 143 | 0.4.0 | | defect | **Keep, in `adfToPortableMarkdown`, the link around a note, so a linked stored image writes every target.** | 2 | 1 | 3 | 4 | 3 | 7 by 1 | 6.5 |
| 112 | 0.4.0 | | | **Pad a GFM pipe table's short rows with empty cells, read its body rows holding no pipe, and read a one-column table without leading pipes in `portableMarkdownToAdf`.** | 2 | 1 | 2 | 3 | 3 | 4 by 4 | 6.2 |
| 80 | 0.4.0 | | | **Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.** | 2 | 1 | 2 | 3 | 3 | 6 by 4 | 6.0 |
| 81 | 0.4.0 | | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 1 | 1 | 1 | 2 | 3 by 2 | 4.2 |
@@ -223,18 +223,14 @@ of item 33, 2026-10-29.
strings near V8's longest, about 512 MB each, so measure each leg's peak before picking the limit:
one too small fails the gate.
-### 143. Keep, in `adfToPortableMarkdown`, the link around a note and the target of a link marking a card, so a linked stored image or card writes every target.
+### 143. Keep, in `adfToPortableMarkdown`, the link around a note, so a linked stored image writes every target.
`noteLeaves` (`src/markdown/portable/inline-reduction.ts`) drops the node's marks, so a stored
image a link marks writes `_(image not included)_` without the link's target, which Goal 7 counts as
content. `adfToHtml`'s carry element keeps it, `(image)`.
-`cardLeaves`, in the same file, links a card to its own URL, so a card a link to another target
-marks writes a link to the card's URL and drops the mark's target. The carry element keeps both: it
-links the card's URL, as its text, to the mark's target (`docs/decisions.md` §The HTML dialect).
-
-Move the choice of where a card's text and a note link into `src/adf/shown-text.ts`, beside
-`shownImage`'s for an image, so both writers render one answer.
+Move the choice of where a note's link goes into `src/adf/shown-text.ts`, beside `shownImage`'s for
+an image, so both writers render one answer.
Leave what an HTML carry element shows unchanged, or ship the change as a breaking HTML change with
a `MIGRATION.md` entry: `htmlToAdf` refuses any other content, so HTML stored by an earlier release