From 06df26727a63f6cd9e86240445af283a25ea5ec7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 14 Oct 2026 00:02:58 +0200 Subject: [PATCH] Fold unspellable-whitespace into unsupported-node-shape --- CHANGELOG.md | 5 +++-- MIGRATION.md | 12 +++++++----- README.md | 3 +-- docs/decisions.md | 6 +++--- src/markdown/directive-syntax.ts | 2 +- src/markdown/parse/markdown-to-adf.test.ts | 2 +- src/result.ts | 1 - todo.md | 13 ------------- 8 files changed, 16 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 221268b..39141c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,9 @@ - **Breaking:** the markdown readers read markdown holding no block as a document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no `content` key. -- **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and - `adfToLosslessMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is +- **Breaking:** `unspellable-character`, `unspellable-line-start` and `unspellable-whitespace` leave + `ConvertErrorCode`. A line ending in an `emoji`, `mention` or `status` directive's content slot is + `unsupported-node-shape`, as a null character there already was. A carriage return in text is spelled ` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span is spelled as that code span, its backticks opening no fence; a code block holding either character, a code span holding a null character, a text node holding no text or holding diff --git a/MIGRATION.md b/MIGRATION.md index 328addd..6e29b39 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -87,11 +87,12 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc ### Error codes -`unmappable-image`, `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave -`ConvertErrorCode`, and `engine-limit` and `not-a-string` join it. A `switch` naming one of the four -stops compiling, and one naming every code needs the two new cases. A document `adfToMarkdown` -refused with one of the three `unspellable-` codes now converts in `adfToLosslessMarkdown`; markdown -the parser refused with one of the four now reads, or refuses with the code the table gives. +`unmappable-image`, `unspellable-character`, `unspellable-line-start`, `unspellable-link` and +`unspellable-whitespace` leave `ConvertErrorCode`, and `engine-limit` and `not-a-string` join it. A +`switch` naming one of the five stops compiling, and one naming every code needs the two new cases. +A document `adfToMarkdown` refused with one of the four `unspellable-` codes now converts in +`adfToLosslessMarkdown`; markdown the parser refused with one of the five now reads, or refuses with +the code the table gives. | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | @@ -105,6 +106,7 @@ the parser refused with one of the four now reads, or refuses with the code the | a paragraph line opening with a code span of three or more backticks, on emit | `unspellable-line-start` | spells the code span | | a block directive whose CommonMark spelling holds a carriage return, a null character or a code span opening a line, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling | | an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character, on emit | `unspellable-whitespace`, `unspellable-character` | rides the inline carry | +| an `emoji`, `mention` or `status` directive whose content slot spells a line ending (` `, ` `, `!adf:text{text="\n"}`), on parse | `unspellable-whitespace` | `unsupported-node-shape`; write the node as `!adf:carry` | | a text node holding no text, or holding `content`, on emit | `unsupported-node-shape` | rides the carry | | a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry | | a leaf node given a body (`media`) | `unsupported-node-shape` | `malformed-directive` | diff --git a/README.md b/README.md index 32a4797..5efa27c 100644 --- a/README.md +++ b/README.md @@ -429,7 +429,6 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r | `not-a-string` | the value handed in is not a string, such as `null`, a number or a `String` object | pass the input as a string, `String(value)` for a `String` object; the message names the value's type | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md), or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning | | `unmappable-html` | the input holds raw HTML, a comment and a processing instruction among them | remove the construct, or write what it holds as markdown | -| `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — ` `, ` `, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines | ### Emitting @@ -449,7 +448,7 @@ Every call can raise these: | --- | --- | --- | | `engine-limit` | in every call: a conversion that passes the longest array, `Map` or `Set` the JavaScript engine builds, or that the engine reports as out of memory; on JavaScriptCore (Bun, Safari), also one that passes its longest string, 2^31 − 1 characters | retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | -| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or one that, written out as plain CommonMark, would be longer than 536870888 characters, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, 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`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536870888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes V8's or SpiderMonkey's longest string | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a portable line or task list, report it as a bug. Past 536870888 characters or the engine's longest string, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply | +| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, puts a line ending or a null character in an `emoji`, `mention` or `status` content slot, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or one that, written out as plain CommonMark, would be longer than 536870888 characters, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, 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`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536870888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes V8's or SpiderMonkey's longest string | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a line ending or null character in a content slot, write the node as `!adf:carry`. For a portable line or task list, report it as a bug. Past 536870888 characters or the engine's longest string, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply | ## The guarantees diff --git a/docs/decisions.md b/docs/decisions.md index 2f8b090..1769e0d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -437,9 +437,9 @@ handles one cause alike whichever node, attribute or direction raised it. - A code names the cause; where one cause recurs across node types, across one mark's attributes or across directions, one code covers them all and `path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever direction hits it. - `unspellable-whitespace` is the newline an inline directive's content slot spells in input; the - emitter carries such a node. A null character in that slot is `unsupported-node-shape`, since - `unspellable-whitespace` does not read true of it (2026-10-04). + A line ending or a null character an inline directive's content slot spells in input is + `unsupported-node-shape`, as both take one fix, the carry; the emitter carries such a node + (2026-10-14). - A claim code names the spelling claimed, never the node that spelling would have built: a malformed `!adf:table` is a `malformed-directive`, and an alignment colon a `malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index e91da0b..09f7780 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -107,7 +107,7 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read< // An inline directive never spans lines, and no content slot holds a null character; the emitter carries such a node. export function slotFault(type: string, text: string): ConvertFault | undefined { - if (/[\n\r]/.test(text)) return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` } + if (/[\n\r]/.test(text)) return unsupportedNodeShape(`the ${type} content slot holds a line ending, which no inline directive spans: write the node as !adf:carry`) return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 2b5ec91..a1835f6 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1103,7 +1103,7 @@ test('names the content slot no lone plain text node reads back from', () => { assert.equal(code(losslessMarkdownToAdf('!adf:status[
]{color=yellow}\n')), 'unmappable-html') assert.equal(code(losslessMarkdownToAdf('!adf:date[
]{timestamp=1}\n')), 'unmappable-html') assert.equal(code(losslessMarkdownToAdf('!adf:widget[
]\n')), 'unmappable-html') - const spans = 'unspellable-whitespace: the status content slot holds a newline no inline directive spans' + const spans = 'unsupported-node-shape: the status content slot holds a line ending, which no inline directive spans: write the node as !adf:carry' assert.equal(content(losslessMarkdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n')), spans) assert.equal(content(losslessMarkdownToAdf('!adf:status[a b]{color=yellow}\n')), spans) assert.equal(content(losslessMarkdownToAdf('!adf:status[a b]{color=yellow}\n')), spans) diff --git a/src/result.ts b/src/result.ts index 283f1ea..cedcfd1 100644 --- a/src/result.ts +++ b/src/result.ts @@ -6,7 +6,6 @@ export type ConvertErrorCode = | 'not-an-adf-document' | 'unknown-directive-name' | 'unmappable-html' - | 'unspellable-whitespace' | 'unsupported-document-version' | 'unsupported-nesting-depth' | 'unsupported-node-shape' diff --git a/todo.md b/todo.md index 2904775..c0b74db 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,6 @@ | ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---|---| -| 103 | 0.2.0 | | **Fold `unspellable-whitespace` into `unsupported-node-shape`, so a line ending and a null character in a content slot take one code.** | 2 | 2 | 2 | 5 | 6 | 1 | 19.5 | | 84 | 0.2.0 | | **Read GFM's pipe table without leading pipes, and with alignment colons, in `portableMarkdownToAdf`, dropping the alignment.** | 3 | 4 | 5 | 7 | 7 | 4, 7 | 19.4 | | 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 3 | 5 | 6 | 6 | 11.5 | | 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 4 | 5 | 6 | 6 | 10.0 | @@ -74,18 +73,6 @@ ## Details -### 103. Fold `unspellable-whitespace` into `unsupported-node-shape`, so a line ending and a null character in a content slot take one code. - -`slotFault` in `directive-syntax.ts` gives a newline in an `emoji`, `mention` or `status` content -slot `unspellable-whitespace` and a null character `unsupported-node-shape`, though both take the -same fix, writing the node as `!adf:carry`. Today a consumer that switches on `code` with no -`default` needs a case for a code only a line ending in these three slots raises. 0.2.0 already -removes the other `unspellable-*` codes, so folding this one in now adds no new break; later it -would be a break of its own. Revise §Which code a cause takes, and add the change to the changelog -entry that removes `unspellable-character`, to `MIGRATION.md`'s Error codes table and to the -README's error table. Found by the product-owner review of -0.2.0, 2026-10-11. - ### 84. Read GFM's pipe table without leading pipes, and with alignment colons, in `portableMarkdownToAdf`, dropping the alignment. Models write `|:---|` delimiter rows and tables without a leading pipe; both are