Skip to content

Commit e8f8960

Browse files
Trim a url's closing punctuation one character at a time
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) <noreply@anthropic.com>
1 parent eff353c commit e8f8960

2 files changed

Lines changed: 33 additions & 17 deletions

File tree

‎src/paste-markdown-html.ts‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,10 @@ interface LinkSpan {
102102
length: number
103103
}
104104

105-
// Whether text starts with a scheme. Deliberately laxer than paste-markdown-link.ts's `isURL`,
106-
// which requires the whole string to round-trip through `new URL()`: the question here is whether
107-
// splicing into this text would corrupt a URL, not whether it is a valid one.
105+
// Whether text starts with a scheme and `//`. Deliberately laxer than paste-markdown-link.ts's
106+
// `isURL`, which requires the whole string to round-trip through `new URL()`: the question here is
107+
// whether splicing into this text would corrupt a URL, not whether it is a valid one. The `//` is
108+
// what keeps prose like `TODO:foo` from reading as a URL, at the cost of `mailto:` and `urn:`.
108109
function looksLikeURL(text: string): boolean {
109110
return /^[a-z][a-z\d+.-]*:\/\//i.test(text)
110111
}
@@ -121,14 +122,21 @@ function tokenAt(markdown: string, index: number): LinkSpan {
121122
const OPENING_PUNCTUATION = '([{<"\''
122123
const CLOSING_PUNCTUATION = ')]}>"\'.,;:!?'
123124

124-
// The same span without the punctuation prose wraps a URL in, so `(https://example.com/a)` and
125-
// `https://example.com/a.` are recognized as the URL they contain.
126-
function withoutWrappingPunctuation(markdown: string, span: LinkSpan): LinkSpan {
125+
// The spans the URL in this token could occupy once the punctuation prose wraps it in is off, so
126+
// `(https://example.com/a)` and `https://example.com/a.` are recognized as the URL they contain.
127+
// Opening punctuation all comes off, since a URL never starts with any. Closing punctuation comes
128+
// off one character at a time, longest span first, since a URL can end in a bracket of its own
129+
// that prose then closes over again: `(…/wiki/Ruby_(programming_language))`.
130+
function withoutWrappingPunctuation(markdown: string, span: LinkSpan): LinkSpan[] {
127131
let start = span.index
128132
let end = span.index + span.length
129133
while (start < end && OPENING_PUNCTUATION.includes(markdown[start])) start++
130-
while (end > start && CLOSING_PUNCTUATION.includes(markdown[end - 1])) end--
131-
return {index: start, length: end - start}
134+
const spans = [{index: start, length: end - start}]
135+
while (end > start && CLOSING_PUNCTUATION.includes(markdown[end - 1])) {
136+
end--
137+
spans.push({index: start, length: end - start})
138+
}
139+
return spans
132140
}
133141

134142
// 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
142150

143151
const token = tokenAt(markdown, index)
144152
if (token.length !== label.length) {
145-
// The token as it stands first, since a URL can end in a bracket of its own
146-
// (`…/wiki/Ruby_(programming_language)`), then the same token with wrapping punctuation off.
147-
for (const span of [token, withoutWrappingPunctuation(markdown, token)]) {
148-
const text = markdown.slice(span.index, span.index + span.length)
149-
// The label describes this whole URL, so the link replaces the whole URL.
150-
if (areEqualLinks(href, text)) return span
151-
// Splicing inside a URL is never right, so a label inside one that is not this link's own
152-
// href leaves the paste alone rather than corrupting it.
153-
if (looksLikeURL(text)) return null
153+
const spans = withoutWrappingPunctuation(markdown, token)
154+
// The label describes this whole URL, so the link replaces the whole URL.
155+
for (const span of spans) {
156+
if (areEqualLinks(href, markdown.slice(span.index, span.index + span.length))) return span
154157
}
158+
// Splicing inside a URL is never right, so a label inside one that is not this link's own
159+
// href leaves the paste alone rather than corrupting it.
160+
if (looksLikeURL(markdown.slice(spans[0].index, spans[0].index + spans[0].length))) return null
155161
}
156162

157163
// The label is the whole token, or abuts other text as `<a href="…">foo</a>bar` does alongside

‎test/test.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,16 @@ describe('paste-markdown', function () {
393393
assert.equal(textarea.value, markdownLink)
394394
})
395395

396+
it('links a url the sentence ends right after', function () {
397+
const url = 'https://github.com/owner/repo/blob/main/a.js#L7'
398+
// eslint-disable-next-line github/unescaped-html-literal
399+
const link = `<meta charset='utf-8'><a href="${url}">repo/blob/main/a.js#L7</a>.`
400+
const markdownLink = `[repo/blob/main/a.js#L7](${url}).`
401+
402+
paste(textarea, {'text/html': link, 'text/plain': `${url}.`})
403+
assert.equal(textarea.value, markdownLink)
404+
})
405+
396406
it('links a url that ends in a bracket of its own', function () {
397407
const url = 'https://en.wikipedia.org/wiki/Ruby_(programming_language)'
398408
// eslint-disable-next-line github/unescaped-html-literal

0 commit comments

Comments
 (0)