13b - link takes the directive form where CommonMark cannot spell it, unspellable-link leaves the code list #93

Merged
lilleman merged 3 commits from 13b into main 2026-09-16 23:29:27 +02:00
7 changed files with 12 additions and 11 deletions
Showing only changes of commit 43292555cd - Show all commits
+3 -3
View File
@@ -17,9 +17,9 @@ When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
where there is a way back. CommonMark spells some things the flavour has no escape for — a link
destination or title holding a backslash or newline, a paragraph opening with a code span whose
backticks read back as a fence — so a parse succeeding does not imply a spellable document;
where there is a way back. CommonMark spells some things the flavour has no escape for — a
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
does not imply a spellable document;
`corpus/commonmark-spec/exceptions.json` names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
+2 -2
View File
@@ -102,7 +102,7 @@ emit refuses:
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node or mark the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees
@@ -114,7 +114,7 @@ emit refuses:
title-less paragraph; mid-text and titled images are error results. Converting back yields the
library's canonical spelling, which round-trips byte-identically — where it converts back at
all: a parse succeeding is no promise of that, so keep the source until the way back succeeds.
`` ` `` ` `` reads cleanly and then refuses.
``` ` `` ` ``` reads cleanly and then refuses.
- Three CommonMark spellings parse without an error and build a document the reference renders
differently: `[](/url)` and `[]()` stay literal text against CommonMark's empty link, a list
continuing past a marker change stays one list against CommonMark's two, and a shortcut
+1 -2
View File
@@ -41,7 +41,6 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
return success(emitted.value.line)
}
// The parser asks which form a paragraph's opening link takes rather than restating the line rule (AGENTS.md §11).
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
const emitted = emitLine(nodes, 'paragraph', path)
if (!emitted.ok) return emitted
@@ -64,7 +63,7 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver
return attempt.ok ? attempt.value.line : undefined
}
// A carried node joins no run and the opening link turns directive once, so every pass carries at least one more node.
// Every pass carries at least one more node, or flips openingLinkAsDirective, which happens once — so the loop ends.
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
const carried = new Set<number>()
let openingLinkAsDirective = false
+1 -1
View File
@@ -35,7 +35,7 @@ export function markSpelling(type: string): MarkSpelling | undefined {
return { attributes, kind: spelling.kind }
}
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth, the parser's none.
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth + 1, the parser's 0.
export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean): CommonMarkLink | undefined {
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
const title = attrs['title']
+3 -1
View File
@@ -20,7 +20,9 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes,
export function inlineMarkSpellingFault(name: string): ConvertFault | undefined {
const spelling = markSpelling(name)
if (spelling === undefined) return undefined
return { code: 'unsupported-node-shape', message: `${name} is spelled ${markdownForm(spelling) ?? `${directivePrefix}${name}[…]`}, never as a block directive` }
const directive = `${directivePrefix}${name}[…]`
const forms = spelling.kind === 'link' ? `[x](url) or ${directive}` : (markdownForm(spelling) ?? directive)
return { code: 'unsupported-node-shape', message: `${name} is spelled ${forms}, never as a block directive` }
}
function markdownForm(spelling: MarkSpelling): string | undefined {
+1 -1
View File
@@ -36,7 +36,7 @@ type Piece =
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
// `container` is the emitter's line container, `undefined` inside a directive's content slot; `openingSpellableLink` defers a directive link at offset 0 to `assemble`.
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = {
container: LineContainer | undefined
definitions: LinkDefinitions
+1 -1
View File
@@ -333,6 +333,7 @@ test('names the leaf given a body at its opener, ahead of any refusal the leaf h
test('names the position a directive name the other one spells belongs to', () => {
assert.equal(content(markdownToAdf('!adf:em\na\n!adf:/em\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive')
assert.equal(content(markdownToAdf('!adf:underline\n')), 'unsupported-node-shape: underline is spelled !adf:underline[…], never as a block directive')
assert.equal(content(markdownToAdf('!adf:link\n')), 'unsupported-node-shape: link is spelled [x](url) or !adf:link[…], never as a block directive')
assert.equal(content(markdownToAdf('!adf:text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, !adf:text{…}, never the block form')
assert.equal(content(markdownToAdf('!adf:date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, !adf:date{…}, never the block form')
assert.equal(content(markdownToAdf('!adf:paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, !adf:paragraph, never the inline form')
@@ -968,7 +969,6 @@ test('names the mark markdown spells, never a directive', () => {
assert.equal(content(markdownToAdf('!adf:code[a]\n')), 'unsupported-node-shape: code is spelled `x`, never as a directive')
})
// spec/flavour.md, Marks: the directive link is refused wherever the emitter would pick the markdown spelling.
test('refuses the directive link CommonMark could spell, and reads the one it could not', () => {
const refused = 'unsupported-node-shape: link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), refused)