diff --git a/CHANGELOG.md b/CHANGELOG.md index db95d9d..7907d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ 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. - **Breaking:** every reader gives code text no mark but `link` and `annotation`, as Atlassian's - schema requires: ``**`npm ci`**`` now reads as plain code, and a directive mark around only code - spans is `unsupported-node-shape`. `adfToLosslessMarkdown` writes code text holding another mark - as `!adf:carry{json="…"}`, and `adfToPortableMarkdown` drops that mark. + schema requires: ``**`npm ci`**`` now reads as plain code, and a directive mark other than a link + around only code spans is `unsupported-node-shape`. `adfToLosslessMarkdown` writes code text + holding another mark as `!adf:carry{json="…"}`, and `adfToPortableMarkdown` drops that mark. - `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`. - The README compares the package with seven other ADF converters, each checked against its diff --git a/MIGRATION.md b/MIGRATION.md index 1adb440..029123e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2,23 +2,22 @@ ## From `0.2.0` to `0.3.0` -A mark spelling skips a node Atlassian's schema withholds the mark from, in `losslessMarkdownToAdf`, and -skips code text in every reader: +Every reader skips code text when it spells a mark other than a link, and `losslessMarkdownToAdf` +also skips every other 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 around only nodes that cannot take it, hard breaks aside, or a directive mark around only such nodes or code spans (`[!adf:status[Done]{color=green}](/u)`) | the nodes hold the mark | `unsupported-node-shape`; write the mark around text beside them | -| a code span inside any mark's spelling but a link's (``**`npm ci`**``), in every reader | the code text holds the mark | the code text holds only `code` and the link | +| a code span inside any mark's spelling but a link's (``**`npm ci`**``) | the code text holds the mark | the code text holds only `code`, and a link where one wraps it | -`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. Written back, a space beside such a node inside -the mark's spelling becomes `!adf:text{text=" "}`: ``**Run `npm ci` first**`` writes back as -``**Run!adf:text{text=" "}**`npm ci`**!adf:text{text=" "}first**``. A kept mark is one Atlassian's -schema withholds, so a site may refuse the document: keep one only in markdown written from ADF that -held it. To keep the mark, read the markdown with `0.2.0` and write it with `0.3.0`, installed side -by side: +`0.3.0`'s `adfToLosslessMarkdown` writes a node holding a withheld mark as `!adf:carry{json="…"}`, +where `0.2.0` wrote these spellings. `0.3.0` reads markdown stored from such a document without the +mark, and refuses it where the mark wrapped only those nodes. Written back, a space beside the node +inside the mark's spelling becomes `!adf:text{text=" "}`: ``**Run `npm ci` first**`` writes back as +``**Run!adf:text{text=" "}**`npm ci`**!adf:text{text=" "}first**``. Keep the mark only in markdown +written from ADF that held it: Atlassian's schema withholds it, so a site may refuse the document. +To keep it, 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 diff --git a/README.md b/README.md index 3cdb33b..3806dbc 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ saving what this pair read replaces mentions, attachments and macros with text. `_(synced block not included)_` for a `syncBlock`. - `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, keeping its text, and so does a mark the flavour cannot spell where it stands. Code text keeps - only `link`. + no mark but `code` and `link`. - A newline in text is a hard break and in an expand's title a space, edge whitespace outside a link or code span is trimmed, carriage returns and null characters are removed, and an empty paragraph drops. @@ -536,8 +536,8 @@ Serves [Goals](#goals) 1, 4 and 5. alt text linked to its URL: `See ![the moon](moon.png).` reads as `See [the moon](moon.png).`. To keep an image an image, give it a paragraph of its own: a blank line between it and any other line. - - A code span inside emphasis reads as code alone, since ADF gives code text no mark but a link - or an annotation: ``**`npm ci`**`` reads as `` `npm ci` ``. + - A code span inside emphasis reads as code without the emphasis, since ADF gives code text no + mark but a link or an annotation: ``**`npm ci`**`` reads as `` `npm ci` ``. - Inside a link, an image reads as plain text the link marks. A link alone in its paragraph whose whole text is one image reads as that image, with the link marking it. - An image's title becomes its caption when the image stays an image, and its link's title when diff --git a/docs/decisions.md b/docs/decisions.md index ce77576..7417d7e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -259,11 +259,11 @@ Their CommonMark suite exceptions are `node-model` (§The CommonMark suite check ## 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. Code -text follows by this entry's rule, unpanelled, 2026-10-14. +3 each. Goals 1, 4 and 6. Valid while Atlassian's schema withholds marks from inline nodes. Extended +to code text under this rule, without a panel, 2026-10-14. A mark spelling skips a node that takes none of its type, code text included, which takes only -`link` and `annotation`. So `**Ping !adf:mention[@A]{id=x} now**` reads as bold text around a plain +`code`, `link` and `annotation`. So `**Ping !adf:mention[@A]{id=x} now**` reads as bold text around a plain mention, and ``**`npm ci`**`` as plain code. 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 diff --git a/spec/flavour.md b/spec/flavour.md index 87ec7b8..db4b20b 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -549,9 +549,9 @@ A block's `marks` key also holds these, which no inline spelling wraps: - `fragment` — Attributes: `localId` (string), `name` (string). - `indentation` — Attributes: `level` (number, 1–6). -A spelling adds its mark to every text node it wraps but one marked `code`, which takes only -`link`, and to every other inline node Atlassian's schema gives that mark: ``**`npm ci`**`` reads -as plain code. Of the marks a spelling makes, `date`, `emoji`, `hardBreak`, `inlineCard`, `mention` +A spelling adds its mark to every inline node it wraps that Atlassian's schema gives the mark. Text +takes every mark, except text marked `code`, which takes only `link`: ``**`npm ci`**`` reads as +plain code. 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 diff --git a/src/adf/inline-nodes.ts b/src/adf/inline-nodes.ts index 71768d3..fa4552d 100644 --- a/src/adf/inline-nodes.ts +++ b/src/adf/inline-nodes.ts @@ -37,7 +37,7 @@ export function inlineNodeModel(type: string): InlineNodeModel | undefined { return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined } -// Undefined where the schema withholds no mark: the table lists no such type, text among them, but code text takes only `codeTextMarkTypes`. +// Undefined means every mark: the table lists only types the schema restricts, and it restricts text only when marked code. export function takenMarkTypes(node: AdfNode): readonly string[] | undefined { if (node.type === 'text') return nodeMarks(node).some((mark) => mark.type === 'code') ? codeTextMarkTypes : undefined return inlineNodeModel(node.type)?.markTypes diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index e641bd0..ec1a512 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -99,7 +99,7 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } } -// Seeds the withheld-mark carries once per line, so no run at any depth reads a node's marks whole. +// Seeds the withheld-mark carries once per line, so the run at each mark depth does not rescan every node's marks. function freshFallbacks(nodes: readonly AdfNode[], flavour: WrittenFlavour): LineFallbacks { const carried = new Set() for (const [index, node] of nodes.entries()) { diff --git a/todo.md b/todo.md index 1afadc8..59e5441 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 116` +`Next ID = 117` | Goal | W | |---|---| @@ -27,7 +27,7 @@ |---|---|---|---|---|---|---|---|---|---|---| | 73 | 0.3.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 | | 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 | -| 115 | 0.3.0 | defect | **Build text holding no `border` mark in every reader, and have `adfToLosslessMarkdown` carry text holding one.** | 3 | 3 | 3 | 4 | 6 | 4 | 12.3 | +| 115 | 0.3.0 | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 3 | 3 | 3 | 4 | 6 | 4 | 12.3 | | 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 | | 59 | 0.3.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 3 | 2 | 6 | 2 | 10.4 | @@ -37,6 +37,7 @@ | 33 | 0.3.0 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 8 | 6 | 9 | 9 | 8.7 | | 98 | 0.3.0 | defect | **Read blank lines inside deeply nested list items in time linear in the markdown.** | 3 | 3 | 7 | 5 | 9 | 9 | 8.2 | | 102 | 0.3.0 | decision | **Place a block a portable task list refuses at its path in the returned document without replaying `splitTaskList` on stand-in nodes.** | 3 | 4 | 7 | 2 | 4 | 1 | 8.0 | +| 116 | 0.3.0 | question | **Retitle `docs/decisions.md` §A mark skips a node that cannot hold it to say only a link or directive mark wrapping only such nodes is refused.** | 1 | 1 | 1 | 2 | 3 | 6 | 5.4 | | 109 | 0.3.0 | defect | **Spell nested lists and quotes in time linear in the markdown.** | 3 | 5 | 8 | 4 | 7 | 9 | 5.3 | | 7 | 0.4.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 9 | 2, 4 | 25.8 | | 45 | 0.4.0 | | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 3 | 4 | 6 | 8 | 1 | 25.2 | @@ -49,7 +50,7 @@ | 112 | 0.4.0 | | **Pad a GFM pipe table's short rows with empty cells, read its body rows holding no pipe, and read a one-column table without leading pipes in `portableMarkdownToAdf`.** | 3 | 3 | 5 | 6 | 6 | 4 | 16.3 | | 46 | 0.4.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 3 | 5 | 5 | 3, 8 | 14.8 | | 72 | 0.4.0 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 2 | 1 | 1 | 3 | 6 | 3 | 13.1 | -| 91 | 0.4.0 | | **Spell an emphasis or link once across the code spans, 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 | 6 | 6 | 6 | 11.0 | +| 91 | 0.4.0 | | **Spell an emphasis once across the code spans, 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 | 6 | 6 | 6 | 11.0 | | 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 | @@ -85,13 +86,13 @@ there reads back as `[alt](href)`, breaking the byte-for-byte rewrite, so only o `href` can survive; a reader panel picks which. If it picks `url`, the README names the dropped `href` where it says link targets stay. -### 115. Build text holding no `border` mark in every reader, and have `adfToLosslessMarkdown` carry text holding one. +### 115. Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one. `!adf:border[x]{color="#091e42" size=2}` builds text holding `border`, which `formatted_text_inline_node` (`spec/adf-schema/full.json`) withholds from text: only `media` and -`mediaInline` take it, so a site may reject the document. `takesMark` (`src/adf/inline-nodes.ts`) -gives text every mark but code text's. It changes how stored markdown reads, so it ships in a minor. -Found while building item 114, 2026-10-14. +`mediaInline` take it, so a site may reject the document. `takenMarkTypes` +(`src/adf/inline-nodes.ts`) restricts text only when it is marked `code`. It changes how stored +markdown reads, so it ships in a minor. Found while building item 114, 2026-10-14. ### 100. 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. @@ -164,6 +165,14 @@ the fix. `taskOfParagraphs` keeps the original child objects. Both paired-ruling seats on 2026-10-11 named it the reason the project read worse. +### 116. Retitle `docs/decisions.md` §A mark skips a node that cannot hold it to say only a link or directive mark wrapping only such nodes is refused. + +The title says "a mark wrapping only such nodes is refused", but emphasis wrapping only a mention or +a code span reads as the plain node, as the entry's body says. Proposed title: "A mark skips a node +that cannot hold it, and a link or directive mark wrapping only such nodes is refused, but for a link +around hard breaks alone", with `AGENTS.md`'s index line. Found by the prose review of item 114, +2026-10-14. + ### 109. Spell nested lists and quotes in time linear in the markdown. Each level of a nested list or quote re-indents its whole text, so the work is the depth times the @@ -271,14 +280,14 @@ Gitea copy holds no issues or releases to move. Actions stay off on the mirror: otherwise run there. When this lands, the `docs/decisions.md` entry "GitHub is canonical" loses its interim sentence. -### 91. Spell an emphasis or link once across the code spans, hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers. +### 91. Spell an emphasis once across the code spans, 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, and none gives code text a mark but a link, so `**Ping !adf:mention[@A]{id=x} -now**` and ``**Run `npm ci` first**`` write back with the space beside the mention or the code in -each strong run spelled `!adf:text{text=" "}`. Joining the run across the break in +withholds from them, and none gives code text a mark but a link. So `**Ping !adf:mention[@A]{id=x} +now**` and ``**Run `npm ci` first**`` each write back as two strong runs, with the space beside the +mention or code 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.