Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/Readme.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 59 additions & 27 deletions server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface PlaygroundProvider {
id: string // Provider identifier
name: string
domains: string[] // Associated domains
path?: string
icon?: string // Provider icon name
}

Expand Down Expand Up @@ -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',
},
]

/**
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -390,35 +411,15 @@ ${html}
return `<img src="${resolvedHref}"${altAttr}${titleAttr}>`
}

// 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 `<a href="${hrefValue}"${titleAttr}${relAttr}${targetAttr}>${text}</a>`
return `<a href="${href}"${titleAttr}${intermediateTitleAttr}>${text}</a>`
}

// GitHub-style callouts: > [!NOTE], > [!TIP], etc.
Expand All @@ -436,7 +437,14 @@ ${html}
return `<blockquote>${body}</blockquote>\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

Expand Down Expand Up @@ -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,
Expand Down
Loading