Read the inline nodes and the marks back, and keep the whitespace CommonMark does not strip #41

Merged
lilleman merged 5 commits from inline-nodes-read-back into main 2026-09-02 12:34:20 +02:00
9 changed files with 43 additions and 12 deletions
Showing only changes of commit 1429f4ce3c - Show all commits
@@ -157,7 +157,7 @@
{
"content": [
{
"text": "Spare",
"text": " Spare",
"type": "text"
}
],
+1 -1
View File
@@ -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 | |
+2 -1
View File
@@ -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
+6
View File
@@ -77,6 +77,12 @@ export function readInlineDirective(text: string, index: number): Read<Directive
return readNestedDirective(text, index, 1)
}
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending.
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined {
if (!/[\n\r]/.test(text)) return undefined
return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
}
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
if (pairs.length === 0) return ''
const spelled = [...pairs].sort(([left], [right]) => keyOrder(left, right)).map(([key, value]) => `${key}=${value}`)
+4 -3
View File
@@ -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}`)] })
+3
View File
@@ -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))
+10 -3
View File
@@ -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[] {
+9 -1
View File
@@ -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[<div>]{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[<div>]{timestamp=1}\n')), 'unmappable-html')
assert.equal(code(markdownToAdf(':widget[<div>]\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&#10;b]{color=yellow}\n')), spans)
assert.equal(content(markdownToAdf(':status[a&#13;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')
})
+7 -2
View File
@@ -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[<div>]{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 `&#10;` 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