From 1429f4ce3c579fcf3b846a62d5b52506700856ac Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 1 Sep 2026 22:35:05 +0200 Subject: [PATCH] Answer the stability pass: the slot a line ending reached, and the text an image description dropped --- corpus/round-trip/block-nodes/table-pipe.json | 2 +- corpus/round-trip/block-nodes/table-pipe.md | 2 +- spec/flavour.md | 3 ++- src/markdown/directive-syntax.ts | 6 ++++++ src/markdown/emit/inline-line.ts | 7 ++++--- src/markdown/parse/directive-nodes.ts | 3 +++ src/markdown/parse/inline-content.ts | 13 ++++++++++--- src/markdown/parse/markdown-to-adf.test.ts | 10 +++++++++- todo-history.md | 9 +++++++-- 9 files changed, 43 insertions(+), 12 deletions(-) diff --git a/corpus/round-trip/block-nodes/table-pipe.json b/corpus/round-trip/block-nodes/table-pipe.json index bb50b24..89dd3b9 100644 --- a/corpus/round-trip/block-nodes/table-pipe.json +++ b/corpus/round-trip/block-nodes/table-pipe.json @@ -157,7 +157,7 @@ { "content": [ { - "text": "Spare", + "text": " Spare", "type": "text" } ], diff --git a/corpus/round-trip/block-nodes/table-pipe.md b/corpus/round-trip/block-nodes/table-pipe.md index ab857c7..3935489 100644 --- a/corpus/round-trip/block-nodes/table-pipe.md +++ b/corpus/round-trip/block-nodes/table-pipe.md @@ -3,4 +3,4 @@ | Bolt M8 | Grade `8.8` | | Nut \| washer | Sold as a pair | | Washer M8 | 100 pcs:hardBreak{}zinc-plated | -| Spare | | +| :text{text=" "}Spare | | diff --git a/spec/flavour.md b/spec/flavour.md index 71bc6b8..1c8548f 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -260,7 +260,8 @@ The moon, at night. **The CommonMark image.** A paragraph whose entire inline content is one image `![alt](url)` is a `mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url` from the destination, `alt` the description's plain-text content when non-empty — a link or image -inside it contributing its own text, and a break of either kind a space. `adfToMarkdown` +inside it contributing its own text, a node spelling its text in the content slot contributing +that text, and a break of either kind a space. `adfToMarkdown` emits the image form for exactly that shape — those attrs and no others, no marks on either node, no caption, and a `media` carrying nothing beyond `alt`, `type` and `url` — and only where CommonMark spells the pair: a destination or a description the image form cannot hold, an empty diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index d1fb73c..94dbf76 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -77,6 +77,12 @@ export function readInlineDirective(text: string, index: number): Read keyOrder(left, right)).map(([key, value]) => `${key}=${value}`) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 37e4f10..85b3d43 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -3,16 +3,16 @@ import type { InlineDirective } from '../../adf/inline-directives.ts' import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' -import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { holdsEntityReference } from '../entity-references.ts' import { inlineDirective } from '../../adf/inline-directives.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../backtick-runs.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { sameMark } from '../../adf/editor-normal.ts' +import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' import { spellDestination, spellTitle } from '../link-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' -import { spellLeafDirective } from '../directive-syntax.ts' import { spellTextDirective } from '../text-directive.ts' type EmittedLine = { line: string; segments: InlineSegment[] } @@ -207,7 +207,8 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute] if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) - if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path) + const spans = slotLineEndingFault(node.type, slot) + if (spans !== undefined) return faulted(spans, path) if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path) const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }] return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] }) diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 9b2970d..d4530cd 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -10,6 +10,7 @@ import { failure, faulted, success, type ConvertErrorPath, type Result } from '. import { inlineDirective } from '../../adf/inline-directives.ts' import { marksAttribute, readMarkValues } from '../block-directive-marks.ts' import { readVocabulary } from './directive-attributes.ts' +import { slotLineEndingFault } from '../directive-syntax.ts' export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode } @@ -56,6 +57,8 @@ export function readInlineDirectiveNode( if (slot !== undefined && content !== undefined) { const text = slotText(content) if (text === undefined) return failure('unsupported-node-shape', `the ${name} content slot holds one unmarked text node`, path) + const spans = slotLineEndingFault(name, text) + if (spans !== undefined) return faulted(spans, path) attrs.value[slot] = text } return success(namedNode(name, attrs.value, undefined)) diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index fccb32d..5cd322b 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -6,6 +6,7 @@ import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedA import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { inlineDirective } from '../../adf/inline-directives.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { readDirectiveMark } from './directive-marks.ts' @@ -305,9 +306,15 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: } function imageAlt(inner: readonly Piece[]): string { - return resolveNodes(inner) - .map((node) => (node.type === 'hardBreak' ? ' ' : (node.text ?? ''))) - .join('') + return resolveNodes(inner).map(altText).join('') +} + +// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included. +function altText(node: AdfNode): string { + if (node.type === 'hardBreak') return ' ' + const slot = inlineDirective(node.type)?.textAttribute + const spelled = slot === undefined ? undefined : node.attrs?.[slot] + return typeof spelled === 'string' ? spelled : (node.text ?? '') } function resolveNodes(pieces: readonly Piece[]): AdfNode[] { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index ebf844c..8f5aac2 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -647,6 +647,8 @@ test('flattens the description of a lone image to the plain text alt holds', () assert.deepEqual(content(markdownToAdf('![a\nb](/u)\n')), [image('/u', 'a b')]) assert.deepEqual(content(markdownToAdf('![a \nb](/u)\n')), [image('/u', 'a b')]) assert.deepEqual(content(markdownToAdf('![a `b`](/u)\n')), [image('/u', 'a b')]) + assert.deepEqual(content(markdownToAdf('![a :mention[@A]{id=b1c2} b](/u)\n')), [image('/u', 'a @A b')]) + assert.deepEqual(content(markdownToAdf('![:mention[@A]{id=b1c2}](/u)\n')), [image('/u', '@A')]) }) test('leaves the brackets of an empty link text the text they are', () => { @@ -697,9 +699,12 @@ test('names the content slot no one unmarked text node reads back from', () => { assert.equal(code(markdownToAdf(':status[:date{timestamp=1}]{color=yellow}\n')), 'unsupported-node-shape') assert.equal(content(markdownToAdf(':status[![a](/u)]{color=yellow}\n')), 'unmappable-image: an image fits only as a paragraph of its own') assert.equal(code(markdownToAdf(':status[
]{color=yellow}\n')), 'unmappable-html') - // The slot parses before the name's table is consulted, so a doubly-broken span reports its inner error. assert.equal(code(markdownToAdf(':date[
]{timestamp=1}\n')), 'unmappable-html') assert.equal(code(markdownToAdf(':widget[
]\n')), 'unmappable-html') + const spans = 'unspellable-whitespace: the status content slot holds a newline no inline directive spans' + assert.equal(content(markdownToAdf(':status[:text{text="\\n"}]{color=yellow}\n')), spans) + assert.equal(content(markdownToAdf(':status[a b]{color=yellow}\n')), spans) + assert.equal(content(markdownToAdf(':status[a b]{color=yellow}\n')), spans) assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot') }) @@ -733,6 +738,9 @@ test('reads the directive marks, the nesting outermost first', () => { assert.deepEqual(content(markdownToAdf(':underline[a:date{timestamp=1}]\n')), [ { content: [marked('a', underline), { attrs: { timestamp: '1' }, marks: [underline], type: 'date' }], type: 'paragraph' }, ]) + assert.deepEqual(content(markdownToAdf('_:underline[a:date{timestamp=1}]_\n')), [ + { content: [marked('a', em, underline), { attrs: { timestamp: '1' }, marks: [em, underline], type: 'date' }], type: 'paragraph' }, + ]) assert.equal(content(markdownToAdf(':border[a]{color="#091e42" size=x}\n')), 'unsupported-node-shape: the size attribute of border is no number') }) diff --git a/todo-history.md b/todo-history.md index e9179e0..9b48aaf 100644 --- a/todo-history.md +++ b/todo-history.md @@ -357,8 +357,13 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: CommonMark carries plainly, are named errors, as 3g refuses the directive form of a node CommonMark spells. The reader takes the slot's parsed nodes rather than its text, so the rule refusing anything but one unmarked text node sits beside the node tables that own the - slot, and a node taking no content still names that first. `directive-content-slot` stays - with the fixtures, its cause now a marked slot rather than a slot at all. + slot. Only `text`, read ahead of the slot, names a refusal before the slot's own: a + doubly-broken span reports what its content holds, `:date[
]{timestamp=1}` being + `unmappable-html` rather than `date takes no content`, which the maintainer pinned with an + assertion rather than reordering the readers. `directive-content-slot` stays with the + fixtures, its cause now a marked slot rather than a slot at all. The slot's own whitespace + answers the rule the spelling does: `:text{text="\n"}` and ` ` alike reach a slot the + emitter refuses a line ending in, so one function answers both directions. The same read found the hole the other way: `attemptLine` refused a line edged with a vertical tab or a form feed, where CommonMark strips spaces and tabs alone, so valid CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered