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

Build text holding no border mark in losslessMarkdownToAdf, and carry text holding one

This commit is contained in:
2026-10-31 02:40:51 +01:00
parent de0033c083
commit 7ce296c455
13 changed files with 47 additions and 28 deletions
+4
View File
@@ -29,6 +29,10 @@
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.
- **Breaking:** `losslessMarkdownToAdf` gives text no `border` mark, as Atlassian's schema requires:
`!adf:border[x]{color="#091e42" size=2}` is `unsupported-node-shape`, and `border` marks a
`mediaInline` alone. `adfToLosslessMarkdown` writes text holding `border` as `!adf:carry{json="…"}`.
`MIGRATION.md` says how to convert stored markdown.
- Every reader keeps the URL and title of an image inside a link's text, where it dropped them:
after the link comes `(image)`, which links to the image, so
`[look at ![the moon](moon.png "Full") tonight](/moon)` reads as
+2 -1
View File
@@ -3,13 +3,14 @@
## 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:
also skips every other node Atlassian's schema withholds the mark from, text included for `border`:
| 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` | `unsupported-node-shape` where the mark wraps only text, else the text holds no `border`; `border` marks a `mediaInline` alone |
`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
+2 -1
View File
@@ -411,7 +411,8 @@ Ping !adf:mention[@Mikael]{id=5b10a2} — !adf:status[In review]{color=yellow}
| ADF | Directive |
| --- | --- |
| `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`, `orderedList`, `paragraph`, `rule` carrying `localId`, marks or a value CommonMark cannot spell | the same node in directive form, `!adf:paragraph {localId=…}` |
| `border`, `subsup`, `textColor`, `underline` | inline directive marks around their text, `!adf:textColor[**Overdue**]{color="#ae2e24"}` |
| `border` | an inline directive mark around a `mediaInline`, the one inline node that takes it |
| `subsup`, `textColor`, `underline` | inline directive marks around their text, `!adf:textColor[**Overdue**]{color="#ae2e24"}` |
| `caption`, `media`, `mediaGroup`, `mediaSingle` | the media family; an image CommonMark cannot spell, a stored file, a caption |
| `date`, `emoji`, `inlineCard`, `mediaInline`, `mention`, `status` | inline leaves; `emoji`, `mention` and `status` spell their `text` in the `[content]` slot |
| `decisionList`, `decisionItem` | containers |
@@ -56,6 +56,9 @@
"type": "inlineCard"
},
{
"attrs": {
"id": "m1"
},
"marks": [
{
"attrs": {
@@ -65,8 +68,7 @@
"type": "border"
}
],
"text": "x",
"type": "text"
"type": "mediaInline"
}
],
"type": "paragraph"
@@ -10,7 +10,7 @@ Title
!adf:extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":-0}"}
!adf:inlineCard{data="[-0]" url="https://example.com/"}!adf:border[x]{color="#000000" size=-0}
!adf:inlineCard{data="[-0]" url="https://example.com/"}!adf:border[!adf:mediaInline{id=m1}]{color="#000000" size=-0}
```adf:blockCard
{
+7 -5
View File
@@ -284,13 +284,15 @@ content (`README.md` `## Goals`, Goal 7).
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. Extended
to code text under this rule, without a panel, 2026-10-14; retitled by the maintainer the same day,
keeping the directive-mark refusal the old title held.
keeping the directive-mark refusal the old title held. Extended to `border` on text, which the
schema withholds from formatted text, 2026-10-31, without a panel.
A mark spelling skips a node that takes none of its type, code text included, which takes only
`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 directive mark wrapping only nodes that take
none of its type is `unsupported-node-shape` in `losslessMarkdownToAdf`. So is a link whose text
holds something besides hard breaks but no node that takes a link: the panel refused to have
`code`, `link` and `annotation`, and text of any kind, which takes no `border`. So
`**Ping !adf:mention[@A]{id=x} now**` reads as bold text around a plain mention, and
``**`npm ci`**`` as plain code. A directive mark wrapping only nodes that take none of its type
is `unsupported-node-shape` in `losslessMarkdownToAdf`. So is a link whose text holds something
besides hard breaks but no node that takes a link: the panel refused to have
`[!adf:status[Done]{color=green}](/u)` read as `/u` linked beside the status. A link text of hard
breaks alone still reads as its destination, then the breaks (§An image reads as an image only alone
in its paragraph). `adfToLosslessMarkdown` writes a node holding a mark its type does not take as
+1 -1
View File
@@ -556,7 +556,7 @@ A block's `marks` key also holds these, which no inline spelling wraps:
- `indentation` — Attributes: `level` (number, 1–6).
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 no other spelled mark but `link`:
takes every mark but `border`, and text marked `code` takes no other spelled mark but `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]`,
+3 -2
View File
@@ -3,6 +3,7 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts'
import { nodeMarks } from './document.ts'
export const codeTextMarkTypes: readonly string[] = ['annotation', 'code', 'link']
export const formattedTextMarkTypes: readonly string[] = ['annotation', 'backgroundColor', 'em', 'link', 'strike', 'strong', 'subsup', 'textColor', 'underline']
export type InlineNodeModel = {
attributes: AttributeVocabulary
@@ -37,9 +38,9 @@ export function inlineNodeModel(type: string): InlineNodeModel | undefined {
return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined
}
// Undefined means every mark: the table lists only types the schema restricts, and it restricts text only when marked code.
// Undefined means every mark: the table lists only types the schema restricts. Text is code text, or formatted text, which withholds `border`.
export function takenMarkTypes(node: AdfNode): readonly string[] | undefined {
if (node.type === 'text') return nodeMarks(node).some((mark) => mark.type === 'code') ? codeTextMarkTypes : undefined
if (node.type === 'text') return nodeMarks(node).some((mark) => mark.type === 'code') ? codeTextMarkTypes : formattedTextMarkTypes
return inlineNodeModel(node.type)?.markTypes
}
+11 -1
View File
@@ -8,7 +8,7 @@ import test from 'node:test'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import { blockArgument } from '../markdown/directive/block.ts'
import { blockNodes } from '../adf/block-nodes.ts'
import { codeTextMarkTypes, inlineNodes } from '../adf/inline-nodes.ts'
import { codeTextMarkTypes, formattedTextMarkTypes, inlineNodes } from '../adf/inline-nodes.ts'
import { markVocabularies } from '../adf/mark-attributes.ts'
type Held = Map<string, Set<AttributeKind>>
@@ -92,6 +92,16 @@ test('code text takes exactly the mark types the ADF JSON Schemas give a code_in
}
})
test('formatted text takes exactly the mark types the ADF JSON Schemas give a formatted_text_inline_node', () => {
for (const file of schemaFiles) {
const definitions = schemaObject(schemaObject(JSON.parse(readFileSync(join(root, file), 'utf8')), file)['definitions'], `${file} definitions`)
const where = `${file} formatted_text_inline_node`
const marks = alternatives(schemaObject(definitions['formatted_text_inline_node'], where), definitions, where).flatMap((properties) => properties.get('marks') ?? [])
const narrowed = marks.map((schema) => heldMarkTypes(schema, definitions, `${where}.marks`)).filter((types) => !types.includes('every mark type'))
assert.deepEqual(narrowed.flat().sort(), formattedTextMarkTypes, file)
}
})
function spelled(): Spelled[] {
return [
...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))),
+4 -1
View File
@@ -335,6 +335,10 @@ test('carries an inline node holding a mark Atlassian\'s schema withholds from i
assert.equal(carried({ attrs: { id: 'm1' }, marks: [{ attrs: { href: '/u' }, type: 'link' }], type: 'mediaInline' }), '[!adf:mediaInline{id=m1}](/u)\n')
assert.equal(carried({ marks: [{ type: 'strong' }, { type: 'code' }], text: 'x', type: 'text' }), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strong\\"},{\\"type\\":\\"code\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n')
assert.equal(carried({ marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: 'x', type: 'text' }), '[`x`](/u)\n')
const border: AdfMark = { attrs: { color: '#091e42', size: 2 }, type: 'border' }
const bordered = '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"color\\":\\"#091e42\\",\\"size\\":2},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}'
assert.equal(carried({ marks: [border], text: 'x', type: 'text' }), `${bordered}\n`)
assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [border], text: 'x', type: 'text' })))), `_a_${bordered}\n`)
})
test('spells a heading level no ATX heading fits as a directive', () => {
@@ -888,7 +892,6 @@ test('spells the directive marks around the longest run they cover', () => {
assert.equal(emitted(marked('a', { attrs: {}, type: 'underline' }), marked('b', underline)), '!adf:underline[a]{attrs=empty}!adf:underline[b]\n')
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n')
assert.equal(emitted(marked('x', { type: 'em' }, underline)), '_!adf:underline[x]_\n')
assert.equal(emitted(marked('x', underline, { type: 'em' })), '!adf:underline[_x_]\n')
assert.equal(emitted(marked('a', underline), { type: 'hardBreak' }, marked('b', underline)), '!adf:underline[a]\\\n!adf:underline[b]\n')
+5 -1
View File
@@ -926,6 +926,11 @@ test('gives an inline node no mark Atlassian\'s schema withholds from it', () =>
assert.deepEqual(read('_!adf:mediaInline{id=m1}_\n'), [{ content: [media], type: 'paragraph' }])
assert.deepEqual(read('!adf:border[!adf:mediaInline{id=m1}]{color="#091e42" size=2}\n'), [{ content: [{ ...media, marks: [border] }], type: 'paragraph' }])
assert.deepEqual(read('[!adf:mediaInline{id=m1}](/u)\n'), [{ content: [{ ...media, marks: [link('/u')] }], type: 'paragraph' }])
assert.deepEqual(read('!adf:border[a !adf:mediaInline{id=m1}]{color="#091e42" size=2}\n'), [{ content: [{ text: 'a ', type: 'text' }, { ...media, marks: [border] }], type: 'paragraph' }])
const unborderable = 'unsupported-node-shape: the border mark wraps the [content] it marks: this one wraps only nodes that take no border mark: write the border mark around text beside them'
assert.equal(read('!adf:border[a]{color="#091e42" size=2}\n'), unborderable)
assert.equal(read('_!adf:border[a]{color="#091e42" size=2}_\n'), unborderable)
assert.equal(read('!adf:underline[!adf:border[a]{color="#091e42" size=2}]\n'), unborderable)
assert.equal(read('!adf:underline[!adf:mention[@A]{id=b1c2}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them')
assert.equal(read('!adf:underline[!adf:mediaInline{id=m1}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them')
assert.equal(read('!adf:link[!adf:status[Done]{color=green}]{collection=c href="/u"}\n'), 'unsupported-node-shape: the link mark wraps the [content] it marks: this one wraps only nodes that take no link mark: write the link mark around text beside them')
@@ -1289,7 +1294,6 @@ test('reads the directive marks, the nesting outermost first', () => {
assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[!adf:underline[a]]\n')), wrapped(underline))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:textColor[a]{color="#ae2e24"}\n')), wrapped({ attrs: { color: '#ae2e24' }, type: 'textColor' }))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:subsup[a]{type=sub}\n')), wrapped({ attrs: { type: 'sub' }, type: 'subsup' }))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:border[a]{color="#091e42" size=2}\n')), wrapped({ attrs: { color: '#091e42', size: 2 }, type: 'border' }))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[a!adf:date{timestamp=1}]\n')), [
{ content: [marked('a', underline), { attrs: { timestamp: '1' }, type: 'date' }], type: 'paragraph' },
])
@@ -416,7 +416,7 @@ test('gives up every highlight run whose own delimiter cannot flank in one fallb
})
test('refuses a portable line holding a node only the carry spells, at the line', () => {
const refused = portableLineFallback([text('x', { type: 'odd' })], 'paragraph', ['content', 0])
const refused = portableLineFallback([text('x', { attrs: { a: 1 }, type: 'em' })], 'paragraph', ['content', 0])
assert.deepEqual(refused.ok ? refused : [refused.error.code, refused.error.path], ['unsupported-node-shape', ['content', 0]])
})
+2 -11
View File
@@ -12,12 +12,11 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 |
| 91 | 0.3.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.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
| 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
| 132 | 0.3.0 | | | **Write tables as `<table>` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 |
| 73 | 0.3.0 | 91, 115, 132, 133 | | **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 | 2 | 3 | 2 | 3 by 8 | 16.9 |
| 72 | 0.3.0 | 91, 115, 132, 133 | 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.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
| 73 | 0.3.0 | 91, 132, 133 | | **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 | 2 | 3 | 2 | 3 by 8 | 16.9 |
| 72 | 0.3.0 | 91, 132, 133 | 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.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
| 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 |
| 156 | 0.4.0 | | | **Stop a leg's container when its script is interrupted, so Ctrl-C ends `ci.sh` or `mutation-tests/run.sh` at once.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 20.9 |
| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
@@ -68,14 +67,6 @@
## Details
### 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. `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.
### 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.
Reading each line back lets the writers try a joined spelling and keep it where it reads back. No