3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00
Files
adf-codec/MIGRATION.md
T

145 lines
12 KiB
Markdown

# Migrating
## From `0.2.0` to `0.3.0`
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`**``) | the code text holds the mark | the code text holds only `code`, and a link where one wraps it |
`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
```
## From `0.1.0` to `0.2.0`
Each call names the markdown it reads or writes:
| `0.1.0` | `0.2.0` |
| --- | --- |
| `adfToMarkdown` | `adfToLosslessMarkdown` |
| `markdownToAdf`, for markdown `adfToMarkdown` wrote, edited since or not | `losslessMarkdownToAdf` |
| `markdownToAdf`, for markdown no `adfToMarkdown` output went into | `portableMarkdownToAdf`, which reads GFM's pipe tables and `~~`, and reads alerts, callouts, task markers and `==` as nodes ([Portable markdown](README.md#portable-markdown)). Or `commonMarkToAdf`, which reads every one of those, and a directive and an `adf:` fence, as CommonMark does: as text and code |
Directives moved under the `!adf:` prefix. `losslessMarkdownToAdf` reads `0.1.0`'s spelling without
an error, turning each directive into text and each carried node into an `adf` code block. Before
`0.2.0` reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue)
with the recipe below, and rewrite markdown your code writes or matches (templates, prompts,
patterns) by the tables below. Stored ADF needs one change: give a document holding no `content` key
`content: []`. `0.1.0` built that shape from empty markdown, meaning the empty document.
### Convert markdown
Read it with `0.1.0` and write it with `0.2.0`, installed side by side:
```sh
npm install @larvit/adf-codec@0.2.0 adf-codec-0.1@npm:@larvit/adf-codec@0.1.0
```
```ts
import { adfToLosslessMarkdown, type AdfNode } from '@larvit/adf-codec'
import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
// 0.1.0 gave a hard break the marks spelled around it, which 0.2.0 writes as !adf:carry{…}. Drop them
// only where text beside the break holds them too, so a break that was a link's whole text keeps its target.
function unmarkHardBreaks(nodes: AdfNode[]): AdfNode[] {
return nodes.map((node, index) => {
if (node.type !== 'hardBreak') return node.content === undefined ? node : { ...node, content: unmarkHardBreaks(node.content) }
const holds = (side: AdfNode | undefined) =>
side?.type === 'text' && (node.marks ?? []).every((mark) => side.marks?.some((other) => JSON.stringify(other) === JSON.stringify(mark)))
const { marks, ...unmarked } = node
return holds(nodes[index - 1]) || holds(nodes[index + 1]) ? unmarked : node
})
}
function migrateMarkdown(stored: string) {
const parsed = markdownToAdf010(stored)
// 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
return parsed.ok ? adfToLosslessMarkdown({ ...parsed.value, content: unmarkHardBreaks(parsed.value.content ?? []) }) : parsed
}
```
- Convert each document once: a second pass can return ok while turning the directives into text.
Stop `0.1.0` writing first, and record which documents are converted.
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with the `content`
the recipe passes to `adfToLosslessMarkdown`, rather than keeping the unconverted markdown.
### Spellings
| `0.1.0` | `0.2.0` |
| --- | --- |
| `:::panel info` … `:::`, the fence longer per nesting level | `!adf:panel info` … `!adf:/panel` at any depth |
| `::media {id=a type=file}` | `!adf:media {id=a type=file}` |
| `:::mediaSingle {layout=center}` … `:::` around `::media {alt=a marks="[{\"attrs\":{\"href\":\"/v\"},\"type\":\"link\"}]" type=external url=u}` | `[![a](u)](/v)` |
| `::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 `adf:<type>` 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}` |
| `::listBreak` between two lists of a kind | no separator: adjacent lists of a kind alternate `- ` and `* `, or `1.` and `1)` |
| `colwidth="[340,420]"` | `colwidth="340,420"`; a `colwidth` holding no number, or a value that is not a number, rides the `adf:tableCell` or `adf:tableHeader` carry |
| `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"` on a block | `marks="breakout{mode=wide}"`; `marks` stays a JSON array where the flavour does not list a mark's type or one of its attributes, or a value holds another type than the flavour gives it |
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are claimed instead.
### Readings
Markdown the spelling table leaves alone, which `0.2.0` reads as a different document.
| Input | `0.1.0` | `0.2.0` |
| --- | --- | --- |
| a list whose bullet character or ordered delimiter changes (`- a` then `* b`, `1. a` then `1) b`) | one list | two lists, as CommonMark reads them |
| a link reference definition between two list items, at the list's own indentation (`- a`, a blank line, `[r]: /u`, `- b`) | one list | two lists, as CommonMark reads them; indent the definition under the item, or move it below the list, to keep one list |
| a link whose text already holds one (`[a<https://example.com/>b](/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 (`""`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
| a hard break inside a mark's spelling (`*a\` then `b*` on the next line) | the hard break holds the mark | the hard break holds no marks, as Atlassian's schema requires, and the recipe above drops the marks `0.1.0` gave it wherever text beside it holds them. A link whose text is only hard breaks reads as its destination linked, followed by those breaks |
| a link whose text is empty (`[](/url)`, `[]()`) | literal text | `/url` linked to `/url`, and nothing where the destination is empty too |
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | in `losslessMarkdownToAdf`, the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
### Error codes
`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` |
| --- | --- | --- |
| an image not alone in its paragraph (`See ![a](u).`, `# ![a](u)`), on parse | `unmappable-image` | its alt text linked to its URL, or, inside a link, plain text the link marks; give the image a paragraph of its own, a blank line between it and any other line, to keep it an image |
| a titled image alone in its paragraph (`![a](u "t")`), on parse | `unmappable-image` | the image, captioned with its title |
| a link alone in its paragraph whose whole text is an image (`[![a](u)](/v)`), on parse | `unmappable-image` | the image, its `media` marked with the link |
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
| a carriage return in text, on emit | `unspellable-character` | spells `&#13;` |
| 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 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 (`&#10;`, `&#13;`, `!adf:text{text="\n"}`), on parse | `unspellable-whitespace` | `unsupported-node-shape`; replace it with a space, or 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` |
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written with a closer | `unsupported-node-shape` | parses |
| `null`, `undefined` or a non-empty array, on parse | throws a `TypeError` | `not-a-string` |
| a `String` object, on parse | the document its text spells | `not-a-string`; pass `String(value)` |
| any other value that is not a string, such as a number or `{}`, on parse | an empty document | `not-a-string` |
| a conversion past the JavaScript engine's longest array, `Map` or `Set`, or meeting an out-of-memory throw the call can catch, and on JavaScriptCore one past its longest string | throws | `engine-limit`; retry it once |