16 - no link wraps a link #104

Merged
lilleman merged 8 commits from link-wrapping-a-link into main 2026-09-19 02:34:21 +02:00
12 changed files with 223 additions and 21 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
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
is refused (the maintainer, 2026-09-13).
is refused (the maintainer, 2026-09-13). No link wraps a link — the bracket form goes literal,
the directive form refused — which is 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
site — accepted.
- The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML
+9 -1
View File
@@ -6,7 +6,7 @@ Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling with
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by
the spelling table. Stored ADF needs no change.
the tables below. Stored ADF needs no change.
### Convert markdown
@@ -47,6 +47,14 @@ function migrateMarkdown(stored: string) {
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
holding an unescaped `!adf:` and a `carry` fence are claimed instead.
### Readings
Markdown the spelling table leaves alone, which `0.2.0` reads as a different document.
| Input | `0.1.0` | `0.2.0` |
| --- | --- | --- |
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
### Error codes
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
+11 -7
View File
@@ -83,7 +83,7 @@ Parsing — `markdownToAdf`, and `htmlToAdf` at `0.2.0`:
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.2.0` |
| `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title |
| `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
@@ -111,15 +111,19 @@ emit refuses:
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML below, with three
carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed
(escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own
title-less paragraph; mid-text and titled images are error results. Converting back yields the
title-less paragraph; mid-text and titled images are error results, save an image inside
another's description, which flattens into the alt text. 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.
- 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
reference matching its definition only under Unicode case folding stays unresolved. Each is
pinned `pending` in `corpus/commonmark-spec/exceptions.json`.
- Four CommonMark spellings parse without an error and build a document the reference
implementation 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, a
shortcut reference matching its definition only under Unicode case folding stays unresolved, and
a link whose text holds an autolink keeps the inner link and leaves the outer brackets literal
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
holds no example of the fourth.
- 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`.
- 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
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
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).
- `code`, `em`, `strike`, `strong` — Attributes: none.
+37 -6
View File
@@ -39,6 +39,8 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = {
container: LineContainer | undefined
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
deactivatedBefore: number
definitions: LinkDefinitions
openingSpellableLink: boolean
path: ConvertErrorPath
@@ -53,6 +55,7 @@ type SlotContent = { carry: boolean; nodes: AdfNode[] }
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 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>'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
@@ -60,7 +63,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
}
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> {
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
const scan: Scan = { container, deactivatedBefore: 0, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
let index = 0
while (index < source.length) {
switch (source.charAt(index)) {
@@ -218,6 +221,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 {
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
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)
scan.openingSpellableLink = true
@@ -264,6 +268,15 @@ function holdsImage(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'image')
}
// A carry rides its own piece: the carried node's marks restore with it rather than riding a spelling, so the guard below answers for it.
function holdsLink(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
}
function marksLink(nodes: readonly AdfNode[]): boolean {
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
}
function readDelimiterRun(scan: Scan, index: number): number {
const character = scan.source.charAt(index)
const length = runLength(scan.source, index)
@@ -340,29 +353,47 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
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.
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
// Ahead of the guards below: brackets going literal put the image and the carry inside no mark for either to refuse.
if (holdsLink(inner)) {
deactivateOpeners(scan, at)
return success(false)
}
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
const resolved = resolveNodes(inner, scan.path)
if (!resolved.ok) return resolved
const nodes = resolved.value
// An empty link text gives the mark no node to ride, so the brackets stay text.
if (nodes.length === 0) return success(false)
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
scan.pieces.length = at
// CommonMark: no link nests inside another, though an image's description holds one.
for (const piece of scan.pieces) if (piece.kind === 'open' && !piece.image) piece.active = false
truncatePieces(scan, at)
deactivateOpeners(scan, at)
scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) })
return success(true)
}
// CommonMark: no link nests inside another, though an image's description holds one.
function deactivateOpeners(scan: Scan, before: number): void {
for (let index = scan.deactivatedBefore; index < before; index += 1) {
const piece = scan.pieces[index]
if (piece?.kind === 'open' && !piece.image) piece.active = false
}
scan.deactivatedBefore = before
}
function truncatePieces(scan: Scan, to: number): void {
scan.pieces.length = to
scan.deactivatedBefore = Math.min(scan.deactivatedBefore, to)
}
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
const resolved = imageAlt(inner, scan.path)
if (!resolved.ok) return resolved
const alt = resolved.value
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
scan.pieces.length = at
truncatePieces(scan, at)
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
return success(null)
}
@@ -416,6 +416,7 @@ test('names the mark spelling no opaque carry sits inside', () => {
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named)
assert.equal(content(markdownToAdf(`[*<http://x/>${carried}*](/w)\n`)), named)
assert.equal(content(markdownToAdf(`!adf:underline[${carried}]\n`)), named)
assert.equal(content(markdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\n`)), named)
assert.equal(content(markdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named)
@@ -825,6 +826,33 @@ 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('keeps the carry and the image the brackets a nested link leaves literal hold', () => {
assert.deepEqual(content(markdownToAdf(`[<http://x/>${carried}](/w)\n`)), [
{ content: [text('['), marked('http://x/', link('http://x/')), { type: 'placeholder' }, text('](/w)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf(`[[<http://x/>](/c)${carried}](/w)\n`)), [
{ content: [text('[['), marked('http://x/', link('http://x/')), text('](/c)'), { type: 'placeholder' }, text('](/w)')], type: 'paragraph' },
])
// The failing bracket sits inside the image, not beside it: beside it the link-marked piece survives, and the deactivation stops being what the assertion pins.
assert.deepEqual(content(markdownToAdf('![![a [b](/c) ](/i)[![[<http://x/>](/c)](/y)](/w)](/v)\n')), [image('/v', 'a b [[http://x/](/c)](/w)')])
})
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][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
@@ -996,6 +1024,15 @@ 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]{title=t}\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)
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{href="/u"}\n')), named)
})
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
every other directive mark spells without attributes in both directions alike (the
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`
+1 -5
View File
@@ -210,11 +210,7 @@ bundle size and the tagline.
- [x] **13b — The directive link.**
- [x] **14 — The CommonMark subset's directory (`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
`[!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).
- [x] **16 — The link wrapping a link (`0.2.0`).**
- [ ] **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
than waiting for a review to catch it (the systems-architect, 2026-09-16). It reads ahead of