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
5 changed files with 32 additions and 16 deletions
Showing only changes of commit 8961cbd20a - Show all commits
+2 -3
View File
@@ -55,9 +55,8 @@ 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). No link wraps a link, so a `[text]` holding one leaves is refused (the maintainer, 2026-09-13). No link wraps a link, which is CommonMark's prose where
the outer brackets literal text and the directive form is refused, following CommonMark's prose its reference implementation nests one `<a>` in another (the maintainer, 2026-09-17).
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
+9 -2
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` 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 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 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 ### 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 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. holding an unescaped `!adf:` and a `carry` fence are claimed instead.
### Readings
Markdown the spelling table leaves alone, reading to 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 |
### Error codes ### Error codes
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link `unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
@@ -55,7 +63,6 @@ it named converts.
| Input | `0.1.0` | `0.2.0` | | Input | `0.1.0` | `0.2.0` |
| --- | --- | --- | | --- | --- | --- |
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` | | a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
| 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 |
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` | | a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` | | a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` | | an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
+3 -3
View File
@@ -120,9 +120,9 @@ emit refuses:
continuing past a marker change stays one list against CommonMark's two, 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, and a link 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, 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 which the spec requires and the reference implementation itself breaks, nesting one `<a>` in the
are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite holds no example of other. The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the
the fourth. 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 - 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
+12 -6
View File
@@ -221,7 +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 { 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 (marksLink(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
@@ -268,7 +268,12 @@ 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 { // 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')) return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
} }
@@ -349,6 +354,11 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
} }
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> {
// 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 (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)
@@ -356,10 +366,6 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
const nodes = resolved.value const nodes = resolved.value
// An empty link text gives the mark no node to ride, so the brackets stay text. // An empty link text gives the mark no node to ride, so the brackets stay text.
if (nodes.length === 0) return success(false) if (nodes.length === 0) return success(false)
if (holdsLink(nodes)) {
deactivateOpeners(scan, at)
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 }
truncatePieces(scan, at) truncatePieces(scan, at)
deactivateOpeners(scan, at) deactivateOpeners(scan, at)
+6 -2
View File
@@ -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(`**${carried}**\n`)), named)
assert.equal(content(markdownToAdf(`~~a ${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(`[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:underline[${carried}]\n`)), named)
assert.equal(content(markdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\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) assert.equal(content(markdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named)
@@ -842,8 +843,11 @@ test('leaves the brackets of a link whose text already holds one the text they a
}) })
test('keeps the carry and the image the brackets a nested link leaves literal hold', () => { test('keeps the carry and the image the brackets a nested link leaves literal hold', () => {
assert.deepEqual(content(markdownToAdf('[[<http://x/>](/c)!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}](/w)\n')), [ assert.deepEqual(content(markdownToAdf(`[<http://x/>${carried}](/w)\n`)), [
{ content: [text('[['), marked('http://x/', link('http://x/')), text('](/c)'), { type: 'blockCard' }, text('](/w)')], type: 'paragraph' }, { 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' },
]) ])
assert.deepEqual(content(markdownToAdf('![![a [b](/c) ](/i)[![x](/y)[<http://x/>](/c)](/w)](/v)\n')), [image('/v', 'a b [x[http://x/](/c)](/w)')]) assert.deepEqual(content(markdownToAdf('![![a [b](/c) ](/i)[![x](/y)[<http://x/>](/c)](/w)](/v)\n')), [image('/v', 'a b [x[http://x/](/c)](/w)')])
}) })