From 19f9a58d5d6f2ac871aec22f117bffa89eaefe98 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 26 Aug 2026 22:47:13 +0200 Subject: [PATCH] Corpus 2e2: the mark runs, the run a carry breaks, and one mark vocabulary --- .../combinations/mark-run-carry.json | 50 +++++++ .../round-trip/combinations/mark-run-carry.md | 1 + corpus/round-trip/combinations/mark-run.json | 129 ++++++++++++++++++ corpus/round-trip/combinations/mark-run.md | 5 + corpus/unspellable/mark-inside-word.error | 1 + corpus/unspellable/mark-inside-word.json | 28 ++++ spec/flavour.md | 7 +- src/adf-to-markdown.test.ts | 1 + src/inline-directives.ts | 23 +++- src/markdown-inline.ts | 27 ++-- 10 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 corpus/round-trip/combinations/mark-run-carry.json create mode 100644 corpus/round-trip/combinations/mark-run-carry.md create mode 100644 corpus/round-trip/combinations/mark-run.json create mode 100644 corpus/round-trip/combinations/mark-run.md create mode 100644 corpus/unspellable/mark-inside-word.error create mode 100644 corpus/unspellable/mark-inside-word.json diff --git a/corpus/round-trip/combinations/mark-run-carry.json b/corpus/round-trip/combinations/mark-run-carry.json new file mode 100644 index 0000000..a7696d1 --- /dev/null +++ b/corpus/round-trip/combinations/mark-run-carry.json @@ -0,0 +1,50 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "type": "em" + }, + { + "type": "strong" + } + ], + "text": "Ready", + "type": "text" + }, + { + "attrs": { + "extensionKey": "status-lozenge", + "extensionType": "com.atlassian.confluence.macro.core" + }, + "marks": [ + { + "type": "em" + }, + { + "type": "strong" + } + ], + "type": "inlineExtension" + }, + { + "marks": [ + { + "type": "em" + }, + { + "type": "strong" + } + ], + "text": "to ship", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/mark-run-carry.md b/corpus/round-trip/combinations/mark-run-carry.md new file mode 100644 index 0000000..9114578 --- /dev/null +++ b/corpus/round-trip/combinations/mark-run-carry.md @@ -0,0 +1 @@ +_**Ready**_:adf{json="{\"attrs\":{\"extensionKey\":\"status-lozenge\",\"extensionType\":\"com.atlassian.confluence.macro.core\"},\"marks\":[{\"type\":\"em\"},{\"type\":\"strong\"}],\"type\":\"inlineExtension\"}"}_**to ship**_ diff --git a/corpus/round-trip/combinations/mark-run.json b/corpus/round-trip/combinations/mark-run.json new file mode 100644 index 0000000..d949442 --- /dev/null +++ b/corpus/round-trip/combinations/mark-run.json @@ -0,0 +1,129 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "color": "#ae2e24" + }, + "type": "textColor" + } + ], + "text": "Overdue since ", + "type": "text" + }, + { + "attrs": { + "color": "red", + "text": "Blocked" + }, + "marks": [ + { + "attrs": { + "color": "#ae2e24" + }, + "type": "textColor" + } + ], + "type": "status" + }, + { + "marks": [ + { + "attrs": { + "color": "#ae2e24" + }, + "type": "textColor" + } + ], + "text": " on Tuesday", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "em" + } + ], + "text": "Torque the ", + "type": "text" + }, + { + "marks": [ + { + "type": "em" + }, + { + "type": "strong" + } + ], + "text": "M8 bolt", + "type": "text" + }, + { + "marks": [ + { + "type": "em" + } + ], + "text": " to ", + "type": "text" + }, + { + "marks": [ + { + "type": "em" + }, + { + "type": "strong" + } + ], + "text": "25 Nm", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "https://example.com/release" + }, + "type": "link" + } + ], + "text": "Release notes ", + "type": "text" + }, + { + "attrs": { + "shortName": ":rocket:", + "text": "๐Ÿš€" + }, + "marks": [ + { + "attrs": { + "href": "https://example.com/release" + }, + "type": "link" + } + ], + "type": "emoji" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/mark-run.md b/corpus/round-trip/combinations/mark-run.md new file mode 100644 index 0000000..863dc71 --- /dev/null +++ b/corpus/round-trip/combinations/mark-run.md @@ -0,0 +1,5 @@ +:textColor[Overdue since :status[Blocked]{color=red} on Tuesday]{color="#ae2e24"} + +_Torque the **M8 bolt** to **25 Nm**_ + +[Release notes :emoji[๐Ÿš€]{shortName=":rocket:"}](https://example.com/release) diff --git a/corpus/unspellable/mark-inside-word.error b/corpus/unspellable/mark-inside-word.error new file mode 100644 index 0000000..09d223e --- /dev/null +++ b/corpus/unspellable/mark-inside-word.error @@ -0,0 +1 @@ +unspellable-mark diff --git a/corpus/unspellable/mark-inside-word.json b/corpus/unspellable/mark-inside-word.json new file mode 100644 index 0000000..90219f1 --- /dev/null +++ b/corpus/unspellable/mark-inside-word.json @@ -0,0 +1,28 @@ +{ + "content": [ + { + "content": [ + { + "text": "un", + "type": "text" + }, + { + "marks": [ + { + "type": "strong" + } + ], + "text": "-real", + "type": "text" + }, + { + "text": "istic", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/spec/flavour.md b/spec/flavour.md index 934d5fc..72c63e6 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -386,9 +386,10 @@ inline nodes carrying an identical mark, attributes included, at that depth. A r node the emitter carries, so no emitted carry sits inside a mark spelling. An inline node whose marks no nesting spells โ€” a mark type not listed here, an attribute no -spelling holds, an order putting a code span outside another mark, or `code` on text holding a -newline โ€” rides the inline carry whole. An opaque carry inside a mark spelling is a named error -in input: the carry restores its node exactly, marks included (AGENTS.md ยง3). +spelling holds, an order putting a code span outside another mark, `code` on text holding a +newline, or a spelling CommonMark's flanking rules cannot open or close where the run sits +(`un**-real**istic`) โ€” rides the inline carry whole. An opaque carry inside a mark spelling is a +named error in input: the carry restores its node exactly, marks included (AGENTS.md ยง3). ``` :textColor[**Overdue**]{color="#ae2e24"}, H:subsup[2]{type=sub}O, :underline[signed]. diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index 346f379..d2fc140 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -96,6 +96,7 @@ test('refuses a mark the canonical spellings cannot nest', () => { assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'annotation' }], text: 'x', type: 'text' })))), 'unspellable-mark') assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }, { type: 'strong' }], text: 'x', type: 'text' })))), 'unspellable-mark') assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { colour: 'red' }, type: 'em' }], text: 'x', type: 'text' })))), 'unspellable-mark') + assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { localId: 'x' }, type: 'code' }], text: 'x', type: 'text' })))), 'unspellable-mark') }) test('refuses whitespace CommonMark cannot hold', () => { diff --git a/src/inline-directives.ts b/src/inline-directives.ts index 46a3dee..0af1aa6 100644 --- a/src/inline-directives.ts +++ b/src/inline-directives.ts @@ -8,6 +8,10 @@ export type InlineDirective = { slot?: string } +export type MarkSpelling = + | { attributes: AttributeVocabulary; kind: 'code' | 'directive' | 'link'; spelling?: undefined } + | { attributes: AttributeVocabulary; kind: 'emphasis'; spelling: string } + const inlineDirectives: Readonly> = { date: { attributes: { localId: 'string', timestamp: 'string' } }, emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' }, @@ -29,19 +33,24 @@ const inlineDirectives: Readonly> = { status: { attributes: { color: 'string', localId: 'string', style: 'string' }, slot: 'text' }, } -const markDirectives: Readonly> = { - border: { color: 'string', size: 'number' }, - subsup: { type: 'string' }, - textColor: { color: 'string' }, - underline: {}, +const markSpellings: Readonly> = { + border: { attributes: { color: 'string', size: 'number' }, kind: 'directive' }, + code: { attributes: {}, kind: 'code' }, + em: { attributes: {}, kind: 'emphasis', spelling: '_' }, + link: { attributes: { href: 'string', title: 'string' }, kind: 'link' }, + strike: { attributes: {}, kind: 'emphasis', spelling: '~~' }, + strong: { attributes: {}, kind: 'emphasis', spelling: '**' }, + subsup: { attributes: { type: 'string' }, kind: 'directive' }, + textColor: { attributes: { color: 'string' }, kind: 'directive' }, + underline: { attributes: {}, kind: 'directive' }, } export function inlineDirective(type: string): InlineDirective | undefined { return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined } -export function markDirective(type: string): AttributeVocabulary | undefined { - return Object.hasOwn(markDirectives, type) ? markDirectives[type] : undefined +export function markSpelling(type: string): MarkSpelling | undefined { + return Object.hasOwn(markSpellings, type) ? markSpellings[type] : undefined } export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result { diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index 3e78fcf..78f2256 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -1,7 +1,7 @@ import type { AdfMark, AdfNode } from './adf-document.ts' import type { InlineDirective } from './inline-directives.ts' import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer } from './markdown-escaping.ts' -import { inlineDirective, markDirective, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts' +import { inlineDirective, markSpelling, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts' import { largestNesting } from './nesting.ts' import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink, isUnicodeWhitespace } from './commonmark-grammar.ts' import { carriedInline } from './opaque-carry.ts' @@ -19,9 +19,6 @@ type InlineContext = { type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } -const emphasisSpellings: Readonly> = { em: '_', strike: '~~', strong: '**' } -const linkAttributes = ['href', 'title'] - export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const segments = lineSegments(nodes, container, path) if (!segments.ok) return segments @@ -204,15 +201,14 @@ function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath) } function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { - if (mark.type === 'code') return emitCodeSpan(nodes, depth, nodePath(context, index)) - if (mark.type === 'link') return emitLink(nodes, mark, depth, index, context) const path = nodePath(context, index) - const spelling = Object.hasOwn(emphasisSpellings, mark.type) ? emphasisSpellings[mark.type] : undefined - if (spelling !== undefined) return emitEmphasis(nodes, mark, spelling, depth, index, context, path) - const vocabulary = markDirective(mark.type) - if (vocabulary === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path) - const attributes = spellMarkAttributes(mark, vocabulary, path) + const spelling = markSpelling(mark.type) + if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path) + const attributes = spellMarkAttributes(mark, spelling.attributes, path) if (!attributes.ok) return attributes + if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, path) + if (spelling.kind === 'emphasis') return emitEmphasis(nodes, mark, spelling.spelling, depth, index, context, path) + if (spelling.kind === 'link') return emitLink(nodes, mark, depth, index, context, path) const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) if (!inner.ok) return inner return success([syntax(`:${mark.type}[`), ...inner.value, syntax(`]${attributes.value}`)]) @@ -227,7 +223,6 @@ function emitEmphasis( context: InlineContext, path: ConvertErrorPath, ): Result { - if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`, path) const inner = emitRun(nodes, depth + 1, index, context) if (!inner.ok) return inner const carried = carryStrippedWhitespace(inner.value) @@ -263,20 +258,16 @@ function needsPadding(text: string): boolean { return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) } -function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { - const path = nodePath(context, index) - const unspelled = Object.keys(mark.attrs ?? {}).find((key) => !linkAttributes.includes(key)) - if (unspelled !== undefined) return failure('unspellable-mark', `the link spelling holds no ${unspelled} attribute`, path) +function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext, path: ConvertErrorPath): Result { const href = mark.attrs?.['href'] const title = mark.attrs?.['title'] if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href', path) - if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string', path) const node = nodes[0] const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1 if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success([syntax(`<${href}>`)]) const destination = spellDestination(href, path) if (!destination.ok) return destination - const spelledTitle = title === undefined ? success('') : spellTitle(title, path) + const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('') if (!spelledTitle.ok) return spelledTitle const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true }) if (!inner.ok) return inner