diff --git a/AGENTS.md b/AGENTS.md index 09c0e64..f502030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb65f0..a3455ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md index 4578288..ada8887 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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: diff --git a/README.md b/README.md index 2b4530a..3bbda26 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index 7f22016..0d849a6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 `` 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. diff --git a/spec/flavour.md b/spec/flavour.md index 8877170..c8d9a42 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -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 diff --git a/src/adf/inline-nodes.ts b/src/adf/inline-nodes.ts index 61da92f..62241d2 100644 --- a/src/adf/inline-nodes.ts +++ b/src/adf/inline-nodes.ts @@ -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> = { - 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> = { 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 +} diff --git a/src/conformance/adf-schema.test.ts b/src/conformance/adf-schema.test.ts index 96fd7a3..e768cbc 100644 --- a/src/conformance/adf-schema.test.ts +++ b/src/conformance/adf-schema.test.ts @@ -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[] { diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 87800c2..069c580 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -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, 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 } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index eff0347..625c1c2 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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 ' 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 { } // 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] } }) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 2fe6994..fc52229 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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' }]) diff --git a/todo.md b/todo.md index 16fb9da..424ef24 100644 --- a/todo.md +++ b/todo.md @@ -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.