Answer the stability pass: the slot a line ending reached, and the text an image description dropped
CI / gate (push) Successful in 8s

This commit is contained in:
2026-09-01 22:35:05 +02:00
parent f4e4d77fc1
commit 1429f4ce3c
9 changed files with 43 additions and 12 deletions
@@ -157,7 +157,7 @@
{ {
"content": [ "content": [
{ {
"text": "Spare", "text": " Spare",
"type": "text" "type": "text"
} }
], ],
+1 -1
View File
@@ -3,4 +3,4 @@
| Bolt M8 | Grade `8.8` | | Bolt M8 | Grade `8.8` |
| Nut \| washer | Sold as a pair | | Nut \| washer | Sold as a pair |
| Washer M8 | 100 pcs:hardBreak{}zinc-plated | | 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 **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` 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 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 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 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 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) 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 { export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
if (pairs.length === 0) return '' if (pairs.length === 0) return ''
const spelled = [...pairs].sort(([left], [right]) => keyOrder(left, right)).map(([key, value]) => `${key}=${value}`) 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 { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts' import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.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 { holdsEntityReference } from '../entity-references.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts' import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { sameMark } from '../../adf/editor-normal.ts' import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
import { spellDestination, spellTitle } from '../link-syntax.ts' import { spellDestination, spellTitle } from '../link-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellLeafDirective } from '../directive-syntax.ts'
import { spellTextDirective } from '../text-directive.ts' import { spellTextDirective } from '../text-directive.ts'
type EmittedLine = { line: string; segments: InlineSegment[] } 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] const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute]
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] }) if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) 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) 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 }] const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] }) 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 { inlineDirective } from '../../adf/inline-directives.ts'
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts' import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
import { readVocabulary } from './directive-attributes.ts' import { readVocabulary } from './directive-attributes.ts'
import { slotLineEndingFault } from '../directive-syntax.ts'
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode } export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode }
@@ -56,6 +57,8 @@ export function readInlineDirectiveNode(
if (slot !== undefined && content !== undefined) { if (slot !== undefined && content !== undefined) {
const text = slotText(content) const text = slotText(content)
if (text === undefined) return failure('unsupported-node-shape', `the ${name} content slot holds one unmarked text node`, path) 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 attrs.value[slot] = text
} }
return success(namedNode(name, attrs.value, undefined)) 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 { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.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 { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readDirectiveMark } from './directive-marks.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 { function imageAlt(inner: readonly Piece[]): string {
return resolveNodes(inner) return resolveNodes(inner).map(altText).join('')
.map((node) => (node.type === 'hardBreak' ? ' ' : (node.text ?? ''))) }
.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[] { 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 \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 `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', () => { 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(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(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') 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(':date[<div>]{timestamp=1}\n')), 'unmappable-html')
assert.equal(code(markdownToAdf(':widget[<div>]\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') 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')), [ assert.deepEqual(content(markdownToAdf(':underline[a:date{timestamp=1}]\n')), [
{ content: [marked('a', underline), { attrs: { timestamp: '1' }, marks: [underline], type: 'date' }], type: 'paragraph' }, { 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') 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 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 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 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 slot. Only `text`, read ahead of the slot, names a refusal before the slot's own: a
with the fixtures, its cause now a marked slot rather than a slot at all. 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 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 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 CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered