16 - no link wraps a link: the outer brackets go literal, the directive form is refused
CI / gate (push) Successful in 37s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-19 01:48:30 +02:00
parent b695c4c88c
commit 3a22ff388d
11 changed files with 168 additions and 13 deletions
+3 -1
View File
@@ -55,7 +55,9 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot
hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark
spelling would read as a link reference definition — and a directive link CommonMark could spell spelling would read as a link reference definition — and a directive link CommonMark could spell
is refused (the maintainer, 2026-09-13). is refused (the maintainer, 2026-09-13). No link wraps a link, so a `[text]` holding one leaves
the outer brackets literal text and the directive form is refused, following CommonMark's prose
where its reference implementation nests one `<a>` in another (the maintainer, 2026-09-17).
- Identity-bearing nodes carry their ids in attributes; a document is only portable within its - Identity-bearing nodes carry their ids in attributes; a document is only portable within its
site — accepted. site — accepted.
- The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML - The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML
+7 -4
View File
@@ -115,11 +115,14 @@ emit refuses:
library's canonical spelling, which round-trips byte-identically — where it converts back at 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. 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 - Four 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 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 continuing past a marker change stays one list against CommonMark's two, a shortcut
reference matching its definition only under Unicode case folding stays unresolved. Each is reference matching its definition only under Unicode case folding stays unresolved, and a link
pinned `pending` in `corpus/commonmark-spec/exceptions.json`. whose text holds an autolink keeps the inner link and leaves the outer brackets literal text,
where the reference nests one `<a>` inside another against the spec's own prose. The first three
are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite holds no example of
the fourth, which a normalization fixture pins instead.
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.2.0`. processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.2.0`.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
See !adf:link[<https://example.com/docs>]{collection=contentId-98237 href="/docs"} here.
+107
View File
@@ -0,0 +1,107 @@
{
"content": [
{
"content": [
{
"text": "[",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "https://example.com/",
"type": "text"
},
{
"text": "](/v)",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "[a",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/b"
},
"type": "link"
}
],
"text": "https://example.com/b",
"type": "text"
},
{
"text": "c](/v)",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "[",
"type": "text"
},
{
"marks": [
{
"attrs": {
"collection": "contentId-98237",
"href": "/docs"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": "](/v)",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "[",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "/u"
},
"type": "link"
}
],
"text": "a",
"type": "text"
},
{
"text": "](/v)",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
+7
View File
@@ -0,0 +1,7 @@
[<https://example.com/>](/v)
[a<https://example.com/b>c](/v)
[!adf:link[the docs]{collection=contentId-98237 href="/docs"}](/v)
[[a](/u)](/v)
+3 -1
View File
@@ -474,7 +474,9 @@ the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an
backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a
newline in the title), or a link opening a paragraph whose markdown spelling would read as a link newline in the title), or a link opening a paragraph whose markdown spelling would read as a link
reference definition. Every such spelling carries an `href`: a directive link CommonMark could reference definition. Every such spelling carries an `href`: a directive link CommonMark could
spell is a named error, and so is one spelling none. spell is a named error, and so is one spelling none. No link wraps a link at any nesting, which is
CommonMark's own rule: a `[text]` already holding one leaves the outer brackets literal text, and
the directive form, open to no literal reading, is a named error.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3). - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
- `code`, `em`, `strike`, `strong` — Attributes: none. - `code`, `em`, `strike`, `strong` — Attributes: none.
+8 -2
View File
@@ -53,6 +53,7 @@ type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
const hreflessLink = 'the link mark spells its href: this one spells none' const hreflessLink = 'the link mark spells its href: this one spells none'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>' const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
@@ -218,6 +219,7 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined { function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
const href = linkHref(nodeAttrs(mark)) const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path) if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
if (holdsLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path) if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
scan.openingSpellableLink = true scan.openingSpellableLink = true
@@ -264,6 +266,10 @@ function holdsImage(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'image') return pieces.some((piece) => piece.kind === 'image')
} }
function holdsLink(nodes: readonly AdfNode[]): boolean {
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
}
function readDelimiterRun(scan: Scan, index: number): number { function readDelimiterRun(scan: Scan, index: number): number {
const character = scan.source.charAt(index) const character = scan.source.charAt(index)
const length = runLength(scan.source, index) const length = runLength(scan.source, index)
@@ -340,14 +346,14 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
return { definition, length: label?.length ?? 0 } return { definition, length: label?.length ?? 0 }
} }
// `false` where the link text is empty: the mark has no node to ride, so the brackets stay text. // `false` keeps the brackets text: an empty link text gives the mark no node to ride, and a linked one no room for a second.
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> { function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path) if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path) if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
const resolved = resolveNodes(inner, scan.path) const resolved = resolveNodes(inner, scan.path)
if (!resolved.ok) return resolved if (!resolved.ok) return resolved
const nodes = resolved.value const nodes = resolved.value
if (nodes.length === 0) return success(false) if (nodes.length === 0 || holdsLink(nodes)) return success(false)
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title } const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
scan.pieces.length = at scan.pieces.length = at
// CommonMark: no link nests inside another, though an image's description holds one. // CommonMark: no link nests inside another, though an image's description holds one.
@@ -825,6 +825,22 @@ test('leaves the bracket pair no link parses as the text it holds', () => {
]) ])
}) })
test('leaves the brackets of a link whose text already holds one the text they are', () => {
const held: AdfMark = { attrs: { collection: 'c', href: '/u' }, type: 'link' }
assert.deepEqual(content(markdownToAdf('[<http://x/>](/v)\n')), [
{ content: [text('['), marked('http://x/', link('http://x/')), text('](/v)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf('[a<http://x/>b](/v)\n')), [
{ content: [text('[a'), marked('http://x/', link('http://x/')), text('b](/v)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf('[!adf:link[a]{collection=c href="/u"}](/v)\n')), [
{ content: [text('['), marked('a', held), text('](/v)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf('[<http://x/>][r]\n\n[r]: /v\n')), [
{ content: [text('['), marked('http://x/', link('http://x/')), text(']'), marked('r', link('/v'))], type: 'paragraph' },
])
})
test('reads the reference links a definition resolves, and leaves the rest literal', () => { test('reads the reference links a definition resolves, and leaves the rest literal', () => {
assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('[a][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('[a][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
@@ -996,6 +1012,14 @@ test('names the href the directive link spells no value for', () => {
assert.equal(content(markdownToAdf('!adf:link[a]\n')), named) assert.equal(content(markdownToAdf('!adf:link[a]\n')), named)
assert.equal(content(markdownToAdf('!adf:link[a]{title=t}\n')), named) assert.equal(content(markdownToAdf('!adf:link[a]{title=t}\n')), named)
assert.equal(content(markdownToAdf('See !adf:link[a]{id=01a032c3-7a90-70c9-88f6-c60f710eda07}.\n')), named) assert.equal(content(markdownToAdf('See !adf:link[a]{id=01a032c3-7a90-70c9-88f6-c60f710eda07}.\n')), named)
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{collection=c}\n')), named)
})
test('names the link a directive link wraps, no link holding another', () => {
const named = 'unsupported-node-shape: no link wraps a link: the [content] this one marks already holds one'
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{collection=c href="/u"}\n')), named)
assert.equal(content(markdownToAdf('!adf:link[[a](/v)]{collection=c href="/u"}\n')), named)
assert.equal(content(markdownToAdf('!adf:link[a <http://x/> b]{collection=c href="/u"}\n')), named)
}) })
test('names the directive mark left without the content it wraps', () => { test('names the directive mark left without the content it wraps', () => {
+6
View File
@@ -814,6 +814,12 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
parses to a mark the emitter writes back as a carry, while the schema requires `href` and parses to a mark the emitter writes back as a carry, while the schema requires `href` and
every other directive mark spells without attributes in both directions alike (the every other directive mark spells without attributes in both directions alike (the
stability-reviewer, 2026-09-16; the maintainer, 2026-09-17). stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
- [x] **16 — The link wrapping a link (`0.2.0`).** Read `[<http://x/>](/v)` and
`[!adf:link[a]{href="/u"}](/v)` as `[[a](/u)](/v)` reads — the inner link wins and the outer
brackets stay literal text, CommonMark's rule that no link holds another — rather than
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a
normalization fixture per shape (the stability-reviewer, 2026-09-16; the maintainer,
2026-09-17).
## 5 — Ship `0.1.0` ## 5 — Ship `0.1.0`
+1 -5
View File
@@ -210,11 +210,7 @@ bundle size and the tagline.
- [x] **13b — The directive link.** - [x] **13b — The directive link.**
- [x] **14 — The CommonMark subset's directory (`0.2.0`).** - [x] **14 — The CommonMark subset's directory (`0.2.0`).**
- [x] **15 — The href-less directive link (`0.2.0`).** - [x] **15 — The href-less directive link (`0.2.0`).**
- [ ] **16 — The link wrapping a link (`0.2.0`).** Read `[<http://x/>](/v)` and - [x] **16 — The link wrapping a link (`0.2.0`).**
`[!adf:link[a]{href="/u"}](/v)` as `[[a](/u)](/v)` reads — the inner link wins and the outer
brackets stay literal text, CommonMark's rule that no link holds another — rather than
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a normalization
fixture per shape (the stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
- [ ] **17 — A machine-enforced size guardrail (`0.2.0`).** Add a per-function complexity check to - [ ] **17 — A machine-enforced size guardrail (`0.2.0`).** Add a per-function complexity check to
the gate — branch count or size — so the fits-in-your-head guardrail fails the build rather the gate — branch count or size — so the fits-in-your-head guardrail fails the build rather
than waiting for a review to catch it (the systems-architect, 2026-09-16). It reads ahead of than waiting for a review to catch it (the systems-architect, 2026-09-16). It reads ahead of