[READ-ONLY] a fast, modern browser for the npm registry

fix: markdown html links (#1389)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

authored by

Marcus Blättermann
coderabbitai[bot]
and committed by
GitHub
ad306bed 678f3066

+60 -28
+1 -1
app/components/Readme.vue
··· 144 144 @apply decoration-accent text-accent; 145 145 } 146 146 147 - .readme :deep(a[target='_blank']::after) { 147 + .readme :deep(a[target='_blank']:not(:has(img))::after) { 148 148 /* 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. */ 149 149 content: '__'; 150 150 @apply inline i-carbon:launch rtl-flip ms-1 opacity-50;
+59 -27
server/utils/readme.ts
··· 13 13 id: string // Provider identifier 14 14 name: string 15 15 domains: string[] // Associated domains 16 + path?: string 16 17 icon?: string // Provider icon name 17 18 } 18 19 ··· 74 75 domains: ['vite.new'], 75 76 icon: 'vite', 76 77 }, 78 + { 79 + id: 'typescript-playground', 80 + name: 'TypeScript Playground', 81 + domains: ['typescriptlang.org'], 82 + path: '/play', 83 + icon: 'typescript', 84 + }, 77 85 ] 78 86 79 87 /** ··· 86 94 87 95 for (const provider of PLAYGROUND_PROVIDERS) { 88 96 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 + ) { 90 101 return provider 91 102 } 92 103 } ··· 210 221 return true 211 222 } 212 223 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 + 213 234 /** 214 235 * Resolve a relative URL to an absolute URL. 215 236 * If repository info is available, resolve to provider's raw file URLs. ··· 390 411 return `<img src="${resolvedHref}"${altAttr}${titleAttr}>` 391 412 } 392 413 393 - // Resolve link URLs, add security attributes, and collect playground links 414 + // // Resolve link URLs, add security attributes, and collect playground links 394 415 renderer.link = function ({ href, title, tokens }: Tokens.Link) { 395 - const resolvedHref = resolveUrl(href, packageName, repoInfo) 396 416 const text = this.parser.parseInline(tokens) 397 417 const titleAttr = title ? ` title="${title}"` : '' 398 - 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 + const plainText = text.replace(/<[^>]*>/g, '').trim() 418 419 419 - const hrefValue = resolvedHref.startsWith('#') ? resolvedHref.toLowerCase() : resolvedHref 420 + const intermediateTitleAttr = `${` data-title-intermediate="${plainText || title}"`}` 420 421 421 - return `<a href="${hrefValue}"${titleAttr}${relAttr}${targetAttr}>${text}</a>` 422 + return `<a href="${href}"${titleAttr}${intermediateTitleAttr}>${text}</a>` 422 423 } 423 424 424 425 // GitHub-style callouts: > [!NOTE], > [!TIP], etc. ··· 436 437 return `<blockquote>${body}</blockquote>\n` 437 438 } 438 439 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 + }) 440 448 441 449 const rawHtml = marked.parse(content) as string 442 450 ··· 494 502 return { tagName, attribs } 495 503 }, 496 504 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 + 497 528 // Add security attributes for external links 498 - if (attribs.href && hasProtocol(attribs.href, { acceptRelative: true })) { 529 + if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) { 499 530 attribs.rel = 'nofollow noreferrer noopener' 500 531 attribs.target = '_blank' 501 532 } 533 + attribs.href = resolvedHref 502 534 return { tagName, attribs } 503 535 }, 504 536 div: prefixId,