Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
83 changes: 15 additions & 68 deletions app/components/Readme.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,6 @@
defineProps<{
html: string
}>()

const router = useRouter()
const { copy } = useClipboard()

// Combined click handler for:
// 1. Intercepting npmjs.com links to route internally
// 2. Copy button functionality for code blocks
function handleClick(event: MouseEvent) {
const target = event.target as HTMLElement | undefined
if (!target) return

// Handle copy button clicks
const copyTarget = target.closest('[data-copy]')
if (copyTarget) {
const wrapper = copyTarget.closest('.readme-code-block')
if (!wrapper) return

const pre = wrapper.querySelector('pre')
if (!pre?.textContent) return

copy(pre.textContent)

const icon = copyTarget.querySelector('span')
if (!icon) return

const originalIcon = 'i-carbon:copy'
const successIcon = 'i-carbon:checkmark'

icon.classList.remove(originalIcon)
icon.classList.add(successIcon)

setTimeout(() => {
icon.classList.remove(successIcon)
icon.classList.add(originalIcon)
}, 2000)
return
}

// Handle npmjs.com link clicks - route internally
const anchor = target.closest('a')
if (!anchor) return

const href = anchor.getAttribute('href')
if (!href) return

// Handle relative anchor links
if (href.startsWith('#')) {
event.preventDefault()
router.push(href)
return
}

const match = href.match(/^(?:https?:\/\/)?(?:www\.)?npmjs\.(?:com|org)(\/.+)$/)
if (!match || !match[1]) return

const route = router.resolve(match[1])
if (route) {
event.preventDefault()
router.push(route)
}
}
</script>

<template>
Expand All @@ -77,7 +16,6 @@ function handleClick(event: MouseEvent) {
'--i18n-warning': '\'' + $t('package.readme.callout.warning') + '\'',
'--i18n-caution': '\'' + $t('package.readme.callout.caution') + '\'',
}"
@click="handleClick"
/>
</template>

Expand Down Expand Up @@ -141,15 +79,24 @@ function handleClick(event: MouseEvent) {
}

.readme :deep(a) {
color: var(--fg);
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-color: var(--fg-subtle);
transition: text-decoration-color 0.2s ease;
@apply underline-offset-[0.2rem]
@apply underline
@apply decoration-1
@apply decoration-fg/30
@apply font-mono
@apply text-fg
@apply transition-colors
@apply duration-200;
}

.readme :deep(a:hover) {
text-decoration-color: var(--accent);
@apply decoration-accent
@apply text-accent;
}

.readme :deep(a:focus-visible) {
@apply decoration-accent
@apply text-accent;
}
Comment thread
essenmitsosse marked this conversation as resolved.

.readme :deep(code) {
Expand Down
41 changes: 40 additions & 1 deletion server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,33 @@ function slugify(text: string): string {
.replace(/^-|-$/g, '') // Trim leading/trailing hyphens
}

/** These path on npmjs.com don't belong to packages or search, so we shouldn't try to replace them with npmx.dev urls */
const reservedPathsNpmJs = [
'products',
'login',
'signup',
'advisories',
'blog',
'about',
'press',
'policies',
]

const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
if (url.host !== 'www.npmjs.com' && url.host !== 'npmjs.com') {
return false
}

if (
url.pathname === '/' ||
reservedPathsNpmJs.some(path => url.pathname.startsWith(`/${path}`))
) {
return false
}

return true
}

/**
* Resolve a relative URL to an absolute URL.
* If repository info is available, resolve to provider's raw file URLs.
Expand All @@ -199,6 +226,11 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
try {
const parsed = new URL(url, 'https://example.com')
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
// Redirect npmjs urls to ourself
console.log({ isnpm: isNpmJsUrlThatCanBeRedirected(parsed) })
if (isNpmJsUrlThatCanBeRedirected(parsed)) {
return parsed.pathname + parsed.search + parsed.hash
}
return url
Comment thread
essenmitsosse marked this conversation as resolved.
}
} catch {
Expand Down Expand Up @@ -362,12 +394,19 @@ ${html}
// Resolve link URLs, add security attributes, and collect playground links
renderer.link = function ({ href, title, tokens }: Tokens.Link) {
const resolvedHref = resolveUrl(href, packageName, repoInfo)
console.log({ resolvedHref })
Comment thread
essenmitsosse marked this conversation as resolved.
Outdated
const text = this.parser.parseInline(tokens)
const titleAttr = title ? ` title="${title}"` : ''

const isExternal = resolvedHref.startsWith('http://') || resolvedHref.startsWith('https://')
const relAttr = isExternal ? ' rel="nofollow noreferrer noopener"' : ''
const targetAttr = isExternal ? ' target="_blank"' : ''
const isExternalIcon = isExternal
? `<span
class="i-carbon:launch inline-block rtl-flip ms-1 size-[1em] opacity-50"
aria-hidden="true"
/>`
: ''
Comment thread
essenmitsosse marked this conversation as resolved.
Outdated

// Check if this is a playground link
const provider = matchPlaygroundProvider(resolvedHref)
Expand All @@ -387,7 +426,7 @@ ${html}

const hrefValue = resolvedHref.startsWith('#') ? resolvedHref.toLowerCase() : resolvedHref

return `<a href="${hrefValue}"${titleAttr}${relAttr}${targetAttr}>${text}</a>`
return `<a href="${hrefValue}"${titleAttr}${relAttr}${targetAttr}>${text}${isExternalIcon}</a>`
}

// GitHub-style callouts: > [!NOTE], > [!TIP], etc.
Expand Down
25 changes: 24 additions & 1 deletion test/unit/server/utils/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,29 @@ describe('Markdown File URL Resolution', () => {
)
})
})

describe('npm.js urls', () => {
it('redirects npmjs.com urls to local', async () => {
const markdown = `[Some npmjs.com link](https://www.npmjs.com/package/test-pkg)`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('href="/package/test-pkg"')
})

it('redirects npmjs.com urls to local (no www and http)', async () => {
const markdown = `[Some npmjs.com link](http://npmjs.com/package/test-pkg)`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('href="/package/test-pkg"')
})

it('does not redirect npmjs.com to local if they are in the list of exceptions', async () => {
const markdown = `[Root Contributing](https://www.npmjs.com/products)`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('href="https://www.npmjs.com/products"')
})
})
})

describe('Markdown Content Extraction', () => {
Expand All @@ -323,7 +346,7 @@ describe('Markdown Content Extraction', () => {
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toBe(`<h3 id="user-content-title" data-level="1">Title</h3>
<p>Some <strong>bold</strong> text and a <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">link</a>.</p>
<p>Some <strong>bold</strong> text and a <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">link<span class="i-carbon:launch inline-block rtl-flip ms-1 size-[1em] opacity-50"></span></a>.</p>
`)
})
})
Expand Down
Loading