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

Answer architecture round 1: 2 🔴, 4 🟡; refuse a link around only nodes that take none, mark the change breaking, file code-text marks

This commit is contained in:
2026-10-14 13:37:50 +02:00
committed by Lillem4n
parent d31efaba16
commit 6842f386ae
12 changed files with 96 additions and 45 deletions
+2 -1
View File
@@ -27,7 +27,8 @@ In `docs/decisions.md`:
- CommonMark is a subset
- Tables
- Links
- An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding no node that takes a link reads as its destination, then those nodes
- An image reads as an image only alone in its paragraph, else as its alt text linked; a link text of hard breaks alone reads as its destination, then the breaks
- A mark skips a node that cannot hold it, and a link wrapping only such nodes is refused
- A linked image is written `[![alt](url)](href)`, a captioned image as its directive
- Ids stay site-local
- Portable task ids come from position
+6 -4
View File
@@ -4,10 +4,12 @@
- `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`.
- `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status` or
`mediaInline` no mark Atlassian's schema withholds from it: `**!adf:mention[@A]{id=…}**` reads as
a plain mention, and a link around only such nodes reads as its destination, then the nodes.
`adfToLosslessMarkdown` writes a node holding such a mark as `!adf:carry`, so it still round-trips.
- **Breaking:** a mark spelling skips a `date`, `emoji`, `inlineCard`, `mention`, `status` or
`mediaInline` Atlassian's schema withholds the mark from, where `losslessMarkdownToAdf` gave it
the marks around it: `**!adf:mention[@A]{id=…}**` reads as a plain mention. A link around only
such nodes, or a directive mark around nodes none of which takes it, is `unsupported-node-shape`.
`adfToLosslessMarkdown` writes a node holding such a mark as `!adf:carry{json="…"}`. See
`MIGRATION.md`.
## 0.2.0
+13
View File
@@ -1,5 +1,18 @@
# Migrating
## From `0.2.0` to `0.3.0`
A mark spelling skips a 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 or directive mark around such nodes alone (`[!adf:status[Done]{color=green}](/u)`) | the nodes hold the mark | `unsupported-node-shape`; write the mark around text beside them |
`0.2.0` wrote the first row's spelling for a node holding such a mark, which `0.3.0` writes as
`!adf:carry{json="…"}`. Markdown stored from such a document reads back without the mark: to keep
it, read the markdown with `0.2.0` and write it with `0.3.0`, installed side by side.
## From `0.1.0` to `0.2.0`
Each call names the markdown it reads or writes:
+3 -2
View File
@@ -463,6 +463,8 @@ Every call can raise these:
replace a line ending with a space, or, for either, write the node as `!adf:carry`;
- wraps a directive mark around nothing, or only around nodes that take no such mark, such as a
hard break or a mention;
- writes a link around nodes none of which takes a link, more than hard breaks alone, such as
`[!adf:status[Done]{color=green}](/u)`: write the link around text beside them;
- writes as a directive a node or mark the lossless flavour spells as CommonMark;
- puts `!adf:textBreak{}` where it parts nothing, or `!adf:doc` anywhere but as the whole
document.
@@ -505,8 +507,7 @@ Serves [Goals](#goals) 1, 4 and 5.
- These CommonMark spellings parse without an error but build a document the reference
implementation renders differently, because ADF holds no node for what the reference renders:
- An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()`
as nothing. A link text holding no node that takes a link, such as a hard break or a mention,
reads the same, followed by those nodes.
as nothing. A link text holding only hard breaks reads the same, followed by its hard breaks.
- An image reads as an image only alone in its paragraph. Anywhere else — beside other text, in
a heading or a table cell, or in the paragraph a task marker opens — it reads as its
alt text linked to its URL: `See ![the moon](moon.png).` reads as
+20 -8
View File
@@ -228,20 +228,18 @@ link reference definition — and a directive link CommonMark could spell is ref
link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
where its reference implementation nests one `<a>` in another.
## An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding no node that takes a link reads as its destination, then those nodes
## An image reads as an image only alone in its paragraph, else as its alt text linked; a link text of hard breaks alone reads as its destination, then the breaks
2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, by Goals 4 and 7, as the
maintainer's plan item asked; a link text of hard breaks alone 2026-10-09, the maintainer. Goals 4,
5, 6 and 7. Valid while `mediaInline` carries no URL, ADF holds no empty text node, Atlassian's
schema withholds the link from some inline nodes and no consumer has reported a link of such nodes
alone.
5, 6 and 7. Valid while `mediaInline` carries no URL, ADF holds no empty text node, a hard break
takes no marks and no consumer has reported a link of hard breaks alone.
Every reader reads every CommonMark image and empty link, as `spec/flavour.md` §The CommonMark image
and §Marks spell out. An image alone in its paragraph reads as the image, its title as the caption.
Anywhere else it reads as its alt text linked to its URL, titled by its title. An empty alt text
reads as the URL, and an empty link text as its destination. A link text holding no node that takes
a link, such as a hard break or a mention, reads as its destination, then those nodes: four of seven
readers expected that of hard breaks over dropping them.
reads as the URL, and an empty link text as its destination. A link text holding only hard breaks
reads as its destination, then those breaks: four of seven readers expected that over dropping them.
`[]()` reads as nothing: it renders nothing, so Goal 7 loses no content. A link alone in its
paragraph whose whole text is one image reads as that image, with the link marking its `media`.
Inside any other link, the alt text is plain text the link marks, and the image's URL and title
@@ -258,6 +256,19 @@ The same panel confirmed the other readings on 2026-10-09: a title as the captio
text as linked alt text, and an empty link text as its URL, each 3 of 3; `[]()` as nothing, 5 of 7.
Their CommonMark suite exceptions are `node-model` (§The CommonMark suite checks three ways).
## A mark skips a node that cannot hold it, and a link wrapping only such nodes is refused
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.
`src/adf/inline-nodes.ts` holds the mark types Atlassian's schema gives each inline node. A mark
spelling skips a node that takes none of its type, so `**Ping !adf:mention[@A]{id=x} now**` reads
as bold text around a plain mention. A link whose text holds no node that takes a link, and more
than hard breaks, is `unsupported-node-shape` in `losslessMarkdownToAdf`: the panel refused to have
`[!adf:status[Done]{color=green}](/u)` read as `/u` linked beside the status. Hard breaks alone keep
§An image reads as an image only alone in its paragraph's reading. `adfToLosslessMarkdown` writes a
node holding a mark its type does not take as `!adf:carry`, so the round-trip holds.
## A linked image is written `[![alt](url)](href)`, a captioned image as its directive
2026-10-11, a writer panel of a product manager, an engineer and a support agent, 3 of 3 on each.
@@ -690,7 +701,8 @@ has no fixture.
holds.
For every node and mark the tables spell, the attribute names and kinds equal what `full.json` and
`stage-0.json` (§Standards ship as data) hold between them. Value sets stay documentation, since
`stage-0.json` (§Standards ship as data) hold between them, and so do the mark types each inline
node takes. Value sets stay documentation, since
any value round-trips. What the schema holds and the tables do not spell is pinned by name — an
attribute as a gap, a type as carried — so a re-pin adding either goes red until someone spells it
or pins it.
+6 -5
View File
@@ -530,8 +530,9 @@ CommonMark could spell is a named error, and so is one spelling none. No link wr
nesting, which is CommonMark's own rule: a `[text]` already holding one leaves the outer brackets
literal text, and the directive form, open to no literal reading, is a named error. ADF holds no
empty text node, so a link whose text is empty reads as its destination linked, `[](/url)` as
`/url`, and one whose destination is empty too, `[]()`, as nothing. A link text holding no node
that takes a link reads the same, followed by its nodes.
`/url`, and one whose destination is empty too, `[]()`, as nothing. A link text holding only hard
breaks reads the same, followed by its hard breaks; one holding other nodes, none of which takes a
link, is a named error.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
- `code`, `em`, `strike`, `strong` — Attributes: none.
@@ -548,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 inline node it wraps but one Atlassian's schema withholds the mark
from: `hardBreak`, `date`, `emoji`, `inlineCard`, `mention` and `status` take none a spelling makes,
and `mediaInline` only `border` and `link`. Nesting is the marks array in order, outermost first:
A spelling adds its mark to every text node it wraps, and to every other inline node Atlassian's
schema gives that mark: `hardBreak`, `date`, `emoji`, `inlineCard`, `mention` and `status` take none
a spelling makes, and `mediaInline` 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
the array, not a set — and opens each spelling once over the longest run of adjacent inline nodes
+13 -8
View File
@@ -2,15 +2,15 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export type InlineNodeModel = {
attributes: AttributeVocabulary
marks: readonly string[]
markTypes: readonly string[]
textAttribute?: string
}
export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
date: { attributes: { localId: 'string', timestamp: 'string' }, marks: ['annotation'] },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, marks: ['annotation'], textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' }, marks: [] },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, marks: ['annotation'] },
date: { attributes: { localId: 'string', timestamp: 'string' }, markTypes: ['annotation'] },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, markTypes: ['annotation'], textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' }, markTypes: [] },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, markTypes: ['annotation'] },
mediaInline: {
attributes: {
alt: 'string',
@@ -23,12 +23,17 @@ export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
type: 'string',
width: 'number',
},
marks: ['annotation', 'border', 'dataConsumer', 'link'],
markTypes: ['annotation', 'border', 'dataConsumer', 'link'],
},
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, marks: ['annotation'], textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, marks: ['annotation'], textAttribute: 'text' },
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, markTypes: ['annotation'], textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, markTypes: ['annotation'], textAttribute: 'text' },
}
export function inlineNodeModel(type: string): InlineNodeModel | undefined {
return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined
}
// An unknown type is no inline leaf the table holds, so the table withholds nothing from it.
export function takesMark(type: string, markType: string): boolean {
return inlineNodeModel(type)?.markTypes.includes(markType) ?? true
}
+1 -1
View File
@@ -78,7 +78,7 @@ test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned c
test('the inline node table gives a type exactly the mark types the ADF JSON Schemas give it, in order', () => {
const { marked } = schemaTypes()
for (const [type, model] of Object.entries(inlineNodes)) assert.deepEqual(model.marks, [...(marked.get(type) ?? [])].sort(), type)
for (const [type, model] of Object.entries(inlineNodes)) assert.deepEqual(model.markTypes, [...(marked.get(type) ?? [])].sort(), type)
})
function spelled(): Spelled[] {
+2 -2
View File
@@ -11,7 +11,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts'
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
@@ -226,7 +226,7 @@ function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): bo
if (carried.has(index)) return true
if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === ''
const model = inlineNodeModel(node.type)
if (model === undefined || nodeMarks(node).some((mark) => !model.marks.includes(mark.type))) return true
if (model === undefined || nodeMarks(node).some((mark) => !takesMark(node.type, mark.type))) return true
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
}
+13 -8
View File
@@ -11,7 +11,7 @@ import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../portable/conventions.ts'
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
import { matchEmphasis } from '../commonmark/emphasis-matching.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
@@ -84,6 +84,7 @@ const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty,
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const hreflessLink = 'the link mark spells its href: this one spells none'
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const unlinkable = 'the text of this link holds only nodes that take no link: write the link around text beside them'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
const textBreak: TextBreak = { kind: 'textBreak' }
@@ -210,7 +211,7 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
}
if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
if (!slot.nodes.some((node) => takesMark(node, mark.type))) {
if (!slot.nodes.some((node) => marksItem(node, mark.type))) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark`, scan.path)
}
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
@@ -266,9 +267,9 @@ function mediaSingle({ alt, definition, link }: Image): AdfNode {
return centeredImage([externalMedia(alt, definition.destination, link), ...caption])
}
// ADF holds no empty text node and some nodes take no link, so the destination text carries the link ahead of the text's nodes; an empty destination leaves them alone.
// ADF holds no empty text node and a hard break takes no link, so the destination text carries the link ahead of the text's hard breaks; an empty destination leaves the breaks alone.
function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] {
if (nodes.some((node) => takesMark(node, 'link'))) return applyMark(nodes, linkMark(definition))
if (nodes.some((node) => marksItem(node, 'link'))) return applyMark(nodes, linkMark(definition))
return definition.destination === '' ? [...nodes] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }, ...nodes]
}
@@ -290,8 +291,8 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
}
// spec/flavour.md, Marks: a spelling skips a node Atlassian's schema withholds its mark from.
function takesMark(item: Inline, type: string): boolean {
return !isNode(item) || (inlineNodeModel(item.type)?.marks.includes(type) ?? true)
function marksItem(item: Inline, type: string): boolean {
return !isNode(item) || takesMark(item.type, type)
}
function isNode(item: Inline): item is AdfNode {
@@ -400,7 +401,11 @@ function linkPiece(scan: Scan, inner: readonly Piece[], definition: LinkDefiniti
const [only] = inner
if (inner.length === 1 && only?.kind === 'image') return success({ ...only, link: definition })
const nodes = resolveNodes(inner, scan, true)
return nodes.ok ? success({ kind: 'nodes', nodes: linkTo(nodes.value, definition) }) : nodes
if (!nodes.ok) return nodes
if (!nodes.value.some((node) => marksItem(node, 'link')) && nodes.value.some((node) => isNode(node) && node.type !== 'hardBreak')) {
return failure('unsupported-node-shape', unlinkable, scan.path)
}
return success({ kind: 'nodes', nodes: linkTo(nodes.value, definition) })
}
// CommonMark: no link nests inside another, though an image's description holds one.
@@ -591,7 +596,7 @@ function markType(character: string, used: number): string {
// `*(*a*)*` builds one `em`: a node holds at most one mark of each type.
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => {
if (!isNode(node) || !takesMark(node, mark.type)) return node
if (!isNode(node) || !marksItem(node, mark.type)) return node
const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
})
+3 -2
View File
@@ -911,7 +911,6 @@ test('gives the hard break inside a mark spelling no marks, and reads a link of
test('gives an inline node no mark Atlassian\'s schema withholds from it', () => {
const mention: AdfNode = { attrs: { id: 'b1c2', text: '@A' }, type: 'mention' }
const status: AdfNode = { attrs: { color: 'green', text: 'Done' }, type: 'status' }
const media: AdfNode = { attrs: { id: 'm1' }, type: 'mediaInline' }
const border: AdfMark = { attrs: { color: '#091e42', size: 2 }, type: 'border' }
const read = (markdown: string): AdfNode[] | string => content(losslessMarkdownToAdf(markdown))
@@ -920,7 +919,9 @@ test('gives an inline node no mark Atlassian\'s schema withholds from it', () =>
assert.deepEqual(read('!adf:underline[a !adf:emoji[🎉]{shortName=":tada:"}]\n'), [
{ content: [marked('a ', underline), { attrs: { shortName: ':tada:', text: '🎉' }, type: 'emoji' }], type: 'paragraph' },
])
assert.deepEqual(read('[!adf:status[Done]{color=green}](/u)\n'), [{ content: [marked('/u', link('/u')), status], type: 'paragraph' }])
const unlinkable = 'unsupported-node-shape: the text of this link holds only nodes that take no link: write the link around text beside them'
assert.equal(read('[!adf:status[Done]{color=green}](/u)\n'), unlinkable)
assert.equal(read('[!adf:status[Done]{color=green}\\\n](/u)\n'), unlinkable)
assert.deepEqual(read('[a !adf:inlineCard{url="https://x/"}](/u)\n'), [{ content: [marked('a ', link('/u')), { attrs: { url: 'https://x/' }, type: 'inlineCard' }], type: 'paragraph' }])
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' }])
+14 -4
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 114`
`Next ID = 115`
| Goal | W |
|---|---|
@@ -25,6 +25,7 @@
| ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|---|
| 114 | 0.2.1 | defect | **Build text marked `code` holding no mark but `link` and `annotation`, in every reader, and carry any other in `adfToLosslessMarkdown`.** | 3 | 4 | 5 | 5 | 7 | 4 | 15.4 |
| 99 | 0.2.1 | 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 |
| 100 | 0.2.1 | 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.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 4 | 2 | 6 | 2 | 10.7 |
@@ -51,7 +52,7 @@
| 107 | 0.3.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.3.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 4 | 5 | 2 | 7 | 4, 5 | 10.4 |
| 95 | 0.3.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 |
| 91 | 0.3.0 | | **Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers.** | 5 | 5 | 8 | 5 | 6 | 6 | 9.0 |
| 91 | 0.3.0 | | **Spell an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions, statuses and inline media between two nodes it marks, in both writers.** | 5 | 5 | 8 | 5 | 6 | 6 | 9.0 |
| 73 | 0.4.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 |
| 9 | 0.4.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 4 | 6 | 8 | 3 | 21.0 |
| 38 | 0.4.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 5 | 4 | 7 | 1 | 19.5 |
@@ -68,6 +69,13 @@
## Details
### 114. Build text marked `code` holding no mark but `link` and `annotation`, in every reader, and carry any other in `adfToLosslessMarkdown`.
`**`npm ci`**` builds text holding `[strong, code]`; `code_inline_node` (`spec/adf-schema/full.json`)
gives code text `code`, `link` and `annotation` alone, so a site may reject the document. The `==`
highlight already skips code (`highlighted`, `src/markdown/parse/inline-content.ts`). Moves the
reading of stored markdown, so it ships where semver allows a breaking change.
### 99. Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.
`mediaLeaves` (`src/markdown/portable/inline-reduction.ts`) writes such an image `[alt](url)`, dropping
@@ -290,11 +298,13 @@ text. Only the property tests catch drift between these and the reader, and it h
under the comprehension floor. Reading the line back costs one parse each time a line is tried. The
run it carries may differ from today's, which changes lossless output (§The formats are API).
### 91. Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers.
### 91. Spell an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions, statuses and inline media between two nodes it marks, 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. Joining the run across the break in `inline-line.ts` ruled worse
typed across a line as two links. No reader gives those other nodes a mark Atlassian's schema
withholds from them, so `**Ping !adf:mention[@A]{id=x} now**` writes back with each strong run's
inner space 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.