From 8d445f059818fd0dd397a507d020ea1e710ae36a Mon Sep 17 00:00:00 2001 From: Deshi Rahim Date: Tue, 21 Jul 2026 16:36:44 -0700 Subject: [PATCH 1/4] fix: handle bracketed spans inside link labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any link label containing a bracketed span, e.g. `[[1] Document](#)` (common in LLM-generated citation links), threw "inline rule didn't increment state.pos": the comark_inline_span rule's silent branch returned true without advancing state.pos, violating markdown-it's inline-rule contract when parseLinkLabel -> skipToken probed the label. Return false in silent mode instead (same pattern as emphasis / strikethrough) so the label's own bracket-depth tracking consumes the nested `[...]` and the outer link parses. Advancing state.pos in the silent branch was not enough — parseLinkLabel treats a multi-char silent match starting with `[` as a nested link and aborts the label. Also keep bare `[text]` literal inside link labels: the span rule ate the inner `[1]` when the label was re-tokenized, rendering link text "1 Document" instead of the plain-markdown literal "[1] Document". markdown-exit's link rule tokenizes label children inside state.linkLevel++/--, so when linkLevel > 0 only match explicit `[text]{attrs}` spans. Bare `[text]` outside links still becomes a span (MDC behavior, per compare-parsers tests). Co-Authored-By: Claude Fable 5 --- packages/comark/src/plugins/syntax.ts | 9 ++++- .../comark/test/parse/inline-span.test.ts | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 packages/comark/test/parse/inline-span.test.ts diff --git a/packages/comark/src/plugins/syntax.ts b/packages/comark/src/plugins/syntax.ts index 742ff224..a96295f9 100644 --- a/packages/comark/src/plugins/syntax.ts +++ b/packages/comark/src/plugins/syntax.ts @@ -449,7 +449,14 @@ const markdownItInlineSpan: PluginSimple = (md) => { const nextChar = state.src[index + 1] if (nextChar === '(' || nextChar === '[') return false - if (silent) return true + // Inside a link label, bare `[text]` stays literal (plain-markdown behavior, + // e.g. `[[1] Document](#)`); only an explicit `[text]{attrs}` span matches + if (state.linkLevel > 0 && nextChar !== '{') return false + + // Returning `true` without advancing `state.pos` breaks the inline-rule + // contract (`skipToken` throws); returning `false` lets `parseLinkLabel`'s + // own depth tracking consume nested brackets and the outer link parse + if (silent) return false state.push('mdc_inline_span', 'span', 1) diff --git a/packages/comark/test/parse/inline-span.test.ts b/packages/comark/test/parse/inline-span.test.ts new file mode 100644 index 00000000..81bf6168 --- /dev/null +++ b/packages/comark/test/parse/inline-span.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { parse } from '../../src/parse' + +// Regression tests for comarkdown/comark#287 — `comark_inline_span` and link +// labels containing brackets (e.g. LLM citation links like `[[1] Document](#)`): the rule's +// silent branch used to throw "inline rule didn't increment state.pos" via +// `parseLinkLabel` -> `skipToken`, and bare `[text]` inside a label was eaten +// as a span instead of staying literal like plain markdown. Explicit +// `[text]{attrs}` spans still work everywhere, bare spans outside labels too. +describe('comark_inline_span inside link labels (regression)', () => { + it('keeps a bare bracketed prefix in a link label literal, not a span', async () => { + const tree = await parse('[[1] Document](#)') + expect(tree.nodes).toEqual([['p', {}, ['a', { href: '#' }, '[1] Document']]]) + }) + + it('keeps a bare nested-bracket label literal, not a span', async () => { + const tree = await parse('[[link-name] more](https://example.com)') + expect(tree.nodes).toEqual([['p', {}, ['a', { href: 'https://example.com' }, '[link-name] more']]]) + }) + + it('still parses a plain span with a class outside of a link (no regression)', async () => { + const tree = await parse('[content]{.class}') + expect(tree.nodes).toEqual([['p', {}, ['span', { class: 'class' }, 'content']]]) + }) + + it('still parses an explicit `{attrs}` span nested inside a link label', async () => { + const tree = await parse('[a [x]{.y} b](#)') + expect(tree.nodes).toEqual([['p', {}, ['a', { href: '#' }, 'a ', ['span', { class: 'y' }, 'x'], ' b']]]) + }) + + it('still parses a bare span outside of a link label (no regression)', async () => { + const tree = await parse('Hello [World]') + expect(tree.nodes).toEqual([['p', {}, 'Hello ', ['span', {}, 'World']]]) + }) +}) From 6f870bea24e425f3a50a591484e714573ec07ff5 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 27 Jul 2026 13:52:56 +0200 Subject: [PATCH 2/4] update SPEC --- packages/comark/SPEC/COMARK/link-with-span.md | 76 +++++++++++++++++ packages/comark/SPEC/common-mark/links.md | 82 +++++++++++++++++++ .../src/internal/stringify/handlers/mdc.ts | 7 +- .../comark/test/parse/inline-span.test.ts | 35 -------- 4 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 packages/comark/SPEC/COMARK/link-with-span.md create mode 100644 packages/comark/SPEC/common-mark/links.md delete mode 100644 packages/comark/test/parse/inline-span.test.ts diff --git a/packages/comark/SPEC/COMARK/link-with-span.md b/packages/comark/SPEC/COMARK/link-with-span.md new file mode 100644 index 00000000..9d41e107 --- /dev/null +++ b/packages/comark/SPEC/COMARK/link-with-span.md @@ -0,0 +1,76 @@ +## Input + +```md +[[1]{} Document](#) + +[[link-name]{.cls} more](https://example.com) +``` + +## AST + +```json + +{ + "frontmatter":{ + + }, + "meta":{ + + }, + "nodes":[ + [ + "p", + { + + }, + [ + "a", + { + "href":"#" + }, + [ + "span", + {}, + "1" + ], + " Document" + ] + ], + [ + "p", + { + + }, + [ + "a", + { + "href":"https://example.com" + }, + [ + "span", + { + "class": "cls" + }, + "link-name" + ], + " more" + ] + ] + ] +} +``` + +## HTML + +```html +

1 Document

+

link-name more

+``` + +## Markdown + +```md +[[1]{} Document](#) + +[[link-name]{.cls} more](https://example.com) +``` diff --git a/packages/comark/SPEC/common-mark/links.md b/packages/comark/SPEC/common-mark/links.md new file mode 100644 index 00000000..96125775 --- /dev/null +++ b/packages/comark/SPEC/common-mark/links.md @@ -0,0 +1,82 @@ +## Input + +```md +[Document](#) + +[[1] Document](#) + +[[link-name] more](https://example.com) +``` + +## AST + +```json + +{ + "frontmatter":{ + + }, + "meta":{ + + }, + "nodes":[ + [ + "p", + { + + }, + [ + "a", + { + "href":"#" + }, + "Document" + ] + ], + [ + "p", + { + + }, + [ + "a", + { + "href":"#" + }, + "[1] Document" + ] + ], + [ + "p", + { + + }, + [ + "a", + { + "href":"https://example.com" + }, + "[link-name] more" + ] + ] + ] +} +``` + +## HTML + +```html +

Document

+

[1] Document

+

[link-name] more

+``` + +## Markdown + +```md +[Document](#) + +[\[1\] Document](#) + +[\[link-name\] more](https://example.com) +``` diff --git a/packages/comark/src/internal/stringify/handlers/mdc.ts b/packages/comark/src/internal/stringify/handlers/mdc.ts index 70b6fb59..433417a0 100644 --- a/packages/comark/src/internal/stringify/handlers/mdc.ts +++ b/packages/comark/src/internal/stringify/handlers/mdc.ts @@ -36,9 +36,14 @@ export async function mdc(node: ComarkElement, state: State, parent?: ComarkElem } content = content.trimEnd() - const attrs = attributeEntries.length > 0 ? comarkAttributes(attributes) : '' + let attrs = attributeEntries.length > 0 ? comarkAttributes(attributes) : '' if (tag === 'span') { + if (!attrs && parent?.[0] == 'a') { + // Add empty attributes syntax to end of spans inside Anchor links + // This will force Comark to parse them as span and prevent conflict + attrs ||= "{}" + } return `[${content}]${attrs}` + (inline ? '' : state.context.blockSeparator) } diff --git a/packages/comark/test/parse/inline-span.test.ts b/packages/comark/test/parse/inline-span.test.ts deleted file mode 100644 index 81bf6168..00000000 --- a/packages/comark/test/parse/inline-span.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parse } from '../../src/parse' - -// Regression tests for comarkdown/comark#287 — `comark_inline_span` and link -// labels containing brackets (e.g. LLM citation links like `[[1] Document](#)`): the rule's -// silent branch used to throw "inline rule didn't increment state.pos" via -// `parseLinkLabel` -> `skipToken`, and bare `[text]` inside a label was eaten -// as a span instead of staying literal like plain markdown. Explicit -// `[text]{attrs}` spans still work everywhere, bare spans outside labels too. -describe('comark_inline_span inside link labels (regression)', () => { - it('keeps a bare bracketed prefix in a link label literal, not a span', async () => { - const tree = await parse('[[1] Document](#)') - expect(tree.nodes).toEqual([['p', {}, ['a', { href: '#' }, '[1] Document']]]) - }) - - it('keeps a bare nested-bracket label literal, not a span', async () => { - const tree = await parse('[[link-name] more](https://example.com)') - expect(tree.nodes).toEqual([['p', {}, ['a', { href: 'https://example.com' }, '[link-name] more']]]) - }) - - it('still parses a plain span with a class outside of a link (no regression)', async () => { - const tree = await parse('[content]{.class}') - expect(tree.nodes).toEqual([['p', {}, ['span', { class: 'class' }, 'content']]]) - }) - - it('still parses an explicit `{attrs}` span nested inside a link label', async () => { - const tree = await parse('[a [x]{.y} b](#)') - expect(tree.nodes).toEqual([['p', {}, ['a', { href: '#' }, 'a ', ['span', { class: 'y' }, 'x'], ' b']]]) - }) - - it('still parses a bare span outside of a link label (no regression)', async () => { - const tree = await parse('Hello [World]') - expect(tree.nodes).toEqual([['p', {}, 'Hello ', ['span', {}, 'World']]]) - }) -}) From b6a3a95dda32a87251293f89f24753a338725351 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 27 Jul 2026 14:00:44 +0200 Subject: [PATCH 3/4] lint: fix --- packages/comark/src/internal/stringify/handlers/mdc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/comark/src/internal/stringify/handlers/mdc.ts b/packages/comark/src/internal/stringify/handlers/mdc.ts index 433417a0..5f8e4ce2 100644 --- a/packages/comark/src/internal/stringify/handlers/mdc.ts +++ b/packages/comark/src/internal/stringify/handlers/mdc.ts @@ -42,7 +42,7 @@ export async function mdc(node: ComarkElement, state: State, parent?: ComarkElem if (!attrs && parent?.[0] == 'a') { // Add empty attributes syntax to end of spans inside Anchor links // This will force Comark to parse them as span and prevent conflict - attrs ||= "{}" + attrs ||= '{}' } return `[${content}]${attrs}` + (inline ? '' : state.context.blockSeparator) } From 3f309c61539f4b8adf395133d970d61670a9bee7 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 27 Jul 2026 14:05:52 +0200 Subject: [PATCH 4/4] Apply suggestion from @farnabaz --- packages/comark/src/plugins/syntax.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/comark/src/plugins/syntax.ts b/packages/comark/src/plugins/syntax.ts index a96295f9..0d22a61e 100644 --- a/packages/comark/src/plugins/syntax.ts +++ b/packages/comark/src/plugins/syntax.ts @@ -453,9 +453,7 @@ const markdownItInlineSpan: PluginSimple = (md) => { // e.g. `[[1] Document](#)`); only an explicit `[text]{attrs}` span matches if (state.linkLevel > 0 && nextChar !== '{') return false - // Returning `true` without advancing `state.pos` breaks the inline-rule - // contract (`skipToken` throws); returning `false` lets `parseLinkLabel`'s - // own depth tracking consume nested brackets and the outer link parse + // Returning `false` lets `parseLinkLabel`'s own depth tracking consume nested brackets and the outer link parse if (silent) return false state.push('mdc_inline_span', 'span', 1)