diff --git a/package-lock.json b/package-lock.json
index 2e6cd33..b65b16b 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"
}
@@ -6062,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": [
{
@@ -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",
diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts
index bbd2d24..ae28cc9 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,68 @@ function convertToMarkdown(plaintext: string, walker: TreeWalker): string {
return index === NODE_LIMIT ? plaintext : markdown
}
+interface LinkSpan {
+ index: number
+ 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.
+function looksLikeURL(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}
+}
+
+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:
+//
+// https://github.com/owner/[repo/blob/main/a.js](https://github.com/owner/repo/blob/main/a.js)
+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) {
+ // 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
+ // `foobar`. 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..b88b942 100644
--- a/test/test.js
+++ b/test/test.js
@@ -370,6 +370,58 @@ 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('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('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`
+ 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,