15 KiB
Migrating
From 0.3.1 to 0.4.0
isAdfDocument is gone. readAdfDocument takes its place and returns a Result<AdfDocument>; the
message of its error names the branch that refused.
0.3.1 |
0.4.0 |
|---|---|
if (isAdfDocument(value)) convert(value) |
const read = readAdfDocument(value); if (read.ok) convert(read.value) |
const valid = isAdfDocument(value) |
const valid = readAdfDocument(value).ok |
docs.filter(isAdfDocument) |
docs.filter((value) => readAdfDocument(value).ok) |
Convert read.value: it is a copy that shares no object with value. The copy keeps own
enumerable keys, as JSON.stringify does, so an inherited or non-enumerable key drops. A document
nesting past 500 levels still reads; the converters refuse it as unsupported-nesting-depth.
readAdfDocument, adfToHtml, adfToLosslessMarkdown and adfToPortableMarkdown read a document
once into that copy. A document holding an accessor property, or a Proxy that throws, is
not-an-adf-document in 0.4.0, where 0.3.1 ran the getter, looped or threw. A document of a
class that keeps its fields behind getters (Vue 2, MobX) is refused too: hand in a snapshot,
JSON.parse(JSON.stringify(document)). A document holding more than 10,000,000 JSON values is
unsupported-node-shape.
portableMarkdownToAdf reads more of GFM's pipe table:
- A body row holding fewer cells than the header is padded with empty cells, where
0.3.1returnedmalformed-pipe-table. - A line after a table that holds no
|and opens no other block is a row of one cell, where it ended the table and read as a paragraph. Markdown that relied on that takes a blank line between the table and the paragraph. - Two lines that read as a paragraph in
0.3.1, a line ending in|over a line of-runs ending in|(a |over--- |), are a one-column table.losslessMarkdownToAdfreturnsmalformed-pipe-tablefor them too, as for any table without leading pipes. In either reader, write the first line asa \|to keep both as text.
losslessMarkdownToAdf reads the first two as before.
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 |
text inside !adf:border[…] (!adf:border[x]{color="#091e42" size=2}) |
the text holds border |
the text holds none, and where nothing the spelling wraps takes border, unsupported-node-shape: delete the spelling and keep its text. Only a mediaInline takes border |
0.3.0's adfToLosslessMarkdown writes a node holding a withheld mark as !adf:carry{json="…"}
(an inlineCard's link excepted, below), 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. 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:
npm install @larvit/adf-codec@0.3.0 adf-codec-0.2@npm:@larvit/adf-codec@0.2.0
That recipe cannot keep a link on an inlineCard: 0.3.0 refuses it as unsupported-node-shape,
the error's path naming the card, since Atlassian's schema gives a card no link mark. Remove the
link mark from that card before you write it with 0.3.0; to keep the link's target, put the
link on a text node beside the card.
0.3.0 refuses, as unsupported-node-shape, a known block holding text, a known non-text inline
node holding text or non-empty content, and a known block that takes no content, such as rule,
holding non-empty content. Atlassian's schema allows none of these. adfToPortableMarkdown wrote
such a node in 0.2.0, and adfToLosslessMarkdown wrote one inside a carried node. Both now refuse
it, and losslessMarkdownToAdf refuses stored markdown whose carry holds one.
Read such stored markdown with 0.2.0, then write the document with 0.3.0. Each refusal names one
wrong key, text or content, on the node the error's path names, and a call reports only the
first: delete that key and write again until the call succeeds.
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). 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:
npm install @larvit/adf-codec@0.2.0 adf-codec-0.1@npm:@larvit/adf-codec@0.1.0
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.0writing first, and record which documents are converted. - A refusal carrying
positionis0.1.0's parse, which refused that markdown before too. One without is0.2.0's emit: store the documentmarkdownToAdf010read as ADF, with thecontentthe recipe passes toadfToLosslessMarkdown, 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} |
[](/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 ., # ), 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 (), on parse |
unmappable-image |
the image, captioned with its title |
a link alone in its paragraph whose whole text is an image ([](/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 |
| 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 ( , , !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 |