From 278ebbbad513257e699ff89f82ac2fc2fb47579e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 14 Oct 2026 20:24:46 +0200 Subject: [PATCH] Give code text no mark but code, link and annotation, in every reader --- CHANGELOG.md | 4 ++++ MIGRATION.md | 1 + README.md | 7 +++++-- corpus/commonmark-spec/exceptions.json | 18 ++---------------- docs/decisions.md | 5 +++-- spec/flavour.md | 7 ++++--- src/adf/inline-nodes.ts | 11 ++++++++--- src/conformance/adf-schema.test.ts | 13 ++++++++++++- src/markdown/emit/adf-to-markdown.test.ts | 2 ++ src/markdown/emit/inline-line.ts | 4 ++-- src/markdown/parse/inline-content.ts | 2 +- src/markdown/parse/markdown-to-adf.test.ts | 12 ++++++++++++ .../portable/adf-to-portable-markdown.test.ts | 5 +++-- src/markdown/portable/inline-reduction.ts | 4 ++-- todo.md | 19 ++++++++++--------- 15 files changed, 71 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1fce10..db95d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ `unsupported-node-shape`, even in markdown `adfToLosslessMarkdown` wrote. A link around hard breaks alone still reads as before. `adfToLosslessMarkdown` writes a node holding a mark its type cannot take as `!adf:carry{json="…"}`. `MIGRATION.md` says how to convert stored markdown. +- **Breaking:** every reader gives code text no mark but `link` and `annotation`, as Atlassian's + schema requires: ``**`npm ci`**`` now reads as plain code, and a directive mark around only code + spans is `unsupported-node-shape`. `adfToLosslessMarkdown` writes code text holding another mark + as `!adf:carry{json="…"}`, and `adfToPortableMarkdown` drops that mark. - `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`. - The README compares the package with seven other ADF converters, each checked against its diff --git a/MIGRATION.md b/MIGRATION.md index 8f08402..48c20dc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -8,6 +8,7 @@ In `losslessMarkdownToAdf`, a mark spelling skips a node Atlassian's schema with | --- | --- | --- | | 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 only nodes that cannot take it, hard breaks under a link aside (`[!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`**``), in every reader | the code text holds the mark | the code text holds only `code` and the link | `0.2.0` wrote these spellings for a node holding such a mark; `0.3.0` writes the node as `!adf:carry{json="…"}`. `0.3.0` reads markdown stored from such a document without the mark, or diff --git a/README.md b/README.md index a235703..9ef3b5f 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,8 @@ saving what this pair read replaces mentions, attachments and macros with text. its key, `_(jira-issues-table not included)_`, or `_(extension not included)_` without one, and `_(synced block not included)_` for a `syncBlock`. - `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, - keeping its text, and so does a mark the flavour cannot spell where it stands. + keeping its text, and so does a mark the flavour cannot spell where it stands. Code text keeps + only `link`. - A newline in text is a hard break and in an expand's title a space, edge whitespace outside a link or code span is trimmed, carriage returns and null characters are removed, and an empty paragraph drops. @@ -484,7 +485,7 @@ Every call can raise these: - puts a line ending or a null character in an `emoji`, `mention` or `status` content slot: 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; + hard break, a mention or a code span; - writes a link whose text holds something besides hard breaks, but no node that takes a link, 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; @@ -535,6 +536,8 @@ Serves [Goals](#goals) 1, 4 and 5. alt text linked to its URL: `See ![the moon](moon.png).` reads as `See [the moon](moon.png).`. To keep an image an image, give it a paragraph of its own: a blank line between it and any other line. + - A code span inside emphasis, strikethrough or a directive mark reads as code alone, since ADF + gives code text no mark but a link: ``**`npm ci`**`` reads as `` `npm ci` ``. - Inside a link, an image reads as plain text the link marks. A link alone in its paragraph whose whole text is one image reads as that image, with the link marking it. - An image's title becomes its caption when the image stays an image, and its link's title when diff --git a/corpus/commonmark-spec/exceptions.json b/corpus/commonmark-spec/exceptions.json index 2f0e1bd..9c40e9d 100644 --- a/corpus/commonmark-spec/exceptions.json +++ b/corpus/commonmark-spec/exceptions.json @@ -258,20 +258,6 @@ "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, - { - "check": "count", - "divergence": "em 1/2", - "example": 478, - "kind": "mark-model", - "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." - }, - { - "check": "count", - "divergence": "em 1/2", - "example": 479, - "kind": "mark-model", - "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." - }, { "check": "text", "divergence": "\"\" against \"./target.md\"", @@ -288,7 +274,7 @@ }, { "check": "count", - "divergence": "a 1/5 em 1/4", + "divergence": "a 1/5 em 1/3", "example": 516, "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." @@ -302,7 +288,7 @@ }, { "check": "count", - "divergence": "a 1/5 em 1/4", + "divergence": "a 1/5 em 1/3", "example": 530, "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." diff --git a/docs/decisions.md b/docs/decisions.md index 7417f43..4deb0f4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -261,8 +261,9 @@ Their CommonMark suite exceptions are `node-model` (§The CommonMark suite check 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. -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 something besides hard breaks, +A mark spelling skips a node that takes none of its type, code text included, which takes only +`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 link whose text holds something besides hard breaks, but no node that takes a link, is `unsupported-node-shape` in `losslessMarkdownToAdf`: 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 diff --git a/spec/flavour.md b/spec/flavour.md index d0ff7e8..87ec7b8 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -549,9 +549,10 @@ 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 text node it wraps, and to every other inline node Atlassian's -schema gives that mark. 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 +A spelling adds its mark to every text node it wraps but one marked `code`, which takes only +`link`, and to every other inline node Atlassian's schema gives that mark: ``**`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]`, `!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 c988239..8320174 100644 --- a/src/adf/inline-nodes.ts +++ b/src/adf/inline-nodes.ts @@ -1,4 +1,8 @@ +import type { AdfNode } from './document.ts' import type { AttributeVocabulary } from './attribute-vocabulary.ts' +import { nodeMarks } from './document.ts' + +export const codeTextMarkTypes: readonly string[] = ['annotation', 'code', 'link'] export type InlineNodeModel = { attributes: AttributeVocabulary @@ -33,7 +37,8 @@ export function inlineNodeModel(type: string): InlineNodeModel | undefined { return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined } -// The table withholds nothing from a type it does not list, such as text. -export function takesMark(type: string, markType: string): boolean { - return inlineNodeModel(type)?.markTypes.includes(markType) ?? true +// The table withholds nothing from a type it does not list, such as text; Atlassian's schema withholds from code text all but `codeTextMarkTypes`. +export function takesMark(node: AdfNode, markType: string): boolean { + if (node.type === 'text' && nodeMarks(node).some((mark) => mark.type === 'code')) return codeTextMarkTypes.includes(markType) + return inlineNodeModel(node.type)?.markTypes.includes(markType) ?? true } diff --git a/src/conformance/adf-schema.test.ts b/src/conformance/adf-schema.test.ts index e768cbc..f6bd0bb 100644 --- a/src/conformance/adf-schema.test.ts +++ b/src/conformance/adf-schema.test.ts @@ -8,7 +8,7 @@ import test from 'node:test' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import { blockArgument } from '../markdown/block-directive.ts' import { blockNodes } from '../adf/block-nodes.ts' -import { inlineNodes } from '../adf/inline-nodes.ts' +import { codeTextMarkTypes, inlineNodes } from '../adf/inline-nodes.ts' import { markVocabularies } from '../adf/mark-attributes.ts' type Held = Map> @@ -81,6 +81,17 @@ test('the inline node table gives a type exactly the mark types the ADF JSON Sch for (const [type, model] of Object.entries(inlineNodes)) assert.deepEqual(model.markTypes, [...(marked.get(type) ?? [])].sort(), type) }) +test('code text takes exactly the mark types the ADF JSON Schemas give a code_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} code_inline_node` + const marks = alternatives(schemaObject(definitions['code_inline_node'], where), definitions, where).flatMap((properties) => properties.get('marks') ?? []) + // allOf narrows text_node's marks, which hold every mark type, to the ones code text takes. + const narrowed = marks.map((schema) => heldMarkTypes(schema, definitions, `${where}.marks`)).filter((types) => !types.includes('every mark type')) + assert.deepEqual(narrowed.flat().sort(), codeTextMarkTypes, file) + } +}) + function spelled(): Spelled[] { return [ ...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))), diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index b1a78fc..431c515 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -289,6 +289,8 @@ test('carries an inline node holding a mark Atlassian\'s schema withholds from i assert.equal(carried({ attrs: { id: 'b1c2', text: '@A' }, marks: [{ type: 'strong' }], type: 'mention' }), '!adf:carry{json="{\\"attrs\\":{\\"id\\":\\"b1c2\\",\\"text\\":\\"@A\\"},\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"type\\":\\"mention\\"}"}\n') assert.equal(carried({ attrs: { id: 'm1' }, marks: [{ type: 'em' }], type: 'mediaInline' }), '!adf:carry{json="{\\"attrs\\":{\\"id\\":\\"m1\\"},\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"mediaInline\\"}"}\n') 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') }) test('spells a heading level no ATX heading fits as a directive', () => { diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 069c580..705e957 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -223,10 +223,10 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath { // Decides the text-node, withheld-mark and slot-fault carries up front, so a line holding many of them is emitted once. function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean { - if (carried.has(index)) return true + if (carried.has(index) || nodeMarks(node).some((mark) => !takesMark(node, mark.type))) 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) => !takesMark(node.type, mark.type))) return true + if (model === undefined) 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 58a4a06..ab563ce 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -292,7 +292,7 @@ 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 marksItem(item: Inline, type: string): boolean { - return !isNode(item) || takesMark(item.type, type) + return !isNode(item) || takesMark(item, type) } function isNode(item: Inline): item is AdfNode { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 5940432..bc3a929 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -931,6 +931,18 @@ test('gives an inline node no mark Atlassian\'s schema withholds from it', () => 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') }) +test('gives code text no mark but code, link and annotation', () => { + const code: AdfMark = { type: 'code' } + for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) { + assert.deepEqual(content(read('**`npm ci`**\n')), [{ content: [marked('npm ci', code)], type: 'paragraph' }]) + assert.deepEqual(content(read('*run `npm ci`*\n')), [{ content: [marked('run ', em), marked('npm ci', code)], type: 'paragraph' }]) + assert.deepEqual(content(read('**[`npm ci`](/u)**\n')), [{ content: [marked('npm ci', link('/u'), code)], type: 'paragraph' }]) + } + assert.deepEqual(content(portableMarkdownToAdf('~~`npm ci`~~\n')), [{ content: [marked('npm ci', code)], type: 'paragraph' }]) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[run `npm ci`]\n')), [{ content: [marked('run ', underline), marked('npm ci', code)], type: 'paragraph' }]) + assert.equal(content(losslessMarkdownToAdf('!adf:underline[`npm ci`]\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') +}) + test('decodes the fenced info string the block walk leaves raw', () => { assert.deepEqual(content(losslessMarkdownToAdf('```java​script\nx\n```\n')), [ { attrs: { language: 'java\u200bscript' }, content: [text('x')], type: 'codeBlock' }, diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index f1a39b3..00a88be 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -307,10 +307,11 @@ test('keeps a table as a pipe table headed by its first row, one line per cell', assert.equal(portable(node('table', {}, row())), '') }) -test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => { +test('keeps code, em, link, strike and strong and drops every other mark, and code text every mark but link, keeping its text', () => { const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }] assert.equal(portable(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n') - assert.equal(portable(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n') + assert.equal(portable(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '`x` **y**_z_\n') + assert.equal(portable(paragraph(text('x', strong, link('/u'), code))), '[`x`](/u)\n') assert.equal(portable(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n') }) diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index d0b1b3a..ba73cf6 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -2,6 +2,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import type { MarkRun } from '../emit/line-escaping.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' +import { codeTextMarkTypes } from '../../adf/inline-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' @@ -141,9 +142,8 @@ function portableMarks(marks: readonly AdfMark[], container: LineContainer, text if (portable !== undefined) kept.push(portable) } const rank = (mark: AdfMark): number => (mark.type === highlight ? 0 : mark.type === 'code' ? 2 : 1) - // The reader highlights no code, as Atlassian's schema allows none. const code = kept.some((mark) => mark.type === 'code') - return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second)) + return kept.filter((mark) => !code || codeTextMarkTypes.includes(mark.type)).sort((first, second) => rank(first) - rank(second)) } export function portableLink(mark: AdfMark, container: LineContainer): AdfMark | undefined { diff --git a/todo.md b/todo.md index 6dc5ef7..1cfd82e 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 115` +`Next ID = 116` | Goal | W | |---|---| @@ -26,8 +26,8 @@ | ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---|---| | 73 | 0.3.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 | -| 114 | 0.3.0 | defect | **Build code-marked text holding no mark besides `link` and `annotation`, in every reader, and have `adfToLosslessMarkdown` carry code text holding any other mark.** | 3 | 4 | 5 | 5 | 7 | 4 | 15.4 | | 99 | 0.3.0 | 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 | +| 115 | 0.3.0 | defect | **Build text holding no `border` mark in every reader, and have `adfToLosslessMarkdown` carry text holding one.** | 3 | 3 | 3 | 4 | 6 | 4 | 12.3 | | 100 | 0.3.0 | 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.3.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 4 | 2 | 6 | 2 | 10.7 | | 59 | 0.3.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 3 | 2 | 6 | 2 | 10.4 | @@ -77,13 +77,6 @@ README table, its versions and its date in step with the output. We draft each p goes; the maintainer posts them once `0.3.0` is published. Found while planning discoverability, 2026-10-04. -### 114. Build code-marked text holding no mark besides `link` and `annotation`, in every reader, and have `adfToLosslessMarkdown` carry code text holding any other mark. - -``**`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`). It changes -how stored markdown reads, so it ships in a minor. - ### 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 @@ -92,6 +85,14 @@ there reads back as `[alt](href)`, breaking the byte-for-byte rewrite, so only o `href` can survive; a reader panel picks which. If it picks `url`, the README names the dropped `href` where it says link targets stay. +### 115. Build text holding no `border` mark in every reader, 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. `takesMark` (`src/adf/inline-nodes.ts`) +gives text every mark but code text's. It changes how stored markdown reads, so it ships in a minor. +Found while building item 114, 2026-10-14. + ### 100. 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. Every reader reads `![a](u "t")` as an image captioned `t` (`docs/decisions.md` §An image reads as an