From 6cbd96748bcbfcb806618d1d5089ee158184dbc1 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 26 Aug 2026 23:53:18 +0200 Subject: [PATCH] Keep the link-opener scan out of emitted syntax, which binds first --- .../combinations/attribute-escapes.json | 15 ++++++++ .../combinations/attribute-escapes.md | 2 ++ src/adf-to-markdown.test.ts | 2 ++ src/markdown-escaping.ts | 35 +++++++++++++++---- todo.md | 3 +- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/corpus/round-trip/combinations/attribute-escapes.json b/corpus/round-trip/combinations/attribute-escapes.json index a9437fa..594712a 100644 --- a/corpus/round-trip/combinations/attribute-escapes.json +++ b/corpus/round-trip/combinations/attribute-escapes.json @@ -34,6 +34,21 @@ } }, "type": "extension" + }, + { + "content": [ + { + "text": "[a", + "type": "text" + }, + { + "attrs": { + "url": "b](c" + }, + "type": "inlineCard" + } + ], + "type": "paragraph" } ], "type": "doc", diff --git a/corpus/round-trip/combinations/attribute-escapes.md b/corpus/round-trip/combinations/attribute-escapes.md index f9cb2d9..5e5920d 100644 --- a/corpus/round-trip/combinations/attribute-escapes.md +++ b/corpus/round-trip/combinations/attribute-escapes.md @@ -3,3 +3,5 @@ a `b :inlineCard{url="c\u0060d"} :inlineCard{url="https://example.com/s?q=a\u0026amp;b\u003cc"} ::extension {extensionKey=jira parameters="{\"jql\":\"a\u007cb \u0060c\u0060 \u0026 d \u003c e\"}"} + +[a:inlineCard{url="b](c"} diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index 1f98ba0..9e5aa0d 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -256,6 +256,8 @@ test('escapes a literal delimiter that would merge with an emitted one', () => { assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n') assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n') assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\![x](https://example.com/)\n') + assert.equal(emitted({ text: '[a', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '[a[x](https://example.com/)\n') + assert.equal(emitted({ text: '[a](b)', type: 'text' }), '\\[a](b)\n') assert.equal(emitted(marked('x', { type: 'underline' }), { text: '{}', type: 'text' }), ':underline[x]\\{}\n') assert.equal(emitted({ attrs: { text: '' }, type: 'status' }, { text: '{color=red}', type: 'text' }), ':status[]\\{color=red}\n') assert.equal(emitted(marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' }), { text: '{}', type: 'text' }), '[x](https://example.com/){}\n') diff --git a/src/markdown-escaping.ts b/src/markdown-escaping.ts index d278909..d7effd3 100644 --- a/src/markdown-escaping.ts +++ b/src/markdown-escaping.ts @@ -19,7 +19,7 @@ const delimiters = ['*', '_', '`', '~'] const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/] const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/ -const linkOpener = /\](?=[([:])/ +const linkTextCloser = /[([:]/ const unicodePunctuation = /[\p{P}\p{S}]/u export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { @@ -65,7 +65,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A for (let index = 0; index < scan.length; index += 1) { const escaping = escapings[index] const escapable = escaping === 'backslash' || escaping === 'bracketed' - if (escapable && (mergesWithSyntax(scan, escapings, index) || opensConstruct(scan, index, escaping === 'bracketed', container, escaped))) { + if (escapable && (mergesWithSyntax(scan, escapings, index) || opensConstruct(scan, escapings, index, escaping === 'bracketed', container, escaped))) { output += '\\' escaped.add(index) } @@ -131,10 +131,17 @@ function isSyntax(escaping: InlineEscaping | undefined): boolean { return escaping === 'none' } -function opensConstruct(scan: string, index: number, inBrackets: boolean, container: LineContainer, escaped: ReadonlySet): boolean { +function opensConstruct( + scan: string, + escapings: readonly (InlineEscaping | undefined)[], + index: number, + inBrackets: boolean, + container: LineContainer, + escaped: ReadonlySet, +): boolean { if (container === 'heading' && closesHeading(scan, index)) return true if (container === 'paragraph' && claimsLineStart(scan, index)) return true - return claimsCharacter(scan, index, inBrackets, container, escaped) + return claimsCharacter(scan, escapings, index, inBrackets, container, escaped) } function claimsLineStart(scan: string, index: number): boolean { @@ -150,7 +157,14 @@ function closesHeading(scan: string, index: number): boolean { return index === 0 || /[ \t]/.test(scan.charAt(index - 1)) } -function claimsCharacter(scan: string, index: number, inBrackets: boolean, container: LineContainer, escaped: ReadonlySet): boolean { +function claimsCharacter( + scan: string, + escapings: readonly (InlineEscaping | undefined)[], + index: number, + inBrackets: boolean, + container: LineContainer, + escaped: ReadonlySet, +): boolean { const character = scan.charAt(index) const rest = scan.slice(index) if (inBrackets && (character === '[' || character === ']')) return true @@ -159,12 +173,21 @@ function claimsCharacter(scan: string, index: number, inBrackets: boolean, conta if (character === '&') return startsEntityReference(rest) if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest)) if (character === ':') return inlineDirectiveOpener.test(rest) - if (character === '[') return linkOpener.test(rest) + if (character === '[') return opensLink(scan, escapings, index) if (character === '`') return opensCodeSpan(scan, index, escaped) if (character === '*' || character === '_' || character === '~') return opensEmphasis(scan, index, escaped) return false } +// A `]` the emitter spelled closes the construct it belongs to, which binds first — never link text. +function opensLink(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean { + for (let cursor = index + 1; cursor < scan.length; cursor += 1) { + if (scan.charAt(cursor) !== ']' || isSyntax(escapings[cursor])) continue + if (linkTextCloser.test(scan.charAt(cursor + 1))) return true + } + return false +} + function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet): boolean { if (!startsRun(scan, index, escaped)) return false const length = runLength(scan, index) diff --git a/todo.md b/todo.md index 8a53803..52b09a5 100644 --- a/todo.md +++ b/todo.md @@ -101,7 +101,8 @@ detail is settled at its own milestone. inline alike — the constructs those four open all bind at or before a directive does, and nothing else reaches into `{attrs}`. Emitted attributes being inert leaves 3 free to keep CommonMark's own precedence between a directive and a code span, and collapsed - `escaping: 'attribute'` into `none`. + `escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax + to match: a `](` inside a directive escapes no text `[`. - [ ] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section cannot spell — an attrs key no section lists, a value that is not the section's type, an arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,