From 8961cbd20a830633032103a108668aec4ff8bf83 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 19 Sep 2026 02:21:38 +0200 Subject: [PATCH] Literal brackets put nothing inside a mark, so the nested-link check runs ahead of both guards --- AGENTS.md | 5 ++--- MIGRATION.md | 11 +++++++++-- README.md | 6 +++--- src/markdown/parse/inline-content.ts | 18 ++++++++++++------ src/markdown/parse/markdown-to-adf.test.ts | 8 ++++++-- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 928cd15..6436a09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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). 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 `` in another (the maintainer, 2026-09-17). + is refused (the maintainer, 2026-09-13). No link wraps a link, which is CommonMark's prose where + its reference implementation nests one `` 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 diff --git a/MIGRATION.md b/MIGRATION.md index 48f24ca..ce3f667 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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, reading to a different document. + +| Input | `0.1.0` | `0.2.0` | +| --- | --- | --- | +| a link whose text already holds one (`[ab](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text | + ### Error codes `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` | | --- | --- | --- | | 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 (`[ab](/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 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` | diff --git a/README.md b/README.md index ecdab0a..71425cb 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,9 @@ emit refuses: 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, - where the reference nests one `` 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 the spec requires and the reference implementation itself breaks, nesting one `` 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 diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 93f57fa..724afc6 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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 | undefined { const href = linkHref(nodeAttrs(mark)) 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 (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path) scan.openingSpellableLink = true @@ -268,7 +268,12 @@ function holdsImage(pieces: readonly Piece[]): boolean { 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')) } @@ -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 { + // 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) @@ -356,10 +366,6 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: 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) - if (holdsLink(nodes)) { - deactivateOpeners(scan, at) - return success(false) - } const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title } truncatePieces(scan, at) deactivateOpeners(scan, at) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 12073f8..cc119e4 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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(`[*${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) @@ -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', () => { - assert.deepEqual(content(markdownToAdf('[[](/c)!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}](/w)\n')), [ - { content: [text('[['), marked('http://x/', link('http://x/')), text('](/c)'), { type: 'blockCard' }, text('](/w)')], type: 'paragraph' }, + assert.deepEqual(content(markdownToAdf(`[${carried}](/w)\n`)), [ + { content: [text('['), marked('http://x/', link('http://x/')), { type: 'placeholder' }, text('](/w)')], type: 'paragraph' }, + ]) + assert.deepEqual(content(markdownToAdf(`[[](/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)[](/c)](/w)](/v)\n')), [image('/v', 'a b [x[http://x/](/c)](/w)')]) })