Skip to content

Commit ad306be

Browse files
fix: markdown html links (#1389)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 678f306 commit ad306be

File tree

2 files changed

+60
-28
lines changed

2 files changed

+60
-28
lines changed

app/components/Readme.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ function handleClick(event: MouseEvent) {
144144
@apply decoration-accent text-accent;
145145
}
146146
147-
.readme :deep(a[target='_blank']::after) {
147+
.readme :deep(a[target='_blank']:not(:has(img))::after) {
148148
/* I don't know what kind of sorcery this is, but it ensures this icon can't wrap to a new line on its own. */
149149
content: '__';
150150
@apply inline i-carbon:launch rtl-flip ms-1 opacity-50;

server/utils/readme.ts

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface PlaygroundProvider {
1313
id: string // Provider identifier
1414
name: string
1515
domains: string[] // Associated domains
16+
path?: string
1617
icon?: string // Provider icon name
1718
}
1819

@@ -74,6 +75,13 @@ const PLAYGROUND_PROVIDERS: PlaygroundProvider[] = [
7475
domains: ['vite.new'],
7576
icon: 'vite',
7677
},
78+
{
79+
id: 'typescript-playground',
80+
name: 'TypeScript Playground',
81+
domains: ['typescriptlang.org'],
82+
path: '/play',
83+
icon: 'typescript',
84+
},
7785
]
7886

7987
/**
@@ -86,7 +94,10 @@ function matchPlaygroundProvider(url: string): PlaygroundProvider | null {
8694

8795
for (const provider of PLAYGROUND_PROVIDERS) {
8896
for (const domain of provider.domains) {
89-
if (hostname === domain || hostname.endsWith(`.${domain}`)) {
97+
if (
98+
(hostname === domain || hostname.endsWith(`.${domain}`)) &&
99+
(!provider.path || parsed.pathname.startsWith(provider.path))
100+
) {
90101
return provider
91102
}
92103
}
@@ -210,6 +221,16 @@ const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
210221
return true
211222
}
212223

224+
const replaceHtmlLink = (html: string) => {
225+
return html.replace(/href="([^"]+)"/g, (match, href) => {
226+
if (isNpmJsUrlThatCanBeRedirected(new URL(href, 'https://www.npmjs.com'))) {
227+
const newHref = href.replace(/^https?:\/\/(www\.)?npmjs\.com/, '')
228+
return `href="${newHref}"`
229+
}
230+
return match
231+
})
232+
}
233+
213234
/**
214235
* Resolve a relative URL to an absolute URL.
215236
* If repository info is available, resolve to provider's raw file URLs.
@@ -390,35 +411,15 @@ ${html}
390411
return `<img src="${resolvedHref}"${altAttr}${titleAttr}>`
391412
}
392413

393-
// Resolve link URLs, add security attributes, and collect playground links
414+
// // Resolve link URLs, add security attributes, and collect playground links
394415
renderer.link = function ({ href, title, tokens }: Tokens.Link) {
395-
const resolvedHref = resolveUrl(href, packageName, repoInfo)
396416
const text = this.parser.parseInline(tokens)
397417
const titleAttr = title ? ` title="${title}"` : ''
418+
const plainText = text.replace(/<[^>]*>/g, '').trim()
398419

399-
const isExternal = resolvedHref.startsWith('http://') || resolvedHref.startsWith('https://')
400-
const relAttr = isExternal ? ' rel="nofollow noreferrer noopener"' : ''
401-
const targetAttr = isExternal ? ' target="_blank"' : ''
402-
403-
// Check if this is a playground link
404-
const provider = matchPlaygroundProvider(resolvedHref)
405-
if (provider && !seenUrls.has(resolvedHref)) {
406-
seenUrls.add(resolvedHref)
407-
408-
// Extract label from link text (strip HTML tags for plain text)
409-
const plainText = text.replace(/<[^>]*>/g, '').trim()
410-
411-
collectedLinks.push({
412-
url: resolvedHref,
413-
provider: provider.id,
414-
providerName: provider.name,
415-
label: plainText || title || provider.name,
416-
})
417-
}
418-
419-
const hrefValue = resolvedHref.startsWith('#') ? resolvedHref.toLowerCase() : resolvedHref
420+
const intermediateTitleAttr = `${` data-title-intermediate="${plainText || title}"`}`
420421

421-
return `<a href="${hrefValue}"${titleAttr}${relAttr}${targetAttr}>${text}</a>`
422+
return `<a href="${href}"${titleAttr}${intermediateTitleAttr}>${text}</a>`
422423
}
423424

424425
// GitHub-style callouts: > [!NOTE], > [!TIP], etc.
@@ -436,7 +437,14 @@ ${html}
436437
return `<blockquote>${body}</blockquote>\n`
437438
}
438439

439-
marked.setOptions({ renderer })
440+
marked.setOptions({
441+
renderer,
442+
walkTokens: token => {
443+
if (token.type === 'html') {
444+
token.text = replaceHtmlLink(token.text)
445+
}
446+
},
447+
})
440448

441449
const rawHtml = marked.parse(content) as string
442450

@@ -494,11 +502,35 @@ ${html}
494502
return { tagName, attribs }
495503
},
496504
a: (tagName, attribs) => {
505+
if (!attribs.href) {
506+
return { tagName, attribs }
507+
}
508+
509+
const resolvedHref = resolveUrl(attribs.href, packageName, repoInfo)
510+
511+
const provider = matchPlaygroundProvider(resolvedHref)
512+
if (provider && !seenUrls.has(resolvedHref)) {
513+
seenUrls.add(resolvedHref)
514+
515+
collectedLinks.push({
516+
url: resolvedHref,
517+
provider: provider.id,
518+
providerName: provider.name,
519+
/**
520+
* We need to set some data attribute before hand because `transformTags` doesn't
521+
* provide the text of the element. This will automatically be removed, because there
522+
* is an allow list for link attributes.
523+
* */
524+
label: attribs['data-title-intermediate'] || provider.name,
525+
})
526+
}
527+
497528
// Add security attributes for external links
498-
if (attribs.href && hasProtocol(attribs.href, { acceptRelative: true })) {
529+
if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) {
499530
attribs.rel = 'nofollow noreferrer noopener'
500531
attribs.target = '_blank'
501532
}
533+
attribs.href = resolvedHref
502534
return { tagName, attribs }
503535
},
504536
div: prefixId,

0 commit comments

Comments
 (0)