[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: resolve relative markdown links to repository blob URLs (#716)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

J.A.R.V.I.S.
Daniel Roe
and committed by
GitHub
b914c043 5cf00f1f

+339 -5
+1 -1
server/api/registry/readme/[...pkg].get.ts
··· 126 126 swr: true, 127 127 getKey: event => { 128 128 const pkg = getRouterParam(event, 'pkg') ?? '' 129 - return `readme:v6:${pkg.replace(/\/+$/, '').trim()}` 129 + return `readme:v7:${pkg.replace(/\/+$/, '').trim()}` 130 130 }, 131 131 }, 132 132 )
+16 -3
server/utils/readme.ts
··· 183 183 /** 184 184 * Resolve a relative URL to an absolute URL. 185 185 * If repository info is available, resolve to provider's raw file URLs. 186 - * Otherwise, fall back to jsdelivr CDN. 186 + * For markdown files (.md), use blob URLs so they render properly. 187 + * Otherwise, fall back to jsdelivr CDN (except for .md files which are left unchanged). 187 188 */ 188 189 function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { 189 190 if (!url) return url ··· 207 208 // for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative 208 209 } 209 210 210 - // Use provider's raw URL base when repository info is available 211 + // Check if this is a markdown file link 212 + const isMarkdownFile = /\.md$/i.test(url.split('?')[0]?.split('#')[0] ?? '') 213 + 214 + // Use provider's URL base when repository info is available 211 215 // This handles assets that exist in the repo but not in the npm tarball 212 216 if (repoInfo?.rawBaseUrl) { 213 217 // Normalize the relative path (remove leading ./) ··· 232 236 } 233 237 } 234 238 235 - return `${repoInfo.rawBaseUrl}/${relativePath}` 239 + // For markdown files, use blob URL so they render on the provider's site 240 + // For other files, use raw URL for direct access 241 + const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl 242 + return `${baseUrl}/${relativePath}` 243 + } 244 + 245 + // For markdown files without repo info, leave unchanged (like npm does) 246 + // This avoids 404s from jsdelivr which doesn't render markdown 247 + if (isMarkdownFile) { 248 + return url 236 249 } 237 250 238 251 // Fallback: relative URLs → jsdelivr CDN (may 404 if asset not in npm tarball)
+31
shared/utils/git-providers.ts
··· 22 22 export interface RepositoryInfo extends RepoRef { 23 23 /** Raw file URL base (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ 24 24 rawBaseUrl: string 25 + /** Blob/rendered file URL base (e.g., https://github.com/owner/repo/blob/HEAD) */ 26 + blobBaseUrl: string 25 27 /** Subdirectory within repo where package lives (e.g., packages/ai) */ 26 28 directory?: string 27 29 } ··· 44 46 parsePath(parts: string[]): { owner: string; repo: string } | null 45 47 /** Get raw file URL base for resolving relative paths */ 46 48 getRawBaseUrl(ref: RepoRef, branch?: string): string 49 + /** Get blob/rendered URL base for markdown files */ 50 + getBlobBaseUrl(ref: RepoRef, branch?: string): string 47 51 /** Convert blob URLs to raw URLs (for images) */ 48 52 blobToRaw?(url: string): string 49 53 } ··· 63 67 }, 64 68 getRawBaseUrl: (ref, branch = 'HEAD') => 65 69 `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/${branch}`, 70 + getBlobBaseUrl: (ref, branch = 'HEAD') => 71 + `https://github.com/${ref.owner}/${ref.repo}/blob/${branch}`, 66 72 blobToRaw: url => url.replace('/blob/', '/raw/'), 67 73 }, 68 74 { ··· 85 91 const host = ref.host ?? 'gitlab.com' 86 92 return `https://${host}/${ref.owner}/${ref.repo}/-/raw/${branch}` 87 93 }, 94 + getBlobBaseUrl: (ref, branch = 'HEAD') => { 95 + const host = ref.host ?? 'gitlab.com' 96 + return `https://${host}/${ref.owner}/${ref.repo}/-/blob/${branch}` 97 + }, 88 98 blobToRaw: url => url.replace('/-/blob/', '/-/raw/'), 89 99 }, 90 100 { ··· 101 111 }, 102 112 getRawBaseUrl: (ref, branch = 'HEAD') => 103 113 `https://bitbucket.org/${ref.owner}/${ref.repo}/raw/${branch}`, 114 + getBlobBaseUrl: (ref, branch = 'HEAD') => 115 + `https://bitbucket.org/${ref.owner}/${ref.repo}/src/${branch}`, 104 116 blobToRaw: url => url.replace('/src/', '/raw/'), 105 117 }, 106 118 { ··· 117 129 }, 118 130 getRawBaseUrl: (ref, branch = 'HEAD') => 119 131 `https://codeberg.org/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}`, 132 + getBlobBaseUrl: (ref, branch = 'HEAD') => 133 + `https://codeberg.org/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}`, 120 134 blobToRaw: url => url.replace('/src/', '/raw/'), 121 135 }, 122 136 { ··· 133 147 }, 134 148 getRawBaseUrl: (ref, branch = 'master') => 135 149 `https://gitee.com/${ref.owner}/${ref.repo}/raw/${branch}`, 150 + getBlobBaseUrl: (ref, branch = 'master') => 151 + `https://gitee.com/${ref.owner}/${ref.repo}/blob/${branch}`, 136 152 blobToRaw: url => url.replace('/blob/', '/raw/'), 137 153 }, 138 154 { ··· 150 166 }, 151 167 getRawBaseUrl: (ref, branch = 'HEAD') => 152 168 `https://git.sr.ht/${ref.owner}/${ref.repo}/blob/${branch}`, 169 + getBlobBaseUrl: (ref, branch = 'HEAD') => 170 + `https://git.sr.ht/${ref.owner}/${ref.repo}/tree/${branch}/item`, 153 171 }, 154 172 { 155 173 id: 'tangled', ··· 170 188 }, 171 189 getRawBaseUrl: (ref, branch = 'main') => 172 190 `https://tangled.sh/${ref.owner}/${ref.repo}/raw/branch/${branch}`, 191 + getBlobBaseUrl: (ref, branch = 'main') => 192 + `https://tangled.sh/${ref.owner}/${ref.repo}/src/branch/${branch}`, 173 193 blobToRaw: url => url.replace('/blob/', '/raw/branch/'), 174 194 }, 175 195 { ··· 187 207 }, 188 208 getRawBaseUrl: (ref, branch = 'HEAD') => 189 209 `https://seed.radicle.at/api/v1/projects/${ref.repo}/blob/${branch}`, 210 + getBlobBaseUrl: (ref, branch = 'HEAD') => 211 + `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}/tree/${branch}`, 190 212 }, 191 213 { 192 214 id: 'forgejo', ··· 211 233 const host = ref.host ?? 'codeberg.org' 212 234 return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` 213 235 }, 236 + getBlobBaseUrl: (ref, branch = 'HEAD') => { 237 + const host = ref.host ?? 'codeberg.org' 238 + return `https://${host}/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}` 239 + }, 214 240 blobToRaw: url => url.replace('/src/', '/raw/'), 215 241 }, 216 242 { ··· 250 276 getRawBaseUrl: (ref, branch = 'HEAD') => { 251 277 const host = ref.host ?? 'gitea.io' 252 278 return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` 279 + }, 280 + getBlobBaseUrl: (ref, branch = 'HEAD') => { 281 + const host = ref.host ?? 'gitea.io' 282 + return `https://${host}/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}` 253 283 }, 254 284 blobToRaw: url => url.replace('/src/', '/raw/'), 255 285 }, ··· 347 377 return { 348 378 ...ref, 349 379 rawBaseUrl: provider.getRawBaseUrl(ref), 380 + blobBaseUrl: provider.getBlobBaseUrl(ref), 350 381 directory: directory ? withoutTrailingSlash(directory) : undefined, 351 382 } 352 383 }
+176
test/unit/server/utils/readme.spec.ts
··· 1 + import type { RepositoryInfo } from '#shared/utils/git-providers' 1 2 import { describe, expect, it, vi, beforeAll } from 'vitest' 2 3 3 4 // Mock the global Nuxt auto-import before importing the module ··· 13 14 14 15 // Import after mock is set up 15 16 const { renderReadmeHtml } = await import('../../../../server/utils/readme') 17 + 18 + // Helper to create mock repository info 19 + function createRepoInfo(overrides?: Partial<RepositoryInfo>): RepositoryInfo { 20 + return { 21 + provider: 'github', 22 + owner: 'test-owner', 23 + repo: 'test-repo', 24 + rawBaseUrl: 'https://raw.githubusercontent.com/test-owner/test-repo/HEAD', 25 + blobBaseUrl: 'https://github.com/test-owner/test-repo/blob/HEAD', 26 + ...overrides, 27 + } 28 + } 16 29 17 30 describe('Playground Link Extraction', () => { 18 31 describe('StackBlitz', () => { ··· 131 144 }) 132 145 }) 133 146 }) 147 + 148 + describe('Markdown File URL Resolution', () => { 149 + describe('with repository info', () => { 150 + it('resolves relative .md links to blob URL for rendered viewing', async () => { 151 + const repoInfo = createRepoInfo() 152 + const markdown = `[Contributing](./CONTRIBUTING.md)` 153 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 154 + 155 + expect(result.html).toContain( 156 + 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"', 157 + ) 158 + }) 159 + 160 + it('resolves relative .MD links (uppercase) to blob URL', async () => { 161 + const repoInfo = createRepoInfo() 162 + const markdown = `[Guide](./GUIDE.MD)` 163 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 164 + 165 + expect(result.html).toContain( 166 + 'href="https://github.com/test-owner/test-repo/blob/HEAD/GUIDE.MD"', 167 + ) 168 + }) 169 + 170 + it('resolves nested relative .md links to blob URL', async () => { 171 + const repoInfo = createRepoInfo() 172 + const markdown = `[API Docs](./docs/api/reference.md)` 173 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 174 + 175 + expect(result.html).toContain( 176 + 'href="https://github.com/test-owner/test-repo/blob/HEAD/docs/api/reference.md"', 177 + ) 178 + }) 179 + 180 + it('resolves relative .md links with query strings to blob URL', async () => { 181 + const repoInfo = createRepoInfo() 182 + const markdown = `[FAQ](./FAQ.md?ref=main)` 183 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 184 + 185 + expect(result.html).toContain( 186 + 'href="https://github.com/test-owner/test-repo/blob/HEAD/FAQ.md?ref=main"', 187 + ) 188 + }) 189 + 190 + it('resolves relative .md links with anchors to blob URL', async () => { 191 + const repoInfo = createRepoInfo() 192 + const markdown = `[Install Section](./CONTRIBUTING.md#installation)` 193 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 194 + 195 + expect(result.html).toContain( 196 + 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md#installation"', 197 + ) 198 + }) 199 + 200 + it('resolves non-.md files to raw URL (not blob)', async () => { 201 + const repoInfo = createRepoInfo() 202 + const markdown = `[Image](./assets/logo.png)` 203 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 204 + 205 + expect(result.html).toContain( 206 + 'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"', 207 + ) 208 + }) 209 + 210 + it('handles monorepo directory for .md links', async () => { 211 + const repoInfo = createRepoInfo({ 212 + directory: 'packages/core', 213 + }) 214 + const markdown = `[Changelog](./CHANGELOG.md)` 215 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 216 + 217 + expect(result.html).toContain( 218 + 'href="https://github.com/test-owner/test-repo/blob/HEAD/packages/core/CHANGELOG.md"', 219 + ) 220 + }) 221 + 222 + it('handles parent directory navigation for .md links', async () => { 223 + const repoInfo = createRepoInfo({ 224 + directory: 'packages/core', 225 + }) 226 + const markdown = `[Root Contributing](../../CONTRIBUTING.md)` 227 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 228 + 229 + expect(result.html).toContain( 230 + 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"', 231 + ) 232 + }) 233 + }) 234 + 235 + describe('without repository info', () => { 236 + it('leaves relative .md links unchanged (no jsdelivr fallback)', async () => { 237 + const markdown = `[Contributing](./CONTRIBUTING.md)` 238 + const result = await renderReadmeHtml(markdown, 'test-pkg') 239 + 240 + // Should remain unchanged, not converted to jsdelivr 241 + expect(result.html).toContain('href="./CONTRIBUTING.md"') 242 + }) 243 + 244 + it('resolves non-.md files to jsdelivr CDN', async () => { 245 + const markdown = `[Schema](./schema.json)` 246 + const result = await renderReadmeHtml(markdown, 'test-pkg') 247 + 248 + expect(result.html).toContain('href="https://cdn.jsdelivr.net/npm/test-pkg/schema.json"') 249 + }) 250 + }) 251 + 252 + describe('absolute URLs', () => { 253 + it('leaves absolute .md URLs unchanged', async () => { 254 + const repoInfo = createRepoInfo() 255 + const markdown = `[External Guide](https://example.com/guide.md)` 256 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 257 + 258 + expect(result.html).toContain('href="https://example.com/guide.md"') 259 + }) 260 + 261 + it('leaves absolute non-.md URLs unchanged', async () => { 262 + const repoInfo = createRepoInfo() 263 + const markdown = `[Docs](https://docs.example.com/)` 264 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 265 + 266 + expect(result.html).toContain('href="https://docs.example.com/"') 267 + }) 268 + }) 269 + 270 + describe('anchor links', () => { 271 + it('prefixes anchor links with user-content-', async () => { 272 + const markdown = `[Jump to section](#installation)` 273 + const result = await renderReadmeHtml(markdown, 'test-pkg') 274 + 275 + expect(result.html).toContain('href="#user-content-installation"') 276 + }) 277 + }) 278 + 279 + describe('different git providers', () => { 280 + it('uses correct blob URL format for GitLab', async () => { 281 + const repoInfo = createRepoInfo({ 282 + provider: 'gitlab', 283 + host: 'gitlab.com', 284 + rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD', 285 + blobBaseUrl: 'https://gitlab.com/owner/repo/-/blob/HEAD', 286 + }) 287 + const markdown = `[Docs](./docs/guide.md)` 288 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 289 + 290 + expect(result.html).toContain( 291 + 'href="https://gitlab.com/owner/repo/-/blob/HEAD/docs/guide.md"', 292 + ) 293 + }) 294 + 295 + it('uses correct blob URL format for Bitbucket', async () => { 296 + const repoInfo = createRepoInfo({ 297 + provider: 'bitbucket', 298 + rawBaseUrl: 'https://bitbucket.org/owner/repo/raw/HEAD', 299 + blobBaseUrl: 'https://bitbucket.org/owner/repo/src/HEAD', 300 + }) 301 + const markdown = `[Readme](./other/README.md)` 302 + const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) 303 + 304 + expect(result.html).toContain( 305 + 'href="https://bitbucket.org/owner/repo/src/HEAD/other/README.md"', 306 + ) 307 + }) 308 + }) 309 + })
+115 -1
test/unit/shared/utils/git-providers.spec.ts
··· 1 1 import { describe, expect, it } from 'vitest' 2 - import { parseRepositoryInfo } from '#shared/utils/git-providers' 2 + import { parseRepositoryInfo, type RepositoryInfo } from '#shared/utils/git-providers' 3 3 4 4 describe('parseRepositoryInfo', () => { 5 5 it('returns undefined for undefined input', () => { ··· 279 279 host: 'forgejo.myserver.com', 280 280 }) 281 281 }) 282 + }) 283 + 284 + describe('blobBaseUrl generation', () => { 285 + it('generates correct blobBaseUrl for GitHub', () => { 286 + const result = parseRepositoryInfo({ 287 + url: 'https://github.com/vercel/ai.git', 288 + }) 289 + expect(result).toMatchObject({ 290 + rawBaseUrl: 'https://raw.githubusercontent.com/vercel/ai/HEAD', 291 + blobBaseUrl: 'https://github.com/vercel/ai/blob/HEAD', 292 + }) 293 + }) 294 + 295 + it('generates correct blobBaseUrl for GitLab', () => { 296 + const result = parseRepositoryInfo({ 297 + url: 'https://gitlab.com/owner/repo.git', 298 + }) 299 + expect(result).toMatchObject({ 300 + rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD', 301 + blobBaseUrl: 'https://gitlab.com/owner/repo/-/blob/HEAD', 302 + }) 303 + }) 304 + 305 + it('generates correct blobBaseUrl for self-hosted GitLab', () => { 306 + const result = parseRepositoryInfo({ 307 + url: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts.git', 308 + }) 309 + expect(result).toMatchObject({ 310 + rawBaseUrl: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts/-/raw/HEAD', 311 + blobBaseUrl: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts/-/blob/HEAD', 312 + }) 313 + }) 314 + 315 + it('generates correct blobBaseUrl for Bitbucket', () => { 316 + const result = parseRepositoryInfo({ 317 + url: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror.git', 318 + }) 319 + expect(result).toMatchObject({ 320 + rawBaseUrl: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror/raw/HEAD', 321 + blobBaseUrl: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/HEAD', 322 + }) 323 + }) 324 + 325 + it('generates correct blobBaseUrl for Codeberg', () => { 326 + const result = parseRepositoryInfo({ 327 + url: 'https://codeberg.org/jgarber/CashCash', 328 + }) 329 + expect(result).toMatchObject({ 330 + rawBaseUrl: 'https://codeberg.org/jgarber/CashCash/raw/branch/main', 331 + blobBaseUrl: 'https://codeberg.org/jgarber/CashCash/src/branch/main', 332 + }) 333 + }) 334 + 335 + it('generates correct blobBaseUrl for Gitee', () => { 336 + const result = parseRepositoryInfo({ 337 + url: 'https://gitee.com/oschina/mcp-gitee.git', 338 + }) 339 + expect(result).toMatchObject({ 340 + rawBaseUrl: 'https://gitee.com/oschina/mcp-gitee/raw/master', 341 + blobBaseUrl: 'https://gitee.com/oschina/mcp-gitee/blob/master', 342 + }) 343 + }) 344 + 345 + it('generates correct blobBaseUrl for Sourcehut', () => { 346 + const result = parseRepositoryInfo({ 347 + url: 'https://git.sr.ht/~ayoayco/astro-resume.git', 348 + }) 349 + expect(result).toMatchObject({ 350 + rawBaseUrl: 'https://git.sr.ht/~ayoayco/astro-resume/blob/HEAD', 351 + blobBaseUrl: 'https://git.sr.ht/~ayoayco/astro-resume/tree/HEAD/item', 352 + }) 353 + }) 354 + 355 + it('generates correct blobBaseUrl for Tangled', () => { 356 + const result = parseRepositoryInfo({ 357 + url: 'https://tangled.sh/pds.ls/pdsls', 358 + }) 359 + expect(result).toMatchObject({ 360 + rawBaseUrl: 'https://tangled.sh/pds.ls/pdsls/raw/branch/main', 361 + blobBaseUrl: 'https://tangled.sh/pds.ls/pdsls/src/branch/main', 362 + }) 363 + }) 364 + 365 + it('generates correct blobBaseUrl for Radicle', () => { 366 + const result = parseRepositoryInfo({ 367 + url: 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', 368 + }) 369 + expect(result).toMatchObject({ 370 + rawBaseUrl: 371 + 'https://seed.radicle.at/api/v1/projects/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT/blob/HEAD', 372 + blobBaseUrl: 373 + 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT/tree/HEAD', 374 + }) 375 + }) 376 + 377 + it('generates correct blobBaseUrl for Forgejo', () => { 378 + const result = parseRepositoryInfo({ 379 + url: 'https://next.forgejo.org/forgejo/forgejo', 380 + }) 381 + expect(result).toMatchObject({ 382 + rawBaseUrl: 'https://next.forgejo.org/forgejo/forgejo/raw/branch/main', 383 + blobBaseUrl: 'https://next.forgejo.org/forgejo/forgejo/src/branch/main', 384 + }) 385 + }) 386 + }) 387 + }) 388 + 389 + describe('RepositoryInfo type', () => { 390 + it('includes blobBaseUrl in RepositoryInfo', () => { 391 + const result = parseRepositoryInfo({ 392 + url: 'https://github.com/test/repo', 393 + }) as RepositoryInfo 394 + expect(result).toHaveProperty('blobBaseUrl') 395 + expect(typeof result.blobBaseUrl).toBe('string') 282 396 }) 283 397 })