3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Refuse an inline card a link marks in every writer, before it writes

This commit is contained in:
2026-11-04 20:16:16 +01:00
committed by Lillem4n
parent 30f45b75d8
commit a27a4453c2
12 changed files with 97 additions and 52 deletions
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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,
`&nbsp;`, a raw `<` or `>` in an attribute value) and as a form post sends it (`\r\n` for each
+5
View File
@@ -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
+6 -7
View File
@@ -526,7 +526,7 @@ content. No stylesheet ships. The complete dialect:
| `rule` | `<hr>` |
| `text` marked `code`, `em`, `strong` | `<code>`, `<em>`, `<strong>`, the first mark outermost |
| an attribute its type holds that its element does not write, such as `localId`, on a paragraph, heading, quote, list, list item, code block, rule, emoji, mention, status, date or card | `data-*` named as HTML's `dataset` names it: `<p data-local-id="a1f3">` |
| 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 | `<div class="adf-carry" data-adf="{…}"><p>its content</p></div>`, a `<span>` inline |
| any other node, or a node holding what its element cannot | `<div class="adf-carry" data-adf="{…}"><p>its content</p></div>`, a `<span>` inline |
- Every URL is written as the ADF holds it, `javascript:` included: a link's, an image's, a
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 `<div>`, the carry element shows each block's content as a `<p>`, and a code block's as
`<pre><code>` keeping its indentation; in a `<span>`, it parts blocks with `<br>`. 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 `<em>(image)</em>`; 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**:
+21 -17
View File
@@ -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 `<a>` 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 `<a>` 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
`<a>` in an `<a>`, 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
+3 -4
View File
@@ -94,7 +94,7 @@ later release writes it as its own element.
- A hard break is `<br>`, and so is a line feed in text outside a `<div>`'s code block.
- Text a link marks is `<a href="…" title="…">` 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.
+29
View File
@@ -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)
})
+21
View File
@@ -55,6 +55,8 @@ export function writableContent(document: AdfDocument): Result<readonly AdfNode[
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no format this library writes carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(undefined)
const refused = refusedShape(document.content)
if (refused !== undefined) return refused
// Text alone past the bound is refused before anything is written (docs/decisions.md §Output past V8's longest string is refused on every engine).
return withinLongest(heldTextLength(document.content), document.content)
}
@@ -145,6 +147,25 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? []
}
// The writers' refusals of what Atlassian's schema forbids, in document order, at any depth and inside nodes no writer reads. Each rule stands on the schema alone.
function refusedShape(content: readonly AdfNode[]): Result<never> | 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 }]
+3 -6
View File
@@ -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' }), '<a href="https://example.com/">The moon</a> (<a href="https://example.com/moon.jpg">image</a>)')
})
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' })))), '<p><a href="https://example.com/">https://example.com/roadmap</a></p>')
})
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])
})
+1 -5
View File
@@ -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<string> {
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) {
@@ -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' }))), '<https://example.com>\n')
assert.equal(portable(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.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) <https://e.com/u>\n')
assert.equal(portable(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
+4 -8
View File
@@ -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, `<a href="…"><em>(image)</em></a>`.
`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