From a15e9402400b3213760f18b536397ce1a876f57f 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 6fe6c2dd4f3c49935865f913245b03fb0d842d1c 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 3e8ccb96edd54435032793d90454d47b43f60309 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 225b5fd472dc2d1a9742bc191677d60cbda6dc4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:43:15 +0000 Subject: [PATCH 4/6] Bump ip-address in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [ip-address](https://github.com/beaugunderson/ip-address). Updates `ip-address` from 9.0.5 to 10.4.0 - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v9.0.5...v10.4.0) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.4.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e6cd33..33d7935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5469,15 +5469,11 @@ "dev": true }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "dev": true, "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -6084,13 +6080,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7872,13 +7861,13 @@ } }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -7910,13 +7899,6 @@ "node": ">= 8" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", From 4f01e52fc28b72d9656bad1d5d26aab009a77987 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:39:14 +0000 Subject: [PATCH 5/6] Bump js-yaml in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml). Updates `js-yaml` from 4.3.0 to 4.3.1 - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33d7935..b65b16b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6058,9 +6058,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { From c1fd2dee1b1e0a683d605f5d7523fafbaefb31d3 Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Thu, 27 Aug 2026 01:50:39 -0400 Subject: [PATCH 6/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`