From f180943edfe395800f063035ee0c76cd9c259d82 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 3 Oct 2026 12:28:20 +0200 Subject: [PATCH] Record deep equality, the text break, empty keys, -0, the typed carry fence and per-node code fences; file items 52 and 53 --- AGENTS.md | 8 +- CHANGELOG.md | 18 ++++- MIGRATION.md | 6 +- README.md | 8 +- corpus/README.md | 4 +- docs/decisions.md | 106 +++++++++++++++++++++++---- spec/flavour.md | 102 +++++++++++++++++--------- src/markdown/emit/plain-reduction.ts | 2 +- todo.md | 24 +++--- 9 files changed, 203 insertions(+), 75 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e954f90..23f0546 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,14 @@ In `docs/decisions.md`: - Plain markdown is a flavour of the grammar - The round-trip is the product - Markdown in is a canonical fixpoint -- Equality is editor-normal +- Equality is deep +- `!adf:textBreak{}` parts text CommonMark would join +- An empty key spells `empty` +- `-0` is spelled `-0` +- Empty markdown is a document of no blocks - Unknown nodes ride the carry +- The carry fence names the node type +- A code block is a fence per text node - Foreign HTML sorts three ways - Names stay text - Directives under `!adf:` diff --git a/CHANGELOG.md b/CHANGELOG.md index 442e445..a02d444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,20 @@ ## Unreleased -- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:` - prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place - of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an - ordinary code block language. Convert stored markdown per `MIGRATION.md`. +- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are + spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, + `!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms, and the block carry is a code + fence whose info string `adf:` names the node's type, its body the node's JSON without + `type`: text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are + claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`. +- **Breaking:** `markdownToAdf` reads 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. +- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would + join are parted by `!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled + `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is 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-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no CommonMark escape spells is written as `!adf:link[text]{attrs}`. - **Breaking:** some directive refusals carry `malformed-directive` where they carried diff --git a/MIGRATION.md b/MIGRATION.md index 2dfb73f..edd56de 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -40,12 +40,12 @@ function migrateMarkdown(stored: string) { | `::media {id=a type=file}` | `!adf:media {id=a type=file}` | | `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` | | `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` | -| the `adf` code fence and `:adf{json="…"}` | the `carry` code fence and `!adf:carry{json="…"}` | +| the `adf` code fence and `:adf{json="…"}` | the `adf:` code fence, its JSON without `type`, and `!adf:carry{json="…"}` | | `\:` keeps a directive literal | `\!adf:` keeps a directive literal | | `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` | A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text -holding an unescaped `!adf:` and a `carry` fence are claimed instead. +holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are claimed instead. ### Readings @@ -54,6 +54,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | | a link whose text already holds one (`[ab](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them | +| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former | +| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block | ### Error codes diff --git a/README.md b/README.md index f164f6b..e69daa6 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0` | Code | Fires when | What you can do | | --- | --- | --- | -| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text | +| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text | | `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `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 an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour | @@ -204,7 +204,9 @@ emit refuses: Serves Goals 1, 3 and 4. -- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely +- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` — every key and value as `doc` holds it, + adjacent text nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node + types carried opaquely ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). - Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from `0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below @@ -239,7 +241,7 @@ Serves Goals 1, 3 and 4. makes a call loop forever. - The emitted formats are semver surface ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)). -- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides +- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides `data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup recovery. diff --git a/corpus/README.md b/corpus/README.md index 097d0af..6ae3974 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -18,8 +18,8 @@ One directory per contract kind: `reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap). -JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys -sorted. `spec.json` is the vendored, upstream machine-readable suite, byte-exact from +JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's +(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable suite, byte-exact from [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not re-serialized by the corpus gate. diff --git a/docs/decisions.md b/docs/decisions.md index b7f65ed..df52047 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -14,7 +14,7 @@ a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF a 2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back through the lossless pair. -`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything +`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-equal `doc` — anything less silently destroys content an editor could not represent, in a document it did not author. When losslessness and readability conflict, losslessness wins. Round-trip equality is a property tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads @@ -31,27 +31,100 @@ where there is a way back. CommonMark spells some things the flavour has no esca paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those. -## Equality is editor-normal +## Equality is deep -2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this -merges. +2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a +shape the editor would not. -"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks -and no attributes merged, JSON number semantics, an empty attrs object, marks array or content -array the absent key — the only domain markdown can restore. -`todo.md` item 40 replaces this with deep equality (2026-09-28, the maintainer). +"Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an +empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side. +CommonMark's spelling stays wherever a document holds none of those shapes. The plain flavour is +lossy and stays editor-normal: its reduction reads and writes ADF with adjacent text nodes of +identical marks and no attributes merged, `-0` read as `0`, and an empty `attrs`, `content` or +`marks` the absent key. + +## `!adf:textBreak{}` parts text CommonMark would join + +2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run. + +Two adjacent text nodes a reader would build back as one — neither carried, neither holding +`attrs` or an empty key, their marks identical, attributes included — are parted by the reserved +inline leaf `!adf:textBreak{}`, mirroring `!adf:listBreak`: it builds no node, and anywhere but +between two such nodes, or given `[content]` or `{attrs}`, it is `unsupported-node-shape`. It sits +inside every mark spelling the pair shares, `**Hello, !adf:textBreak{}world**`; a code span holds +no directive, so it closes and reopens, `` `a`!adf:textBreak{}`b` ``. A carried node never joins +its neighbour, so a carry needs no break. A mark run breaks on any difference in the mark, `attrs: +{}` against no `attrs` included. + +## An empty key spells `empty` + +2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while no attribute value +spells an empty object or array. + +An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the +bare value `empty` on every directive — block, inline node and mark: `{attrs=empty}`, +`{content=empty}`, `{marks=empty}`. A writer panel chose the spelling, 5 of 7. A container +spelling `content=empty` closes with no body, so an empty pair stays the node holding no content +key; a leaf spells it too, `!adf:rule {content=empty}`. A node CommonMark spells takes its +directive form to hold an empty key, and a text node holding one rides the inline carry, as does a +node under an `em`, `strong`, `strike`, `code` or `link` mark whose `attrs` is empty, since none of +those spellings holds attributes. Any other value of a reserved key is `unsupported-node-shape`, +except on a block's `marks`, which reads it as the marks array in JSON. + +## `-0` is spelled `-0` + +2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`. + +Wherever the flavour writes a number or a JSON value — an attribute, a `json` value, the carry — +`-0` is `-0`, which JSON's grammar reads back as `-0`. An `orderedList` whose `order` is `-0` takes +the directive form, since no list marker spells the sign. + +## Empty markdown is a document of no blocks + +2026-10-03, a panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires +`content` on `doc`. + +Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document +`spec/adf-schema/full.json` requires. A document holding no `content` key is +`!adf:doc {content=none}` standing alone as its only block, and a named error anywhere else. The +panel split 4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a +bare `!adf:doc`. ## Unknown nodes ride the carry -2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF -holds nodes, or node positions, this library does not spell. +2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, the +maintainer. Goal 1. Valid while ADF holds nodes, or node positions, this library does not spell. An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and restores to a deep-equal node. The round-trip holds for documents newer than the library. So does a known node no section spells where it stands: a markdown serializer spells a node by type without -checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. -Where a container's own spelling cannot hold the child it has — a `bulletList` holding other than -`listItem`, a `codeBlock` other than text — the error result names that instead. +checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. A +`codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or +`content` — rides the carry whole. + +## The carry fence names the node type + +2026-10-03, the maintainer. Goals 1 and 5. Valid while a code fence's info string reads back +verbatim. + +The block carry is a code fence whose info string `adf:` names the node's type, its body the +node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the +rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every +info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the +`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named +type, or an `adf:` fence whose type an info string carries, is `unsupported-node-shape`. + +## A code block is a fence per text node + +2026-10-03, the maintainer. Goal 1. Valid while ADF holds a code block's text in more than one +node. + +A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per +node, each fence's info string the language; its other attributes sit on the opener, and a +language no info string carries stays the opener's `language` with bare fences. A `codeBlock` +spelling `content=empty` has no fence to carry the language, so the opener does. Fences with +differing info strings are `unsupported-node-shape` — ADF holds one language — and so is an empty +fence beside another, since a text node holds text. ## Foreign HTML sorts three ways @@ -295,10 +368,11 @@ handles one cause alike whichever node, attribute or direction raised it. - A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never - that code, and the two the flavour reserves part on form: a form the grammar does not have is a - claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place + that code, and the names the flavour reserves part on form: a form the grammar does not have is + a claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one - type (2026-09-01). + type, `!adf:textBreak{}` anything but two text nodes a reader joins, `!adf:doc` standing beside + another block (2026-09-01, the text break and `doc` 2026-10-03). - A well-formed directive the node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape its content model does not take — is `unsupported-node-shape`, the emitter's code for the same diff --git a/spec/flavour.md b/spec/flavour.md index fcb97ba..d61d2f0 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -44,7 +44,8 @@ normalizes to it through the round-trip. CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node carries an attribute, it is the inline directive. - An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair - holding nothing. + holding nothing, and one whose `content` is an empty array the pair + `!adf:paragraph {content=empty}` … `!adf:/paragraph` (Attributes). - Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a title; title in double quotes. A backslash escapes a parenthesis the destination leaves unbalanced, and a quote inside the title; a balanced pair stays bare. `` autolink form only @@ -67,7 +68,10 @@ normalizes to it through the round-trip. matching below, which is what lets the emitter decide its own pairings. - Blocks separated by one blank line at document level, inside a blockquote and between CommonMark blocks; inside a directive container a pair holding a directive block takes none. No trailing - whitespace outside a code block's content, single trailing newline; a document with no blocks is the empty string. + whitespace outside a code block's content, single trailing newline. A document whose `content` is + an empty array is the empty string, and markdown holding no block reads back to it; a document + holding no `content` key is the leaf `!adf:doc {content=none}` as its only block, which is a + named error anywhere else or spelled any other way. ## Directives @@ -79,9 +83,11 @@ result naming it at the opener, whatever follows it — so output an old emitter escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md` §The formats are API). Each name belongs to one position, and a name the other one spells — a mark or an inline node written as a block directive, a block node written inline — is a different error, -naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry, -as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent -lists (Canonical form). +naming the spelling it takes. Four reserved names read back to no node: `carry` for the inline +opaque carry, `listBreak` for the leaf that parts two adjacent lists and `doc` for a document +holding no `content` key (Canonical form), and `textBreak` for the leaf that parts two text nodes +(Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque +carry). **Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name` closes a container, and a name picks by what follows it in turn — a space or the line's end a block @@ -142,6 +148,13 @@ ends the name (`!adf:hardBreak{}`). Input reads that spelling alone: keys out of quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` on a block line or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a named error naming the spelling to write instead. +`attrs`, `content` and `marks` are reserved keys on every directive — block, inline node and mark — +whose bare value `empty` spells the node's or mark's key holding an empty object or array: +`!adf:underline[a]{attrs=empty}`, `!adf:date{content=empty}`, `!adf:hardBreak{marks=empty}`. A +container spelling `content=empty` closes with no body; `attrs=empty` stands beside no other +attribute, argument or content slot; and an inline node spelling `marks=empty` stands inside no +mark spelling. Any other value of a reserved key is a named error, except on a block's `marks` +(Block nodes). **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and @@ -163,15 +176,18 @@ and restores to a deep-equal node. A carry may hold a node the emitter spells na unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the product). Block and inline positions canonicalize differently, each fitting where it sits: -- **Block position**: a fenced code block with info string `carry`, body = the node's JSON — - two-space indent, object keys sorted. +- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the + node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````. + A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info + string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or an + `adf:` fence whose type an info string carries, is a named error. - **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace), JSON-string-escaped into the attribute. -The info string `carry` is reserved: a genuine `codeBlock` whose `language` is exactly `carry` takes +Every info string opening `adf:` is reserved: a genuine `codeBlock` whose `language` opens so takes the attribute the section below keeps for a language no info string holds, so the reservation -stays absolute. -In block-directive position `!adf:carry` is a named error — the carry's block form is the fence. +stays absolute. In block-directive position `!adf:carry` is a named error — the carry's block form +is the fence. ## Raw HTML in input @@ -193,13 +209,14 @@ Each section lists attributes as `name (type)`. A parenthesized value set docume payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare (`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted), -quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal -ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md` -§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two -spellings. +quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the +markdown spells one, an empty one through its reserved key (Attributes), so a document reads back +deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive +form to hold an empty key. Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a -`json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. +`json` value, `marks=empty` where it is empty: +`!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. A section saying its body is inline takes at most one paragraph, whose inline content becomes the node's `content`; any other body is a named error, and an empty pair is a node holding none. @@ -215,14 +232,17 @@ cannot — `localId` (string) on any of them, marks, and the values below — ta form. - `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string). -- `codeBlock` — container, body one fenced code block whose info string is the language and whose - content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` - (string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty, - the reserved `carry`, or holding a backtick, a backslash, a control character, edge whitespace or - an entity reference — rides the `language` attribute instead and the fence carries no info - string; writing it in the slot that rule leaves empty, or in both, is a named error. The body is - one ordinary code block, and a fence's info string decodes escapes and entity references as any - other does. +- `codeBlock` — container, body one fenced code block per text node, each fence's info string the + language and its content the node's text; a node holding no `content` key is one empty fence. + Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` (string), `uniqueId` + (string), `wrap` (boolean). A language no info string carries back — empty, opening the reserved + `adf:`, or holding a backtick, a backslash, a control character, edge whitespace or an entity + reference — rides the `language` attribute instead and the fences carry no info string, and so + does a language beside `content=empty`, which has no fence; writing it in the slot that rule + leaves empty, or in both, is a named error, and so are fences whose info strings differ and an + empty fence beside another. Each fence is an ordinary code block, and its info string decodes + escapes and entity references as any other does. A node holding a child no fence holds — any but + a text node carrying no marks, `attrs` or `content` — rides the block carry. - `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no CommonMark spelling. @@ -424,9 +444,8 @@ Right. Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as 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 — adjacent text -nodes with identical marks and no attributes merged first — is a named error, and so is a `text` key -in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no 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. 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). @@ -453,16 +472,27 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000 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 whitespace run alone and leaves the rest plain text; -`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes -(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one -run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark -carries plainly — is a named error. +cells trim and pad. The emitter wraps the whitespace 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, or one run of newlines, and anything else — a mixed run, or text CommonMark carries +plainly — is a named error. ``` !adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines. ``` +**Adjacent text nodes.** CommonMark reads two adjacent text nodes back as one where neither is +carried, neither holds `attrs` or an empty key, and their marks are identical, attributes included. +The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a +code span holds no directive, so it closes and reopens. It builds no node and reads only between +two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md` +§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs` or an empty key +rides the inline carry. + +``` +Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b` +``` + ## Marks An inline node's marks ride the spelling wrapped around them, never the block sections' reserved @@ -490,14 +520,14 @@ the directive form, open to no literal reading, is a named error. A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order, outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it — -`docs/decisions.md` §Equality is editor-normal 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. A run breaks at every node the emitter carries, so no emitted carry sits -inside a mark spelling. +`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 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 order putting a code span outside another mark, `code` over anything but a +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 or over text holding a newline, 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 diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 593c4fe..30d82bb 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -51,7 +51,7 @@ export function reduceToPlain(document: AdfDocument): Result { const fault = adfDocumentFault(document) if (fault !== undefined) return faulted(fault, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) - // The plain flavour is lossy: it reads and writes editor-normal ADF, whose shapes CommonMark spells. + // docs/decisions.md §Equality is deep: the plain flavour reads and writes editor-normal ADF. const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] }) return blocks.ok ? success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 }) : blocks } diff --git a/todo.md b/todo.md index 9fd26b3..cab4975 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 52` +`Next ID = 54` | Goal | W | |---|---| @@ -24,13 +24,14 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 40 | 0.2.0 | decision | **Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.** | 6 | 7 | 8 | 9 | 1 | 26.2 | | 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 | | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 | | 43 | 0.2.0 | | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 | | 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 | +| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 | | 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 | +| 53 | 0.2.0 | | **Bench a block's `marks` spelling with the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 | | 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 | | 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 | | 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 | @@ -45,14 +46,6 @@ ## Details -### 40. Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes. - -Today it holds for editor-normal documents only: two adjacent text nodes with the same marks merge, -an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines and bots -build. Spell each so it reads back as written; CommonMark's spelling stays wherever the document -holds none of these shapes. The spellings are part of the chunk. `docs/decisions.md` §Equality is -editor-normal, `spec/flavour.md` and `corpus/README.md` follow, and the tests drop `toEditorNormal`. - ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF. Lands after item 6. The CommonMark spec suite also runs against `markdownToHtml`. The README @@ -90,6 +83,11 @@ Readings table gains its row, and its Spellings table one if `!adf:listBreak` re and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer. +### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`. + +A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]"`. Breaking: +`MIGRATION.md`'s Spellings table gains its row. + ### 51. Match a reference label to its definition under Unicode case folding. `link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec @@ -97,6 +95,12 @@ example 540); lowercasing and then uppercasing folds it. Breaking, so it ships b `MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer. +### 53. Bench a block's `marks` spelling with the writer panel and adopt its pick. + +Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as +escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table +gains its row. + ### 50. Read `[](/url)` and `[]()` as CommonMark's empty link. Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a