diff --git a/app/components/Readme.vue b/app/components/Readme.vue index 488fce8c2..e613e76e3 100644 --- a/app/components/Readme.vue +++ b/app/components/Readme.vue @@ -144,7 +144,7 @@ function handleClick(event: MouseEvent) { @apply decoration-accent text-accent; } -.readme :deep(a[target='_blank']::after) { +.readme :deep(a[target='_blank']:not(:has(img))::after) { /* 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. */ content: '__'; @apply inline i-carbon:launch rtl-flip ms-1 opacity-50; diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 8f50a0350..decf44b66 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -13,6 +13,7 @@ interface PlaygroundProvider { id: string // Provider identifier name: string domains: string[] // Associated domains + path?: string icon?: string // Provider icon name } @@ -74,6 +75,13 @@ const PLAYGROUND_PROVIDERS: PlaygroundProvider[] = [ domains: ['vite.new'], icon: 'vite', }, + { + id: 'typescript-playground', + name: 'TypeScript Playground', + domains: ['typescriptlang.org'], + path: '/play', + icon: 'typescript', + }, ] /** @@ -86,7 +94,10 @@ function matchPlaygroundProvider(url: string): PlaygroundProvider | null { for (const provider of PLAYGROUND_PROVIDERS) { for (const domain of provider.domains) { - if (hostname === domain || hostname.endsWith(`.${domain}`)) { + if ( + (hostname === domain || hostname.endsWith(`.${domain}`)) && + (!provider.path || parsed.pathname.startsWith(provider.path)) + ) { return provider } } @@ -210,6 +221,16 @@ const isNpmJsUrlThatCanBeRedirected = (url: URL) => { return true } +const replaceHtmlLink = (html: string) => { + return html.replace(/href="([^"]+)"/g, (match, href) => { + if (isNpmJsUrlThatCanBeRedirected(new URL(href, 'https://www.npmjs.com'))) { + const newHref = href.replace(/^https?:\/\/(www\.)?npmjs\.com/, '') + return `href="${newHref}"` + } + return match + }) +} + /** * Resolve a relative URL to an absolute URL. * If repository info is available, resolve to provider's raw file URLs. @@ -390,35 +411,15 @@ ${html} return `` } - // Resolve link URLs, add security attributes, and collect playground links + // // Resolve link URLs, add security attributes, and collect playground links renderer.link = function ({ href, title, tokens }: Tokens.Link) { - const resolvedHref = resolveUrl(href, packageName, repoInfo) const text = this.parser.parseInline(tokens) const titleAttr = title ? ` title="${title}"` : '' + const plainText = text.replace(/<[^>]*>/g, '').trim() - const isExternal = resolvedHref.startsWith('http://') || resolvedHref.startsWith('https://') - const relAttr = isExternal ? ' rel="nofollow noreferrer noopener"' : '' - const targetAttr = isExternal ? ' target="_blank"' : '' - - // Check if this is a playground link - const provider = matchPlaygroundProvider(resolvedHref) - if (provider && !seenUrls.has(resolvedHref)) { - seenUrls.add(resolvedHref) - - // Extract label from link text (strip HTML tags for plain text) - const plainText = text.replace(/<[^>]*>/g, '').trim() - - collectedLinks.push({ - url: resolvedHref, - provider: provider.id, - providerName: provider.name, - label: plainText || title || provider.name, - }) - } - - const hrefValue = resolvedHref.startsWith('#') ? resolvedHref.toLowerCase() : resolvedHref + const intermediateTitleAttr = `${` data-title-intermediate="${plainText || title}"`}` - return `${text}` + return `${text}` } // GitHub-style callouts: > [!NOTE], > [!TIP], etc. @@ -436,7 +437,14 @@ ${html} return `
${body}
\n` } - marked.setOptions({ renderer }) + marked.setOptions({ + renderer, + walkTokens: token => { + if (token.type === 'html') { + token.text = replaceHtmlLink(token.text) + } + }, + }) const rawHtml = marked.parse(content) as string @@ -494,11 +502,35 @@ ${html} return { tagName, attribs } }, a: (tagName, attribs) => { + if (!attribs.href) { + return { tagName, attribs } + } + + const resolvedHref = resolveUrl(attribs.href, packageName, repoInfo) + + const provider = matchPlaygroundProvider(resolvedHref) + if (provider && !seenUrls.has(resolvedHref)) { + seenUrls.add(resolvedHref) + + collectedLinks.push({ + url: resolvedHref, + provider: provider.id, + providerName: provider.name, + /** + * We need to set some data attribute before hand because `transformTags` doesn't + * provide the text of the element. This will automatically be removed, because there + * is an allow list for link attributes. + * */ + label: attribs['data-title-intermediate'] || provider.name, + }) + } + // Add security attributes for external links - if (attribs.href && hasProtocol(attribs.href, { acceptRelative: true })) { + if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) { attribs.rel = 'nofollow noreferrer noopener' attribs.target = '_blank' } + attribs.href = resolvedHref return { tagName, attribs } }, div: prefixId,