Skip to content
Open
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
6 changes: 4 additions & 2 deletions docs/app/components/BadgeGeneratorParameters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,11 @@ const copyToClipboard = async () => {
>
<select
v-model="badgeStyle"
class="min-w-30 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-black/20 text-xs outline-none cursor-pointer hover:border-emerald-500 transition-colors"
class="min-w-30 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-black/20 text-gray-900 dark:text-gray-100 text-xs outline-none cursor-pointer hover:border-emerald-500 transition-colors"
>
<option v-for="s in styles" :key="s" :value="s">{{ s }}</option>
<option v-for="s in styles" :key="s" :value="s" class="dark:bg-gray-900">
{{ s }}
</option>
</select>
</div>
</div>
Expand Down
26 changes: 20 additions & 6 deletions server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ const reservedPathsNpmJs = [
const npmJsHosts = new Set(['www.npmjs.com', 'npmjs.com', 'www.npmjs.org', 'npmjs.org'])

const USER_CONTENT_PREFIX = 'user-content-'
const LOCAL_NPMX_REDIRECT_PREFIX = '$npmx-local:'

function withUserContentPrefix(value: string): string {
return value.startsWith(USER_CONTENT_PREFIX) ? value : `${USER_CONTENT_PREFIX}${value}`
Expand Down Expand Up @@ -315,6 +316,10 @@ export const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
return true
}

function toLocalNpmxRedirect(path: string): string {
return `${LOCAL_NPMX_REDIRECT_PREFIX}${path}`
}

/**
* Resolve a relative URL to an absolute URL.
* If repository info is available, resolve to provider's raw file URLs.
Expand All @@ -323,6 +328,9 @@ export const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
*/
function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string {
if (!url) return url
if (url.startsWith(LOCAL_NPMX_REDIRECT_PREFIX)) {
return url.slice(LOCAL_NPMX_REDIRECT_PREFIX.length)
}
if (url.startsWith('#')) {
// Prefix anchor links to match heading IDs (avoids collision with page IDs)
// Normalize markdown-style heading fragments to the same slug format used
Expand All @@ -338,15 +346,24 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
const normalizedFragment = slugify(decodeHashFragment(fragment))
return toUserContentHash(normalizedFragment || fragment)
}
// Absolute paths (e.g. /package/foo from a previous npmjs redirect) are already resolved
if (url.startsWith('/')) return url
// Check if this is a markdown file link
const isMarkdownFile = /\.md$/i.test(url.split('?')[0]?.split('#')[0] ?? '')

if (url.startsWith('/') && !url.startsWith('//')) {
if (!repoInfo?.rawBaseUrl) {
return url
}

const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl
return `${baseUrl}${url}`
}
if (hasProtocol(url, { acceptRelative: true })) {
try {
const parsed = new URL(url, 'https://example.com')
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
// Redirect npmjs urls to ourself
if (isNpmJsUrlThatCanBeRedirected(parsed)) {
return parsed.pathname + parsed.search + parsed.hash
return toLocalNpmxRedirect(parsed.pathname + parsed.search + parsed.hash)
}
return url
}
Expand All @@ -360,9 +377,6 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
// for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative
}

// Check if this is a markdown file link
const isMarkdownFile = /\.md$/i.test(url.split('?')[0]?.split('#')[0] ?? '')

// Use provider's URL base when repository info is available
// This handles assets that exist in the repo but not in the npm tarball
if (repoInfo?.rawBaseUrl) {
Expand Down
72 changes: 72 additions & 0 deletions test/unit/server/utils/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,70 @@ describe('Markdown File URL Resolution', () => {
'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
)
})

it('resolves root-relative .md links to the repository root blob URL', async () => {
const repoInfo = createRepoInfo({
directory: 'packages/core',
})
const markdown = `[Root Contributing](/CONTRIBUTING.md)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
)
})

it('resolves root-relative .md links in raw HTML anchors', async () => {
const repoInfo = createRepoInfo()
const markdown = `<a href="/CONTRIBUTING.md">Contributing</a>`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
)
})

it('resolves root-relative non-.md links to the repository root raw URL', async () => {
const repoInfo = createRepoInfo({
directory: 'packages/core',
})
const markdown = `[Logo](/assets/logo.png)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"',
)
})

it('resolves authored root-relative npmx-like paths to the repository root raw URL', async () => {
const repoInfo = createRepoInfo()
const markdown = `[Package](/package/test-pkg)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/package/test-pkg"',
)
})

it('keeps npmjs redirects local when repository info is available', async () => {
const repoInfo = createRepoInfo()
const markdown = `[Package](https://www.npmjs.com/package/test-pkg)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we want this behaviour for non-markdown links?

Why not something similar to what is done for this?

it('resolves non-.md files to raw URL (not blob)', async () => {
const repoInfo = createRepoInfo()
const markdown = `[Image](./assets/logo.png)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
expect(result.html).toContain(
'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"',
)
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I updated this to resolve root-relative non-markdown links via rawBaseUrl, consistent with existing relative-link handling. Markdown files still resolve via blobBaseUrl.

Local npmx routes (/package, /org, /search, etc.) are preserved separately, and I added regression tests covering both behaviors.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason / place where preserving Local npmx routes would be useful instead of them resolving to rawBaseUrl too?

@BittuBarnwal7479 BittuBarnwal7479 Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. the main case is npmjs links that the README renderer intentionally converts to local npmx routes. For example, https://www.npmjs.com/package/test-pkg becomes /package/test-pkg.

If we treated every root-relative path as a repo file, that converted route would incorrectly become rawBaseUrl/package/test-pkg.

So the updated logic preserves known npmx routes separately, while root-relative repo files like /CONTRIBUTING.md and /assets/logo.png resolve to blobBaseUrl / rawBaseUrl.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is there no way to differentiate a /package that was because of https://www.npmjs.com/package/test-pkg from a /package that someone wrote in their readme?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. i will update this to preserve npmjs-originated links via an internal marker rather than path matching. README-authored root-relative paths now resolve normally against the repository. Added regression tests for both cases.


it('keeps npmjs route roots local when repository info is available', async () => {
const repoInfo = createRepoInfo()
const markdown = [
`[Packages](https://www.npmjs.com/package)`,
`[Organizations](https://www.npmjs.com/org)`,
].join('\n')
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain('href="/package"')
expect(result.html).toContain('href="/org"')
})
})

describe('without repository info', () => {
Expand Down Expand Up @@ -284,6 +348,14 @@ describe('Markdown File URL Resolution', () => {

expect(result.html).toContain('href="https://docs.example.com/"')
})

it('leaves protocol-relative URLs unchanged with repository info', async () => {
const repoInfo = createRepoInfo()
const markdown = `[CDN](//cdn.example.com/file.css)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain('href="//cdn.example.com/file.css"')
})
})

describe('anchor links', () => {
Expand Down
Loading