diff --git a/docs/app/components/BadgeGeneratorParameters.vue b/docs/app/components/BadgeGeneratorParameters.vue index bf0a4e5d6e..ba5420cdc8 100644 --- a/docs/app/components/BadgeGeneratorParameters.vue +++ b/docs/app/components/BadgeGeneratorParameters.vue @@ -253,9 +253,11 @@ const copyToClipboard = async () => { > diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 6e220f7827..40822cb7b8 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -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}` @@ -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. @@ -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 @@ -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 } @@ -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) { diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts index dacc2653b7..5b8e01d4c4 100644 --- a/test/unit/server/utils/readme.spec.ts +++ b/test/unit/server/utils/readme.spec.ts @@ -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 = `Contributing` + 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"') + }) + + 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', () => { @@ -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', () => {