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

Read two lists a link reference definition parts as two lists, and put the changelog's breaking entries first

This commit is contained in:
2026-10-10 11:04:24 +02:00
committed by Lillem4n
parent f7df2f0f22
commit fa7f4ef987
6 changed files with 54 additions and 53 deletions
+30 -28
View File
@@ -2,13 +2,12 @@
## Unreleased
- `adfToLosslessMarkdown` and `adfToPortableMarkdown` write text holding a long run of `*`, `_`, `~`
or backticks, or a heading ending in a long run of `#`, in time linear in the run: a run of 20000
`*` took seconds before, and now takes milliseconds.
- **Breaking:** a list whose bullet character or ordered delimiter changes reads as two lists, as
CommonMark reads it: `- a` then `* b`, `1. a` then `1) b`. `adfToLosslessMarkdown` gives a list
right after one of its kind the other marker, `* ` or `1)`, in place of the `::listBreak`
separator. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** two lists a link reference definition parts read as two lists, as CommonMark
reads them, where they read as one; see `MIGRATION.md`.
- **Breaking:** each call names the markdown it reads or writes. `adfToMarkdown` and `markdownToAdf`
are now `adfToLosslessMarkdown` and `losslessMarkdownToAdf`. A bot's or a model's markdown reads
in `portableMarkdownToAdf`, and markdown known to be strict CommonMark in the new
@@ -21,10 +20,6 @@
converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own
keys. Build it as JSON holds it: `date.toISOString()`, `Object.fromEntries(map)`,
`Array.from(bytes)`, `{ ...instance }`.
- The README opens with what the package does and for whom, and `package.json` carries search
keywords.
- The source moves to [github.com/larvit/adf-codec](https://github.com/larvit/adf-codec), where
issues and pull requests go.
- **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: text holding an unescaped
@@ -35,12 +30,6 @@
- **Breaking:** the markdown readers read 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.
- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it:
two adjacent text nodes CommonMark would read back as one 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-character` and `unspellable-line-start` leave `ConvertErrorCode`, and
`adfToLosslessMarkdown` 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
@@ -48,8 +37,6 @@
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
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
@@ -57,14 +44,6 @@
`MIGRATION.md` lists each.
- **Breaking:** a link whose text holds another link keeps the inner link and leaves the outer
brackets literal text, where `0.1.0` split the outer link around it; see `MIGRATION.md`.
- Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from
markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and
pipe tables. A task item keeps its paragraphs, then its nested task lists; the rest of the item,
such as an image or a code block, stands after the task list.
- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
- Fix an image inside another image's description: it flattens into the alt text, where it was
refused.
- **Breaking:** `unmappable-image` leaves `ConvertErrorCode`, and every image reads. An image reads
as an image only alone in its paragraph, and its title becomes the caption. Anywhere else, after
a task marker too, it reads as its alt text linked to its URL; inside a link, as plain text the
@@ -73,20 +52,43 @@
`MIGRATION.md`.
- **Breaking:** an empty link text reads as its destination, `[](/url)` as `/url` linked, and
`[]()` as nothing, where both spellings stayed literal text. See `MIGRATION.md`.
- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`:
`losslessMarkdownToAdf` reads it as the key it spells, where on V8 13.6 and later (Node 24, Deno,
Chrome) `markdownToAdf` could refuse markdown `adfToMarkdown` wrote, as `unsupported-node-shape`,
once the process had read a key holding a backslash
([V8 bug](https://issues.chromium.org/issues/521080746)).
- **Breaking:** a hard break inside emphasis, strike, a link or a directive mark reads holding no
marks, as Atlassian's schema requires, where every reader gave it the marks around it. A link
whose text is only hard breaks reads as its destination linked, followed by those breaks, and
`losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as
`unsupported-node-shape`. `adfToLosslessMarkdown` writes a hard break holding marks as
`!adf:carry{json="…"}`. See `MIGRATION.md`.
- `adfToLosslessMarkdown` and `adfToPortableMarkdown` write text holding a long run of `*`, `_`, `~`
or backticks, or a heading ending in a long run of `#`, in time linear in the run: a run of 20000
`*` took seconds before, and now takes milliseconds.
- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it:
two adjacent text nodes CommonMark would read back as one 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.
- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it
read back as U+FFFD.
- Fix an image inside another image's description: it flattens into the alt text, where it was
refused.
- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`:
`losslessMarkdownToAdf` reads it as the key it spells, where on V8 13.6 and later (Node 24, Deno,
Chrome) `markdownToAdf` could refuse markdown `adfToMarkdown` wrote, as `unsupported-node-shape`,
once the process had read a key holding a backslash
([V8 bug](https://issues.chromium.org/issues/521080746)).
- Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and
`adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`,
where all three hung.
- The README opens with what the package does and for whom, and `package.json` carries search
keywords.
- The source moves to [github.com/larvit/adf-codec](https://github.com/larvit/adf-codec), where
issues and pull requests go.
- Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from
markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and
pipe tables. A task item keeps its paragraphs, then its nested task lists; the rest of the item,
such as an image or a code block, stands after the task list.
- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
## 0.1.0
+1
View File
@@ -77,6 +77,7 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
| 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 |
| two lists a link reference definition parts (`- a`, `[r]: /u`, `- b`, a blank line between each) | one list | two lists, as CommonMark reads them |
| 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 |
+6 -7
View File
@@ -239,7 +239,7 @@ Serves Goals 1, 4 and 5.
without a delimiter row, is an error result; each can be kept literal (`spec/flavour.md`).
- A document any reader built, written back with `adfToLosslessMarkdown`, takes the library's
canonical spelling, which round-trips byte-identically.
- Seven CommonMark spellings parse without an error and build a document the reference
- Six CommonMark spellings parse without an error and build a document the reference
implementation renders differently. Four because ADF holds no node for what it renders:
- An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()`
as nothing. A link text holding only hard breaks reads the same, followed by its hard breaks.
@@ -253,12 +253,11 @@ Serves Goals 1, 4 and 5.
- An image's title becomes its caption when the image stays an image, and its link's title when
it becomes linked alt text. Inside another link, the title is dropped.
And three more: a shortcut reference matching its definition only under Unicode case folding
stays unresolved, two lists a link reference definition parts stay one list against CommonMark's
two, and a link whose text holds an autolink keeps the inner link and leaves the outer brackets
literal text, which the spec requires and the reference itself breaks, nesting one `<a>` in the
other. `corpus/commonmark-spec/exceptions.json` pins the first four `node-model` where the suite
reaches them and the fifth `pending`; the suite holds no example of the last two.
And two more: a shortcut reference matching its definition only under Unicode case folding
stays unresolved, and a link whose text holds an autolink keeps the inner link and leaves the
outer brackets literal text, which the spec requires and the reference itself breaks, nesting one
`<a>` in the other. `corpus/commonmark-spec/exceptions.json` pins the first four `node-model`
where the suite reaches them and the fifth `pending`; the suite holds no example of the last.
- Not every document converts back: `adfToLosslessMarkdown` 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
+8 -2
View File
@@ -75,6 +75,8 @@ type ContainerStack = {
type Walk = ParsedBlocks & {
claims: Claims
// The block a paragraph of link reference definitions alone last closed after, which ends a list as any block does.
definedAfter: Block | undefined
leaf: OpenLeaf | undefined
leafOpeners: Map<Block[], Map<string, LeafOpener>>
position: SourcePosition
@@ -89,6 +91,7 @@ export function parseBlocks(markdown: string, claims: Claims): ParsedBlocks {
const walk: Walk = {
blocks: [],
claims,
definedAfter: undefined,
definitions: new Map(),
leaf: undefined,
leafOpeners: new Map(),
@@ -251,7 +254,7 @@ function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>
if (!start.fresh) return start.list
const blocks = currentBlocks(walk)
const previous = blocks.at(-1)
if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && continuesList(previous, start.list)) return previous
if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && previous !== walk.definedAfter && continuesList(previous, start.list)) return previous
blocks.push(start.list)
return start.list
}
@@ -514,7 +517,10 @@ function takeParagraph(walk: Walk): Extract<Block, { kind: 'paragraph' }> | unde
if (leaf?.kind !== 'paragraph') return undefined
walk.leaf = undefined
const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n'))
if (text === '') return undefined
if (text === '') {
walk.definedAfter = currentBlocks(walk).at(-1)
return undefined
}
const kept = leaf.positions[leaf.lines.length - text.split('\n').length]
return { kind: 'paragraph', position: kept ?? leaf.position, text }
}
+9 -1
View File
@@ -673,10 +673,18 @@ test('reads a bullet list, the marker width setting the continuation', () => {
assert.deepEqual(content(losslessMarkdownToAdf('- Code.\n')), [bulletList(item({ content: [text('Code.')], type: 'codeBlock' }))])
assert.deepEqual(content(losslessMarkdownToAdf('- a\n-\n\n- c\n')), [bulletList(item(paragraph('a')), item(), item(paragraph('c')))])
assert.deepEqual(content(losslessMarkdownToAdf('- a\n1. b\n')), [bulletList(item(paragraph('a'))), orderedList(1, item(paragraph('b')))])
assert.deepEqual(content(losslessMarkdownToAdf('- a\n\n[r]: /u\n\n- b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
assert.deepEqual(content(losslessMarkdownToAdf('-\n\n Part.\n')), [bulletList(item()), paragraph('Part.')])
})
test('reads two lists a link reference definition parts as two lists, in every reader', () => {
for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) {
assert.deepEqual(content(read('- a\n\n[r]: /u\n\n- b\n')), [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))])
assert.deepEqual(content(read('- a\n\n[r]: /u\n- b\n')), [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))])
assert.deepEqual(content(read('-\n\n[r]: /u\n\n- b\n')), [bulletList(item()), bulletList(item(paragraph('b')))])
assert.deepEqual(content(read('- a\n\n [r]: /u\n- b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
}
})
test('reads an ordered list, its first marker the order attribute', () => {
assert.deepEqual(content(losslessMarkdownToAdf('9. Bolt M8\n10. Nut M8\n')), [orderedList(9, item(paragraph('Bolt M8')), item(paragraph('Nut M8')))])
assert.deepEqual(content(losslessMarkdownToAdf('1) Loosen the clamp\n')), [orderedList(1, item(paragraph('Loosen the clamp')))])
-15
View File
@@ -25,8 +25,6 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|
| 94 | 0.2.0 | defect | **Read two lists a link reference definition parts as two lists, in every reader.** | 3 | 3 | 4 | 7 | 4, 5 | 13.6 |
| 97 | 0.2.0 | | **Put every Breaking entry first under `CHANGELOG.md`'s `## Unreleased`, then fixes and additions.** | 1 | 1 | 5 | 3 | 3 | 13.4 |
| 93 | 0.2.0 | | **Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.** | 2 | 3 | 3 | 5 | 1 | 13.2 |
| 86 | 0.2.0 | | **Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 |
| 85 | 0.2.0 | defect | **Write an external image a link marks as `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
@@ -71,19 +69,6 @@
## Details
### 94. Read two lists a link reference definition parts as two lists, in every reader.
`openedList` in `src/markdown/parse/blocks.ts` joins a fresh list to the list just before it when
no block stands between, so `- a\n\n[r]: /u\n\n- b` reads as one list; CommonMark reads two,
the definition closing the first. The join exists for an empty item a blank line ends, which
continues its list. `markdown-to-adf.test.ts` asserts the one list. Breaking: `MIGRATION.md`'s
Readings table gains its row, and the README's guarantees drop the exception and count one fewer.
### 97. Put every Breaking entry first under `CHANGELOG.md`'s `## Unreleased`, then fixes and additions.
The bullets run in the order the work landed, so an app developer upgrading meets a fix before the
breaking changes they must act on.
### 93. Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.
`bulletBlocks` fixes a block's path before `splitTaskList` moves it beside or after its task, so