From 04931ab35a33ca29008dd12fbcfc3b4f8f5ab21d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 21:09:51 +0200 Subject: [PATCH] Answer prose round 1: the refusal list lives in the error row, the slot rule gives its reason, and stale item citations go --- CHANGELOG.md | 9 +++++---- MIGRATION.md | 6 +++--- README.md | 7 +++---- docs/decisions.md | 14 +++++++------- spec/flavour.md | 19 +++++++++---------- src/adf/document.ts | 2 +- src/markdown/directive-syntax.ts | 2 +- src/markdown/emit/adf-to-markdown.ts | 2 +- todo.md | 15 +++++++-------- 9 files changed, 37 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b424638..d73fcc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,12 @@ spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding other than plain text nodes rides the block carry, where it was refused. - **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and - `adfToMarkdown` no longer refuses `unspellable-whitespace`. A carriage return in text is spelled + `adfToMarkdown` no longer returns `unspellable-whitespace`. 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 one, its backticks opening no fence; a code block holding either character, a text - node holding no text or holding `content`, and an `emoji`, `mention` or `status` whose `text` holds - a line ending or a null character ride the carry. See `MIGRATION.md`. + 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 + `content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null + character ride the carry. See `MIGRATION.md`. - Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it read back as U+FFFD. - **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no diff --git a/MIGRATION.md b/MIGRATION.md index 66e2c03..e003bd6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -63,8 +63,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc ### Error codes `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`: -a `switch` naming one stops compiling. The document a removed code named on emit converts; the -markdown it named on parse refuses with the code the table gives. +a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now +converts; markdown the parser refused with one of them now refuses with the code the table gives. | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | @@ -73,7 +73,7 @@ markdown it named on parse refuses with the code the table gives. | a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` | | a carriage return or null character in a code block, or a null character in a code span, on emit | `unspellable-character` | rides the carry | | 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 one of the above, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling | +| 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 | | 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 | diff --git a/README.md b/README.md index 59cb408..ba17236 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ emit refuses: | Code | Fires when | What you can do | | --- | --- | --- | | `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: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node listed under "Not every document converts back" below | fix what the message names; `spec/flavour.md` lists every type's attributes and body | +| `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying 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` | fix what the message names; `spec/flavour.md` lists every type's attributes and body | ## The guarantees @@ -224,9 +224,8 @@ Serves Goals 1, 3 and 4. text, which the spec requires and the reference itself breaks, nesting one `` in the other. The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite holds no example of the fourth. -- Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, a - node carrying one mark type twice, a block or a non-text inline node holding `text`, and a leaf - block or a non-text inline node holding `content`. Show the refusal and keep the document +- Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, and + the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document read-only; saving markdown you could not produce is the loss the round-trip exists to stop. - The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error result rather than the prose it reads as, and an alignment colon in the delimiter row is an diff --git a/docs/decisions.md b/docs/decisions.md index dfae570..0725c68 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -23,7 +23,7 @@ sanitized and a mention keeps the test user's real account id. ## Markdown in is a canonical fixpoint -2026-08-23, the maintainer; the way back made total 2026-10-04. Goals 1 and 4. Valid while +2026-08-23, the maintainer; every parsed document converts back since 2026-10-04. Goals 1 and 4. Valid while markdown input may be written by hand. The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way @@ -347,10 +347,10 @@ 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. Where two codes - stay apart, the line between them is what they name: `unspellable-whitespace` is the newline an - inline directive's content slot spells in input, where the emitter carries the node - (2026-10-04). + `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 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 @@ -493,8 +493,8 @@ exits 0 on. 2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid while `todo.md` items 59, 60, 61 and 62 are open. -A four-seat comprehension panel scored the project under the floor of 7. Its last scoring run -(item 55, 2026-10-04) is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension +A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of +2026-10-04 is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension lower improves what the seats named and scores again, and after three such rounds asks the maintainer. diff --git a/spec/flavour.md b/spec/flavour.md index 778c5c6..3f692c4 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -450,8 +450,8 @@ the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content parsing to anything but one text node carrying neither marks, attributes nor content is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot. -A node whose `text` holds a line ending or a null character rides the inline carry; a slot spelling -one is a named error. +An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character +rides the inline carry; a content slot spelling one is a named error. The rest take no content, `!adf:text` included; content on a node that takes none is a named error. - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds). @@ -475,11 +475,11 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000 ``` **Text CommonMark cannot hold plainly.** A carriage return in text is ` `, which CommonMark reads -back as the character. A newline inside a text node, a null character, which CommonMark reads as -U+FFFD, and a space or tab where CommonMark strips or refuses one — a block's inline content edges, -either side of a line break, an em, strong or strike spelling's inner edges, a pipe cell's edges — -is spelled `!adf:text{text="…"}`, the reserved key carrying the node's text, escaped by the -attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the run alone and +back as the character. Three things are spelled `!adf:text{text="…"}`: a newline inside a text +node; a null character, which CommonMark reads as U+FFFD; and a space or tab where CommonMark strips +or refuses one — a block's inline content edges, either side of a line break, an em, strong or +strike spelling's inner edges, a pipe cell's edges. The reserved key carries the node's text, +escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the run alone and leaves the rest plain text, which the spelled run joins on reading. Input reads that spelling alone: the value is one run of spaces and tabs, one run of newlines, or one run of null characters, and anything else — a mixed run, or text CommonMark carries plainly — is a named error. @@ -535,9 +535,8 @@ breaks at every node the emitter carries, so no emitted carry sits inside a mark An inline node whose marks no nesting spells — a mark type not listed here, 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 open or close where the run sits (`un**-real**istic`), or one +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 open or close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a mark spelling is a named error in input: the carry diff --git a/src/adf/document.ts b/src/adf/document.ts index 8816ea8..5945edb 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -65,7 +65,7 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma return keys } -// A format spells this node as text; anything more rides a carry. +// No attrs, content or empty marks; a format may spell this node as text, and anything more rides a carry. export function isBareText(node: AdfNode): boolean { return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0 } diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 621c820..623c71e 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -100,7 +100,7 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read< return { value: spelled.decoded } } -// What no content slot holds, which the emitter carries instead: an inline directive never spans lines, and CommonMark reads a null character as U+FFFD. +// No content slot holds a line ending or a null character, which only the text directive spells; 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` } return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index cac3017..12cf34d 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -272,7 +272,7 @@ function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertEr return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n'))) } -// The text each fence holds, or `undefined` where a child is no text a fence keeps, which the carry holds instead. +// The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node. function fencedTexts(node: AdfNode): string[] | undefined { if (node.content === undefined) return [''] const texts: string[] = [] diff --git a/todo.md b/todo.md index 4b5e86e..9166f5b 100644 --- a/todo.md +++ b/todo.md @@ -51,7 +51,7 @@ | 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 | | 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 | | 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 | -| 69 | 0.3.0 | question | **Decide whether a text node's `content` is content `adfToPlainMarkdown` keeps, and keep or document it.** | 2 | 2 | 3 | 5 | 6 | 7.9 | +| 69 | 0.3.0 | question | **Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README.** | 2 | 2 | 3 | 5 | 6 | 7.9 | | 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 | | 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 7 | 1.2 | | 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 7 | -2.2 | @@ -62,8 +62,7 @@ ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF. Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against -`markdownToHtml`. The README documents HTML as it documents markdown, and its tagline and -`package.json`'s `description` keep naming HTML. +`markdownToHtml`. The README documents HTML as it documents markdown. ### 45. Replace `isAdfDocument` with a reader returning `Result`. @@ -76,7 +75,7 @@ cannot say which branch refused, where `not-an-adf-document`'s message already d `quoteNode`'s title loop (`parse/markdown-to-adf.ts`) spreads `inlineLeaves`'s result into `push`, against `docs/decisions.md` §Nothing spreads an unbounded array: an inline carry in a folded callout's title holding an unknown node with ~125k text children throws a `RangeError` from -`plainMarkdownToAdf`. Found by the README-goals audit of item 55, 2026-10-04. +`plainMarkdownToAdf`. Found by the README-goals audit, 2026-10-04. ### 6. Specify the HTML dialect. @@ -126,8 +125,8 @@ writer panel chose this reading 3 of 3 (2026-10-04), by Goal 5, which outranks G `refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`) refuse these with `unsupported-node-shape`, though `isAdfDocument` accepts them and the carry round-trips them; `docs/decisions.md` §The code list gives a cause the carry answers no code. The -README's "Not every document converts back" bullet loses them. Found by the README-goals audit of -item 55, 2026-10-04. +Drop them from the README's "Not every document converts back" bullet. Found by the README-goals +audit, 2026-10-04. ### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`. @@ -264,7 +263,7 @@ or report the unminified gzip). The figure lands in README §The package beside dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 kB. -### 69. Decide whether a text node's `content` is content `adfToPlainMarkdown` keeps, and keep or document it. +### 69. Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README. `nodeLeaves` (`plain/inline-reduction.ts`) keeps only a text node's `text`, so `{ text: 'a', content: [{ text: 'kept', type: 'text' }] }` writes `a`, though `toEditorNormal` @@ -272,7 +271,7 @@ keeps that content (`plain/editor-normal.ts`) and an unknown node's children kee Atlassian's schema allows no `content` on a text node, so no Atlassian reader saw it; an export/indexing tool fed a looser producer loses searchable text. Goal 6 reads two ways here: keep the children's text as an unknown node's, or name the drop in the README's plain section. Found by -the product-owner review of item 55, 2026-10-04. +the product-owner review, 2026-10-04. ### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.