From 84636e210a732fe4bddaf44190456a311439bd3f Mon Sep 17 00:00:00 2001 From: Pranay Shirolkar Date: Wed, 5 Aug 2026 17:25:11 -0700 Subject: [PATCH 1/6] Don't splice a link into the middle of a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `convertToMarkdown` turns HTML into Markdown by splicing `[label](href)` over the label at the offset where it finds that label in the plain-text flavor. That assumes `text/plain` is a flattening of `text/html`, which holds for prose containing a link but not for how many native apps and clipboard tools copy a link: `text/plain` is the URL, and `text/html` is an anchor labelled with a shortened rendering of it. The label's only occurrence is then inside the URL, so the `[` lands there: text/plain: https://github.com/owner/repo/blob/main/a.js#L7 text/html: repo/blob/main/a.js#L7 pasted: https://github.com/owner/[repo/blob/main/a.js#L7](https://github.com/owner/repo/blob/main/a.js#L7) Such a label describes the whole URL, so replace the whole whitespace-delimited token when it is this link's own href — which yields the link the clipboard meant, `[repo/blob/main/a.js#L7](https://…#L7)`. When the token is a URL but *not* this href, leave the paste alone rather than corrupt it; splicing inside a URL is never right. Every other case keeps the label's own occurrence, so a label that merely abuts other text (`foobar` pasted alongside `foobar`) is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- src/paste-markdown-html.ts | 56 ++++++++++++++++++++++++++++++++++---- test/test.js | 22 +++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts index bbd2d24..6d1389a 100644 --- a/src/paste-markdown-html.ts +++ b/src/paste-markdown-html.ts @@ -79,15 +79,15 @@ function convertToMarkdown(plaintext: string, walker: TreeWalker): string { continue } - // Find the index where "text" is found in "markdown" _after_ "markdownIgnoreBeforeIndex" - const markdownFoundIndex = markdown.indexOf(text, markdownIgnoreBeforeIndex) + // Find the part of "markdown" this link replaces, at or after "markdownIgnoreBeforeIndex" + const span = findLinkSpan(markdown, text, markdownIgnoreBeforeIndex, currentNode.href) - if (markdownFoundIndex >= 0) { + if (span) { const markdownLink = linkify(currentNode, text) // Transform 'example link plus more text' into 'example [link](example link) plus more text' // Method: 'example [link](example link) plus more text' = 'example ' + '[link](example link)' + ' plus more text' - markdown = markdown.slice(0, markdownFoundIndex) + markdownLink + markdown.slice(markdownFoundIndex + text.length) - markdownIgnoreBeforeIndex = markdownFoundIndex + markdownLink.length + markdown = markdown.slice(0, span.index) + markdownLink + markdown.slice(span.index + span.length) + markdownIgnoreBeforeIndex = span.index + markdownLink.length } currentNode = walker.nextNode() @@ -97,6 +97,52 @@ function convertToMarkdown(plaintext: string, walker: TreeWalker): string { return index === NODE_LIMIT ? plaintext : markdown } +interface LinkSpan { + index: number + length: number +} + +function isURL(text: string): boolean { + return /^[a-z][a-z\d+.-]*:\/\//i.test(text) +} + +// The whitespace-delimited token of "markdown" containing "index". +function tokenAt(markdown: string, index: number): LinkSpan { + let start = index + let end = index + while (start > 0 && !/\s/.test(markdown[start - 1])) start-- + while (end < markdown.length && !/\s/.test(markdown[end])) end++ + return {index: start, length: end - start} +} + +// Which part of the plaintext this link replaces. Usually the label's own occurrence — but a label +// can be a shortened rendering of the URL itself, which is how a link reaches the clipboard from +// many native apps and clipboard tools: `text/plain` is the URL, and `text/html` is an anchor +// labelled with part of it. The label's only occurrence is then inside the URL, and splicing there +// plants a `[` in the middle of it: +// +// https://github.com/owner/[repo/blob/main/a.js](https://github.com/owner/repo/blob/main/a.js) +// +// Such a label describes the whole URL, so the link replaces the whole token instead. +function findLinkSpan(markdown: string, label: string, from: number, href: string): LinkSpan | null { + const index = markdown.indexOf(label, from) + if (index < 0) return null + + const token = tokenAt(markdown, index) + if (token.length === label.length) return {index, length: label.length} + + const tokenText = markdown.slice(token.index, token.index + token.length) + if (areEqualLinks(href, tokenText)) return token + + // Splicing inside a URL is never right, so a label found inside one that is not this link's own + // href leaves the paste alone rather than corrupting it. + if (isURL(tokenText)) return null + + // The label abuts other text, as `foobar` pasted alongside `foobar` does. Its own + // occurrence is the right span. + return {index, length: label.length} +} + function isWithinUserMention(textarea: HTMLTextAreaElement): boolean { const selectionStart = textarea.selectionStart || 0 if (selectionStart === 0) { diff --git a/test/test.js b/test/test.js index dcf266a..78d54c2 100644 --- a/test/test.js +++ b/test/test.js @@ -370,6 +370,28 @@ describe('paste-markdown', function () { assert.equal(textarea.value, markdownSentence) }) + it('links the whole url when the label is a shortened rendering of it', function () { + const url = 'https://github.com/owner/repo/blob/main/a.js#L7' + // eslint-disable-next-line github/unescaped-html-literal + const link = `repo/blob/main/a.js#L7` + const markdownLink = `[repo/blob/main/a.js#L7](${url})` + + // A link copied by a native app or clipboard tool: the URL as text/plain, an anchor + // labelled with part of it as text/html. Splicing at the label's offset inside the URL + // used to produce `https://github.com/owner/[repo/blob/main/a.js#L7](…)`. + paste(textarea, {'text/html': link, 'text/plain': url}) + assert.equal(textarea.value, markdownLink) + }) + + it("doesn't splice a link inside a url that is not its own href", function () { + // eslint-disable-next-line github/unescaped-html-literal + const link = `github.com/owner` + const plaintextLink = 'https://github.com/owner/repo' + + paste(textarea, {'text/html': link, 'text/plain': plaintextLink}) + assert.equal(textarea.value, '') + }) + it('skip markdown formatting with (Ctrl+Shift+v)', function () { const data = { 'text/html': tableHtml, From 2efbd166c7621a262277ed21530ca39dca2b5c99 Mon Sep 17 00:00:00 2001 From: Pranay Shirolkar Date: Wed, 5 Aug 2026 17:42:17 -0700 Subject: [PATCH 2/6] Fold the two identical span returns into one The equal-length guard and the fallback both returned the label's own occurrence, so two of four return paths produced the same value and the guard read as a distinct case. Nesting the URL checks under the length comparison leaves one such return, and computes the token's text only where it is used. Also renames `isURL` to `looksLikeURL`. paste-markdown-link.ts already has an `isURL` that requires the whole string to round-trip through `new URL()`; this one only tests for a scheme, because the question here is whether splicing into the text would corrupt a URL rather than whether it is a valid one. The shared name invited a future consolidation that would change behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- src/paste-markdown-html.ts | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts index 6d1389a..7044d1f 100644 --- a/src/paste-markdown-html.ts +++ b/src/paste-markdown-html.ts @@ -102,7 +102,10 @@ interface LinkSpan { length: number } -function isURL(text: string): boolean { +// Whether text starts with a scheme. Deliberately laxer than paste-markdown-link.ts's `isURL`, +// which requires the whole string to round-trip through `new URL()`: the question here is whether +// splicing into this text would corrupt a URL, not whether it is a valid one. +function looksLikeURL(text: string): boolean { return /^[a-z][a-z\d+.-]*:\/\//i.test(text) } @@ -115,31 +118,27 @@ function tokenAt(markdown: string, index: number): LinkSpan { return {index: start, length: end - start} } -// Which part of the plaintext this link replaces. Usually the label's own occurrence — but a label -// can be a shortened rendering of the URL itself, which is how a link reaches the clipboard from -// many native apps and clipboard tools: `text/plain` is the URL, and `text/html` is an anchor -// labelled with part of it. The label's only occurrence is then inside the URL, and splicing there +// Which part of the plaintext this link replaces. Usually the label's own occurrence, but a label +// that is a shortened rendering of its own URL occurs only inside that URL, and splicing there // plants a `[` in the middle of it: // // https://github.com/owner/[repo/blob/main/a.js](https://github.com/owner/repo/blob/main/a.js) -// -// Such a label describes the whole URL, so the link replaces the whole token instead. function findLinkSpan(markdown: string, label: string, from: number, href: string): LinkSpan | null { const index = markdown.indexOf(label, from) if (index < 0) return null const token = tokenAt(markdown, index) - if (token.length === label.length) return {index, length: label.length} - - const tokenText = markdown.slice(token.index, token.index + token.length) - if (areEqualLinks(href, tokenText)) return token - - // Splicing inside a URL is never right, so a label found inside one that is not this link's own - // href leaves the paste alone rather than corrupting it. - if (isURL(tokenText)) return null + if (token.length !== label.length) { + const tokenText = markdown.slice(token.index, token.index + token.length) + // The label describes this whole URL, so the link replaces the whole URL. + if (areEqualLinks(href, tokenText)) return token + // Splicing inside a URL is never right, so a label inside one that is not this link's own href + // leaves the paste alone rather than corrupting it. + if (looksLikeURL(tokenText)) return null + } - // The label abuts other text, as `foobar` pasted alongside `foobar` does. Its own - // occurrence is the right span. + // The label is the whole token, or abuts other text as `foobar` does alongside + // `foobar`. Its own occurrence is the right span. return {index, length: label.length} } From 5fba4e1f78cead4d77ca9028574b0e7153cab306 Mon Sep 17 00:00:00 2001 From: Pranay Shirolkar Date: Wed, 5 Aug 2026 17:52:46 -0700 Subject: [PATCH 3/6] Recognize a url the surrounding prose wraps in punctuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `looksLikeURL` tests for a scheme at offset 0, so `(https://example.com/a)` did not read as a URL: neither branch applied and the label was still spliced inside it, leaving `(https://github.com/owner/[repo](…))`. Try the whitespace-delimited token first and then the same token with wrapping punctuation trimmed off both ends. Order matters: a URL can end in a bracket of its own, as `…/wiki/Ruby_(programming_language)` does, and trimming first would strip that bracket, fail the href comparison, and decline a paste that has a perfectly good link in it. Both orders are covered by tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/paste-markdown-html.ts | 29 +++++++++++++++++++++++------ test/test.js | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts index 7044d1f..ae28cc9 100644 --- a/src/paste-markdown-html.ts +++ b/src/paste-markdown-html.ts @@ -118,6 +118,19 @@ function tokenAt(markdown: string, index: number): LinkSpan { return {index: start, length: end - start} } +const OPENING_PUNCTUATION = '([{<"\'' +const CLOSING_PUNCTUATION = ')]}>"\'.,;:!?' + +// The same span without the punctuation prose wraps a URL in, so `(https://example.com/a)` and +// `https://example.com/a.` are recognized as the URL they contain. +function withoutWrappingPunctuation(markdown: string, span: LinkSpan): LinkSpan { + let start = span.index + let end = span.index + span.length + while (start < end && OPENING_PUNCTUATION.includes(markdown[start])) start++ + while (end > start && CLOSING_PUNCTUATION.includes(markdown[end - 1])) end-- + return {index: start, length: end - start} +} + // Which part of the plaintext this link replaces. Usually the label's own occurrence, but a label // that is a shortened rendering of its own URL occurs only inside that URL, and splicing there // plants a `[` in the middle of it: @@ -129,12 +142,16 @@ function findLinkSpan(markdown: string, label: string, from: number, href: strin const token = tokenAt(markdown, index) if (token.length !== label.length) { - const tokenText = markdown.slice(token.index, token.index + token.length) - // The label describes this whole URL, so the link replaces the whole URL. - if (areEqualLinks(href, tokenText)) return token - // Splicing inside a URL is never right, so a label inside one that is not this link's own href - // leaves the paste alone rather than corrupting it. - if (looksLikeURL(tokenText)) return null + // The token as it stands first, since a URL can end in a bracket of its own + // (`…/wiki/Ruby_(programming_language)`), then the same token with wrapping punctuation off. + for (const span of [token, withoutWrappingPunctuation(markdown, token)]) { + const text = markdown.slice(span.index, span.index + span.length) + // The label describes this whole URL, so the link replaces the whole URL. + if (areEqualLinks(href, text)) return span + // Splicing inside a URL is never right, so a label inside one that is not this link's own + // href leaves the paste alone rather than corrupting it. + if (looksLikeURL(text)) return null + } } // The label is the whole token, or abuts other text as `foobar` does alongside diff --git a/test/test.js b/test/test.js index 78d54c2..cd13538 100644 --- a/test/test.js +++ b/test/test.js @@ -383,6 +383,26 @@ describe('paste-markdown', function () { assert.equal(textarea.value, markdownLink) }) + it('links a url the surrounding prose wraps in punctuation', function () { + const url = 'https://github.com/owner/repo/blob/main/a.js#L7' + // eslint-disable-next-line github/unescaped-html-literal + const link = `(repo/blob/main/a.js#L7)` + const markdownLink = `([repo/blob/main/a.js#L7](${url}))` + + paste(textarea, {'text/html': link, 'text/plain': `(${url})`}) + assert.equal(textarea.value, markdownLink) + }) + + it('links a url that ends in a bracket of its own', function () { + const url = 'https://en.wikipedia.org/wiki/Ruby_(programming_language)' + // eslint-disable-next-line github/unescaped-html-literal + const link = `wiki/Ruby_(programming_language)` + const markdownLink = `[wiki/Ruby_(programming_language)](${url})` + + paste(textarea, {'text/html': link, 'text/plain': url}) + assert.equal(textarea.value, markdownLink) + }) + it("doesn't splice a link inside a url that is not its own href", function () { // eslint-disable-next-line github/unescaped-html-literal const link = `github.com/owner` From eff353c2089e79ef6bdd548b8334cc80dd14953a Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Thu, 27 Aug 2026 01:50:39 -0400 Subject: [PATCH 4/6] Add URL punctuation regression test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 29a3ad99-f79c-4dc7-8a8e-24530c3e3c6b --- test/test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/test.js b/test/test.js index cd13538..b88b942 100644 --- a/test/test.js +++ b/test/test.js @@ -403,6 +403,16 @@ describe('paste-markdown', function () { assert.equal(textarea.value, markdownLink) }) + it('links a url ending in the same punctuation that surrounds it', function () { + const url = 'https://en.wikipedia.org/wiki/Ruby_(programming_language)' + // eslint-disable-next-line github/unescaped-html-literal + const link = `(wiki/Ruby_(programming_language))` + const markdownLink = `([wiki/Ruby_(programming_language)](${url}))` + + paste(textarea, {'text/html': link, 'text/plain': `(${url})`}) + assert.equal(textarea.value, markdownLink) + }) + it("doesn't splice a link inside a url that is not its own href", function () { // eslint-disable-next-line github/unescaped-html-literal const link = `github.com/owner` From e8f896049a87c8165b8aa51005c52ad589db8429 Mon Sep 17 00:00:00 2001 From: Pranay Shirolkar Date: Wed, 14 Oct 2026 12:28:06 -0700 Subject: [PATCH 5/6] Trim a url's closing punctuation one character at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A url that ends in a bracket of its own, wrapped by prose that closes over it again, matched neither the token as it stood nor the token with every closing character trimmed: `(…/wiki/Ruby_(programming_language))` lost its own `)` to the trim, and the paste was declined. The candidates are now each length from the whole token down to the fully trimmed one, so the url's own bracket can stay. Every candidate is compared against the href before any of them is taken as some other url. Declining as soon as the untrimmed token looked like a url meant a link followed by a full stop, `https://…/a.js#L7.`, was declined too. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/paste-markdown-html.ts | 40 ++++++++++++++++++++++---------------- test/test.js | 10 ++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts index ae28cc9..10cb012 100644 --- a/src/paste-markdown-html.ts +++ b/src/paste-markdown-html.ts @@ -102,9 +102,10 @@ interface LinkSpan { length: number } -// Whether text starts with a scheme. Deliberately laxer than paste-markdown-link.ts's `isURL`, -// which requires the whole string to round-trip through `new URL()`: the question here is whether -// splicing into this text would corrupt a URL, not whether it is a valid one. +// Whether text starts with a scheme and `//`. Deliberately laxer than paste-markdown-link.ts's +// `isURL`, which requires the whole string to round-trip through `new URL()`: the question here is +// whether splicing into this text would corrupt a URL, not whether it is a valid one. The `//` is +// what keeps prose like `TODO:foo` from reading as a URL, at the cost of `mailto:` and `urn:`. function looksLikeURL(text: string): boolean { return /^[a-z][a-z\d+.-]*:\/\//i.test(text) } @@ -121,14 +122,21 @@ function tokenAt(markdown: string, index: number): LinkSpan { const OPENING_PUNCTUATION = '([{<"\'' const CLOSING_PUNCTUATION = ')]}>"\'.,;:!?' -// The same span without the punctuation prose wraps a URL in, so `(https://example.com/a)` and -// `https://example.com/a.` are recognized as the URL they contain. -function withoutWrappingPunctuation(markdown: string, span: LinkSpan): LinkSpan { +// The spans the URL in this token could occupy once the punctuation prose wraps it in is off, so +// `(https://example.com/a)` and `https://example.com/a.` are recognized as the URL they contain. +// Opening punctuation all comes off, since a URL never starts with any. Closing punctuation comes +// off one character at a time, longest span first, since a URL can end in a bracket of its own +// that prose then closes over again: `(…/wiki/Ruby_(programming_language))`. +function withoutWrappingPunctuation(markdown: string, span: LinkSpan): LinkSpan[] { let start = span.index let end = span.index + span.length while (start < end && OPENING_PUNCTUATION.includes(markdown[start])) start++ - while (end > start && CLOSING_PUNCTUATION.includes(markdown[end - 1])) end-- - return {index: start, length: end - start} + const spans = [{index: start, length: end - start}] + while (end > start && CLOSING_PUNCTUATION.includes(markdown[end - 1])) { + end-- + spans.push({index: start, length: end - start}) + } + return spans } // Which part of the plaintext this link replaces. Usually the label's own occurrence, but a label @@ -142,16 +150,14 @@ function findLinkSpan(markdown: string, label: string, from: number, href: strin const token = tokenAt(markdown, index) if (token.length !== label.length) { - // The token as it stands first, since a URL can end in a bracket of its own - // (`…/wiki/Ruby_(programming_language)`), then the same token with wrapping punctuation off. - for (const span of [token, withoutWrappingPunctuation(markdown, token)]) { - const text = markdown.slice(span.index, span.index + span.length) - // The label describes this whole URL, so the link replaces the whole URL. - if (areEqualLinks(href, text)) return span - // Splicing inside a URL is never right, so a label inside one that is not this link's own - // href leaves the paste alone rather than corrupting it. - if (looksLikeURL(text)) return null + const spans = withoutWrappingPunctuation(markdown, token) + // The label describes this whole URL, so the link replaces the whole URL. + for (const span of spans) { + if (areEqualLinks(href, markdown.slice(span.index, span.index + span.length))) return span } + // Splicing inside a URL is never right, so a label inside one that is not this link's own + // href leaves the paste alone rather than corrupting it. + if (looksLikeURL(markdown.slice(spans[0].index, spans[0].index + spans[0].length))) return null } // The label is the whole token, or abuts other text as `foobar` does alongside diff --git a/test/test.js b/test/test.js index b88b942..fc9ceda 100644 --- a/test/test.js +++ b/test/test.js @@ -393,6 +393,16 @@ describe('paste-markdown', function () { assert.equal(textarea.value, markdownLink) }) + it('links a url the sentence ends right after', function () { + const url = 'https://github.com/owner/repo/blob/main/a.js#L7' + // eslint-disable-next-line github/unescaped-html-literal + const link = `repo/blob/main/a.js#L7.` + const markdownLink = `[repo/blob/main/a.js#L7](${url}).` + + paste(textarea, {'text/html': link, 'text/plain': `${url}.`}) + assert.equal(textarea.value, markdownLink) + }) + it('links a url that ends in a bracket of its own', function () { const url = 'https://en.wikipedia.org/wiki/Ruby_(programming_language)' // eslint-disable-next-line github/unescaped-html-literal From edd155a17299e885b2edfb9ee5fb2c1ba515fb99 Mon Sep 17 00:00:00 2001 From: Pranay Shirolkar Date: Thu, 15 Oct 2026 16:39:19 -0700 Subject: [PATCH 6/6] Leave a label that runs past its url's token to its own span A label is matched to the whitespace-delimited token it starts in, but a label with a space in it, `https://example.com/ docs`, runs on into the next token. Treating it as a url inside that token replaced only the url and left ` docs` in twice, or, with a different href, declined a perfectly good link. Such a label isn't inside a url, so its own occurrence is the span, as before this PR. Co-Authored-By: Claude Opus 5.5 --- src/paste-markdown-html.ts | 8 +++++--- test/test.js | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts index 10cb012..ac9b2f3 100644 --- a/src/paste-markdown-html.ts +++ b/src/paste-markdown-html.ts @@ -149,7 +149,9 @@ function findLinkSpan(markdown: string, label: string, from: number, href: strin if (index < 0) return null const token = tokenAt(markdown, index) - if (token.length !== label.length) { + // A label that runs past its token, as `https://example.com/ docs` does, isn't inside a URL. + const labelInsideToken = index + label.length <= token.index + token.length + if (token.length !== label.length && labelInsideToken) { const spans = withoutWrappingPunctuation(markdown, token) // The label describes this whole URL, so the link replaces the whole URL. for (const span of spans) { @@ -160,8 +162,8 @@ function findLinkSpan(markdown: string, label: string, from: number, href: strin if (looksLikeURL(markdown.slice(spans[0].index, spans[0].index + spans[0].length))) return null } - // The label is the whole token, or abuts other text as `foobar` does alongside - // `foobar`. Its own occurrence is the right span. + // The label is the whole token, spans several, or abuts other text as `foobar` + // does alongside `foobar`. Its own occurrence is the right span. return {index, length: label.length} } diff --git a/test/test.js b/test/test.js index fc9ceda..17b850c 100644 --- a/test/test.js +++ b/test/test.js @@ -393,6 +393,24 @@ describe('paste-markdown', function () { assert.equal(textarea.value, markdownLink) }) + it('links a label that starts with its url and goes on past it', function () { + // eslint-disable-next-line github/unescaped-html-literal + const link = `https://example.com/ docs` + const markdownLink = `[https://example.com/ docs](https://example.com/)` + + paste(textarea, {'text/html': link, 'text/plain': 'https://example.com/ docs'}) + assert.equal(textarea.value, markdownLink) + }) + + it('links a label that starts with a url other than its href', function () { + // eslint-disable-next-line github/unescaped-html-literal + const link = `https://example.com/ docs` + const markdownLink = `[https://example.com/ docs](https://example.com/docs)` + + paste(textarea, {'text/html': link, 'text/plain': 'https://example.com/ docs'}) + assert.equal(textarea.value, markdownLink) + }) + it('links a url the sentence ends right after', function () { const url = 'https://github.com/owner/repo/blob/main/a.js#L7' // eslint-disable-next-line github/unescaped-html-literal