From 6c389b9c83584ddbf1d4068a56208d1e47f26fd5 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 14 Oct 2026 14:41:57 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20prose=20round=201:=200=20=F0=9F=94=B4,?= =?UTF-8?q?=208=20=F0=9F=9F=A1;=20say=20which=20marks=20a=20node=20refuses?= =?UTF-8?q?,=20and=20name=20the=20fix=20in=20the=20directive=20mark=20refu?= =?UTF-8?q?sal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- CHANGELOG.md | 13 ++++++------- MIGRATION.md | 14 +++++++++----- README.md | 4 ++-- docs/decisions.md | 19 +++++++++---------- spec/flavour.md | 15 +++++++-------- src/adf/inline-nodes.ts | 2 +- src/markdown/parse/inline-content.ts | 2 +- src/markdown/parse/markdown-to-adf.test.ts | 8 ++++---- todo.md | 20 ++++++++++---------- 10 files changed, 50 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f502030..bf27ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ In `docs/decisions.md`: - Tables - Links - An image reads as an image only alone in its paragraph, else as its alt text linked; a link text of hard breaks alone reads as its destination, then the breaks -- A mark skips a node that cannot hold it, and a link wrapping only such nodes is refused +- A mark skips a node that cannot hold it, and a mark wrapping only such nodes is refused, but for a link around hard breaks alone - A linked image is written `[![alt](url)](href)`, a captioned image as its directive - Ids stay site-local - Portable task ids come from position diff --git a/CHANGELOG.md b/CHANGELOG.md index 77515e4..d0cba89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,14 @@ ## Unreleased +- **Breaking:** `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status` + or `mediaInline` only the marks Atlassian's schema lets it hold: `**!adf:mention[@A]{id=…}**` now + reads as a plain mention. A link or directive mark around only nodes that cannot take it is + `unsupported-node-shape`, even in markdown `adfToLosslessMarkdown` wrote. A link around hard + breaks alone still reads as before. `adfToLosslessMarkdown` writes a node holding a mark its type + cannot take as `!adf:carry{json="…"}`. `MIGRATION.md` says how to convert stored markdown. - `losslessMarkdownToAdf` names the CommonMark spelling to write when it refuses a directive whose node CommonMark spells, such as `[![alt](url)](href)` for a linked `!adf:mediaSingle`. -- **Breaking:** a mark spelling skips a `date`, `emoji`, `inlineCard`, `mention`, `status` or - `mediaInline` Atlassian's schema withholds the mark from, where `losslessMarkdownToAdf` gave it - the marks around it: `**!adf:mention[@A]{id=…}**` reads as a plain mention. A link around only - such nodes, or a directive mark around nodes none of which takes it, is `unsupported-node-shape`, - markdown `adfToLosslessMarkdown` wrote before included. - `adfToLosslessMarkdown` writes a node holding such a mark as `!adf:carry{json="…"}`. See - `MIGRATION.md`. ## 0.2.0 diff --git a/MIGRATION.md b/MIGRATION.md index 5d44d2e..c030f13 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,12 +7,16 @@ A mark spelling skips a node Atlassian's schema withholds the mark from: | Input | `0.2.0` | `0.3.0` | | --- | --- | --- | | a `date`, `emoji`, `inlineCard`, `mention`, `status` or `mediaInline` inside a mark's spelling (`**!adf:mention[@A]{id=x}**`) | the node holds the mark | the node holds only marks its type takes; text beside it keeps the mark | -| a link or directive mark around such nodes alone (`[!adf:status[Done]{color=green}](/u)`) | the nodes hold the mark | `unsupported-node-shape`; write the mark around text beside them | +| a link or directive mark around only nodes that cannot take it, hard breaks under a link aside (`[!adf:status[Done]{color=green}](/u)`) | the nodes hold the mark | `unsupported-node-shape`; write the mark around text beside them | -`0.2.0` wrote these spellings for a node holding such a mark, which `0.3.0` writes as -`!adf:carry{json="…"}`. Markdown stored from such a document reads back without the mark, or, where -the mark wrapped such nodes alone, is refused: to keep it, read the markdown with `0.2.0` and write -it with `0.3.0`, installed side by side. +`0.2.0` wrote these spellings for a node holding such a mark; `0.3.0` writes the node as +`!adf:carry{json="…"}`. `0.3.0` reads markdown stored from such a document without the mark, or +refuses it where the mark wrapped such nodes alone. To keep the mark, read the markdown with `0.2.0` +and write it with `0.3.0`, installed side by side: + +```sh +npm install @larvit/adf-codec@0.3.0 adf-codec-0.2@npm:@larvit/adf-codec@0.2.0 +``` ## From `0.1.0` to `0.2.0` diff --git a/README.md b/README.md index 3bbda26..dd9e988 100644 --- a/README.md +++ b/README.md @@ -463,8 +463,8 @@ Every call can raise these: replace a line ending with a space, or, for either, write the node as `!adf:carry`; - wraps a directive mark around nothing, or only around nodes that take no such mark, such as a hard break or a mention; - - writes a link around nodes none of which takes a link, more than hard breaks alone, such as - `[!adf:status[Done]{color=green}](/u)`: write the link around text beside them; + - writes a link whose text holds something besides hard breaks, but no node that takes a link, + such as `[!adf:status[Done]{color=green}](/u)`: write the link around text beside them; - 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. diff --git a/docs/decisions.md b/docs/decisions.md index 81990ad..7417f43 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -256,18 +256,18 @@ The same panel confirmed the other readings on 2026-10-09: a title as the captio text as linked alt text, and an empty link text as its URL, each 3 of 3; `[]()` as nothing, 5 of 7. Their CommonMark suite exceptions are `node-model` (§The CommonMark suite checks three ways). -## A mark skips a node that cannot hold it, and a link wrapping only such nodes is refused +## A mark skips a node that cannot hold it, and a mark wrapping only such nodes is refused, but for a link around hard breaks alone 2026-10-14, two reader panels of the viewer/editor app, the LLM pipeline and a support agent, 3 of 3 each. Goals 1, 4 and 6. Valid while Atlassian's schema withholds marks from inline nodes. -`src/adf/inline-nodes.ts` holds the mark types Atlassian's schema gives each inline node. A mark -spelling skips a node that takes none of its type, so `**Ping !adf:mention[@A]{id=x} now**` reads -as bold text around a plain mention. A link whose text holds no node that takes a link, and more -than hard breaks, is `unsupported-node-shape` in `losslessMarkdownToAdf`: the panel refused to have -`[!adf:status[Done]{color=green}](/u)` read as `/u` linked beside the status. Hard breaks alone keep -§An image reads as an image only alone in its paragraph's reading. `adfToLosslessMarkdown` writes a -node holding a mark its type does not take as `!adf:carry`, so the round-trip holds. +A mark spelling skips a node that takes none of its type, so `**Ping !adf:mention[@A]{id=x} now**` +reads as bold text around a plain mention. A link whose text holds something besides hard breaks, +but no node that takes a link, is `unsupported-node-shape` in `losslessMarkdownToAdf`: the panel +refused to have `[!adf:status[Done]{color=green}](/u)` read as `/u` linked beside the status. A link +text of hard breaks alone still reads as its destination, then the breaks (§An image reads as an +image only alone in its paragraph). `adfToLosslessMarkdown` writes a node holding a mark its type +does not take as `!adf:carry`, so the round-trip holds. ## A linked image is written `[![alt](url)](href)`, a captioned image as its directive @@ -702,8 +702,7 @@ holds. For every node and mark the tables spell, the attribute names and kinds equal what `full.json` and `stage-0.json` (§Standards ship as data) hold between them, and so do the mark types each inline -node takes. Value sets stay documentation, since -any value round-trips. What the schema holds and the tables do not spell is pinned by name — an +node takes. Value sets stay documentation, since any value round-trips. What the schema holds and the tables do not spell is pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until someone spells it or pins it. diff --git a/spec/flavour.md b/spec/flavour.md index c8d9a42..d0ff7e8 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -531,8 +531,8 @@ nesting, which is CommonMark's own rule: a `[text]` already holding one leaves t literal text, and the directive form, open to no literal reading, is a named error. ADF holds no empty text node, so a link whose text is empty reads as its destination linked, `[](/url)` as `/url`, and one whose destination is empty too, `[]()`, as nothing. A link text holding only hard -breaks reads the same, followed by its hard breaks; one holding other nodes, none of which takes a -link, is a named error. +breaks reads the same, followed by its hard breaks. A link text holding something else, but no node +that takes a link, is a named error. - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3). - `code`, `em`, `strike`, `strong` — Attributes: none. @@ -550,18 +550,17 @@ A block's `marks` key also holds these, which no inline spelling wraps: - `indentation` — Attributes: `level` (number, 1–6). A spelling adds its mark to every text node it wraps, and to every other inline node Atlassian's -schema gives that mark: `hardBreak`, `date`, `emoji`, `inlineCard`, `mention` and `status` take none -a spelling makes, and `mediaInline` only `border` and `link`. Nesting is the marks array in order, outermost first: -`_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToLosslessMarkdown` nests in -the order the array holds rather than sorting it — `docs/decisions.md` §Equality is deep restores +schema gives that mark. Of the marks a spelling makes, `date`, `emoji`, `hardBreak`, `inlineCard`, +`mention` and `status` take none, and `mediaInline` takes only `border` and `link`. Nesting is the +marks array in order, outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, +`!adf:underline[_x_]` the reverse. `adfToLosslessMarkdown` nests in the order the array holds rather than sorting it — `docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling. An inline node whose marks no nesting spells — a node holding a mark Atlassian's schema withholds -from it, a mark type the first -list above does not hold, an attrs key its spelling does not list, a value that is not the +from it, a mark type the first list above does not hold, an attrs key its spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside another mark, `code` over anything but a text node holding text, or over text holding a line ending or a null character, or a spelling CommonMark's flanking rules cannot diff --git a/src/adf/inline-nodes.ts b/src/adf/inline-nodes.ts index 62241d2..c988239 100644 --- a/src/adf/inline-nodes.ts +++ b/src/adf/inline-nodes.ts @@ -33,7 +33,7 @@ export function inlineNodeModel(type: string): InlineNodeModel | undefined { return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined } -// An unknown type is no inline leaf the table holds, so the table withholds nothing from it. +// The table withholds nothing from a type it does not list, such as text. export function takesMark(type: string, markType: string): boolean { return inlineNodeModel(type)?.markTypes.includes(markType) ?? true } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 625c1c2..58a4a06 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -212,7 +212,7 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC } if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path) if (!slot.nodes.some((node) => marksItem(node, mark.type))) { - return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark`, scan.path) + return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark: write the ${name} mark around text beside them`, scan.path) } const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined if (refused !== undefined) return refused diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 6bc112d..5940432 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -926,9 +926,9 @@ test('gives an inline node no mark Atlassian\'s schema withholds from it', () => assert.deepEqual(read('_!adf:mediaInline{id=m1}_\n'), [{ content: [media], type: 'paragraph' }]) assert.deepEqual(read('!adf:border[!adf:mediaInline{id=m1}]{color="#091e42" size=2}\n'), [{ content: [{ ...media, marks: [border] }], type: 'paragraph' }]) assert.deepEqual(read('[!adf:mediaInline{id=m1}](/u)\n'), [{ content: [{ ...media, marks: [link('/u')] }], type: 'paragraph' }]) - assert.equal(read('!adf:underline[!adf:mention[@A]{id=b1c2}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark') - assert.equal(read('!adf:underline[!adf:mediaInline{id=m1}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark') - assert.equal(read('!adf:link[!adf:status[Done]{color=green}]{collection=c href="/u"}\n'), 'unsupported-node-shape: the link mark wraps the [content] it marks: this one wraps only nodes that take no link mark') + assert.equal(read('!adf:underline[!adf:mention[@A]{id=b1c2}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them') + assert.equal(read('!adf:underline[!adf:mediaInline{id=m1}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them') + assert.equal(read('!adf:link[!adf:status[Done]{color=green}]{collection=c href="/u"}\n'), 'unsupported-node-shape: the link mark wraps the [content] it marks: this one wraps only nodes that take no link mark: write the link mark around text beside them') }) test('decodes the fenced info string the block walk leaves raw', () => { @@ -1268,7 +1268,7 @@ test('names the directive mark left without the content it wraps', () => { const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none' assert.equal(content(losslessMarkdownToAdf('!adf:underline[]\n')), named) assert.equal(content(losslessMarkdownToAdf('!adf:underline{}\n')), named) - const breaks = (name: string): string => `unsupported-node-shape: the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark` + const breaks = (name: string): string => `unsupported-node-shape: the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark: write the ${name} mark around text beside them` assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n')), breaks('underline')) assert.equal(content(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n')), breaks('link')) }) diff --git a/todo.md b/todo.md index db490b8..031719f 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,7 @@ | ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---|---| -| 114 | 0.3.0 | defect | **Build text marked `code` holding no mark but `link` and `annotation`, in every reader, and carry any other in `adfToLosslessMarkdown`.** | 3 | 4 | 5 | 5 | 7 | 4 | 15.4 | +| 114 | 0.3.0 | defect | **Build code-marked text holding no mark besides `link` and `annotation`, in every reader, and have `adfToLosslessMarkdown` carry code text holding any other mark.** | 3 | 4 | 5 | 5 | 7 | 4 | 15.4 | | 99 | 0.3.0 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 5 | 6 | 7 | 7 | 12.9 | | 100 | 0.3.0 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 3 | 4 | 5 | 4, 6 | 10.9 | | 60 | 0.3.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 4 | 2 | 6 | 2 | 10.7 | @@ -52,7 +52,7 @@ | 107 | 0.4.0 | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 4 | 5 | 7 | 3 | 5 | 1 | 10.8 | | 51 | 0.4.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 4 | 5 | 2 | 7 | 4, 5 | 10.4 | | 95 | 0.4.0 | decision | **Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.** | 5 | 6 | 9 | 2 | 6 | 1 | 9.5 | -| 91 | 0.4.0 | | **Spell an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions, statuses and inline media between two nodes it marks, in both writers.** | 5 | 5 | 8 | 5 | 6 | 6 | 9.0 | +| 91 | 0.4.0 | | **Spell an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 5 | 5 | 8 | 5 | 6 | 6 | 9.0 | | 73 | 0.5.0 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 1 | 6 | 8 | 3 | 23.0 | | 9 | 0.5.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 4 | 6 | 8 | 3 | 21.0 | | 38 | 0.5.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 5 | 4 | 7 | 1 | 19.5 | @@ -69,12 +69,12 @@ ## Details -### 114. Build text marked `code` holding no mark but `link` and `annotation`, in every reader, and carry any other in `adfToLosslessMarkdown`. +### 114. Build code-marked text holding no mark besides `link` and `annotation`, in every reader, and have `adfToLosslessMarkdown` carry code text holding any other mark. -`**`npm ci`**` builds text holding `[strong, code]`; `code_inline_node` (`spec/adf-schema/full.json`) +``**`npm ci`**`` builds text holding `[strong, code]`; `code_inline_node` (`spec/adf-schema/full.json`) gives code text `code`, `link` and `annotation` alone, so a site may reject the document. The `==` -highlight already skips code (`highlighted`, `src/markdown/parse/inline-content.ts`). Moves the -reading of stored markdown, so it ships where semver allows a breaking change. +highlight already skips code (`highlighted`, `src/markdown/parse/inline-content.ts`). It changes +how stored markdown reads, so it ships in a minor. ### 99. Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`. @@ -298,14 +298,14 @@ text. Only the property tests catch drift between these and the reader, and it h under the comprehension floor. Reading the line back costs one parse each time a line is tried. The run it carries may differ from today's, which changes lossless output (§The formats are API). -### 91. Spell an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions, statuses and inline media between two nodes it marks, in both writers. +### 91. Spell an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers. Lands after item 95, which lets the joined spelling be tried and kept only where it reads back. No reader marks a hard break, so `*One\` then `Two*` writes back as `_One_\` then `_Two_`, and a link typed across a line as two links. No reader gives those other nodes a mark Atlassian's schema -withholds from them, so `**Ping !adf:mention[@A]{id=x} now**` writes back with each strong run's -inner space spelled `!adf:text{text=" "}`. Joining the run across the break in `inline-line.ts` ruled worse -with two comprehension panels (PR #22): a joined run that cannot be spelled needs its own fallback, +withholds from them, so `**Ping !adf:mention[@A]{id=x} now**` writes back with the space beside +the mention in each strong run spelled `!adf:text{text=" "}`. Joining the run across the break in +`inline-line.ts` ruled worse with two comprehension panels (PR #22): a joined run that cannot be spelled needs its own fallback, first as a set of parted breaks, then as a flag the first fallback clears. ### 73. Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.