[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: render images with relative paths correctly

+170 -11
+6 -1
server/api/registry/readme/[...pkg].get.ts
··· 1 + import { parseRepositoryInfo } from '#server/utils/readme' 2 + 1 3 /** 2 4 * Fetch README from jsdelivr CDN for a specific package version. 3 5 * Falls back through common README filenames. ··· 82 84 return { html: '' } 83 85 } 84 86 85 - const html = await renderReadmeHtml(readmeContent, packageName) 87 + // Parse repository info for resolving relative URLs to GitHub 88 + const repoInfo = parseRepositoryInfo(packageData.repository) 89 + 90 + const html = await renderReadmeHtml(readmeContent, packageName, repoInfo) 86 91 return { html } 87 92 } catch (error) { 88 93 if (error && typeof error === 'object' && 'statusCode' in error) {
+89 -10
server/utils/readme.ts
··· 1 1 import { marked, type Tokens } from 'marked' 2 2 import sanitizeHtml from 'sanitize-html' 3 - import { hasProtocol } from 'ufo' 3 + import { hasProtocol, withoutTrailingSlash } from 'ufo' 4 + 5 + export interface RepositoryInfo { 6 + /** GitHub raw base URL (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ 7 + rawBaseUrl?: string 8 + /** Subdirectory within repo where package lives (e.g., packages/ai) */ 9 + directory?: string 10 + } 4 11 5 12 // only allow h3-h6 since we shift README headings down by 2 levels 6 13 // (page h1 = package name, h2 = "Readme" section, so README h1 → h3) ··· 63 70 // GitHub-style callout types 64 71 // Format: > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION] 65 72 66 - function resolveUrl(url: string, packageName: string): string { 73 + /** 74 + * Parse repository field from package.json into GitHub raw URL base. 75 + * Supports both full objects and shorthand strings. 76 + */ 77 + export function parseRepositoryInfo( 78 + repository?: { type?: string; url?: string; directory?: string } | string, 79 + ): RepositoryInfo | undefined { 80 + if (!repository) return undefined 81 + 82 + let url: string | undefined 83 + let directory: string | undefined 84 + 85 + if (typeof repository === 'string') { 86 + url = repository 87 + } else { 88 + url = repository.url 89 + directory = repository.directory 90 + } 91 + 92 + if (!url) return undefined 93 + 94 + // Parse GitHub URL: git+https://github.com/owner/repo.git or https://github.com/owner/repo 95 + const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?/) 96 + if (!githubMatch?.[1] || !githubMatch[2]) return undefined 97 + 98 + const owner = githubMatch[1] 99 + const repo = githubMatch[2] 100 + 101 + return { 102 + rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`, 103 + directory: directory ? withoutTrailingSlash(directory) : undefined, 104 + } 105 + } 106 + 107 + /** 108 + * Resolve a relative URL to an absolute URL. 109 + * If repository info is available, resolve to GitHub raw URLs. 110 + * Otherwise, fall back to jsdelivr CDN. 111 + */ 112 + function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { 67 113 if (!url) return url 68 114 if (url.startsWith('#')) { 69 115 return url ··· 71 117 if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) { 72 118 return url 73 119 } 74 - // Relative URLs → jsdelivr CDN 120 + 121 + // Prefer GitHub raw URLs when repository info is available 122 + // This handles assets that exist in the repo but not in the npm tarball 123 + if (repoInfo?.rawBaseUrl) { 124 + // Normalize the relative path (remove leading ./) 125 + let relativePath = url.replace(/^\.\//, '') 126 + 127 + // If package is in a subdirectory, resolve relative paths from there 128 + // e.g., for packages/ai with ./assets/hero.gif → packages/ai/assets/hero.gif 129 + // but for ../../.github/assets/banner.jpg → resolve relative to subdirectory 130 + if (repoInfo.directory) { 131 + // Split directory into parts for relative path resolution 132 + const dirParts = repoInfo.directory.split('/').filter(Boolean) 133 + 134 + // Handle ../ navigation 135 + while (relativePath.startsWith('../')) { 136 + relativePath = relativePath.slice(3) 137 + dirParts.pop() 138 + } 139 + 140 + // Reconstruct the path 141 + if (dirParts.length > 0) { 142 + relativePath = `${dirParts.join('/')}/${relativePath}` 143 + } 144 + } 145 + 146 + return `${repoInfo.rawBaseUrl}/${relativePath}` 147 + } 148 + 149 + // Fallback: relative URLs → jsdelivr CDN (may 404 if asset not in npm tarball) 75 150 return `https://cdn.jsdelivr.net/npm/${packageName}/${url.replace(/^\.\//, '')}` 76 151 } 77 152 78 153 // Convert GitHub blob URLs to raw URLs for images 79 154 // e.g. https://github.com/nuxt/nuxt/blob/main/.github/assets/banner.svg 80 155 // → https://github.com/nuxt/nuxt/raw/main/.github/assets/banner.svg 81 - function resolveImageUrl(url: string, packageName: string): string { 82 - const resolved = resolveUrl(url, packageName) 156 + function resolveImageUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { 157 + const resolved = resolveUrl(url, packageName, repoInfo) 83 158 // GitHub blob → raw 84 159 if (resolved.includes('github.com') && resolved.includes('/blob/')) { 85 160 return resolved.replace('/blob/', '/raw/') ··· 87 162 return resolved 88 163 } 89 164 90 - export async function renderReadmeHtml(content: string, packageName: string): Promise<string> { 165 + export async function renderReadmeHtml( 166 + content: string, 167 + packageName: string, 168 + repoInfo?: RepositoryInfo, 169 + ): Promise<string> { 91 170 if (!content) return '' 92 171 93 172 const shiki = await getShikiHighlighter() ··· 127 206 128 207 // Resolve image URLs (with GitHub blob → raw conversion) 129 208 renderer.image = ({ href, title, text }: Tokens.Image) => { 130 - const resolvedHref = resolveImageUrl(href, packageName) 209 + const resolvedHref = resolveImageUrl(href, packageName, repoInfo) 131 210 const titleAttr = title ? ` title="${title}"` : '' 132 211 const altAttr = text ? ` alt="${text}"` : '' 133 212 return `<img src="${resolvedHref}"${altAttr}${titleAttr}>` ··· 135 214 136 215 // Resolve link URLs and add security attributes 137 216 renderer.link = function ({ href, title, tokens }: Tokens.Link) { 138 - const resolvedHref = resolveUrl(href, packageName) 217 + const resolvedHref = resolveUrl(href, packageName, repoInfo) 139 218 const text = this.parser.parseInline(tokens) 140 219 const titleAttr = title ? ` title="${title}"` : '' 141 220 ··· 169 248 allowedTags: ALLOWED_TAGS, 170 249 allowedAttributes: ALLOWED_ATTR, 171 250 allowedSchemes: ['http', 'https', 'mailto'], 172 - // Transform img src URLs (GitHub blob → raw, relative → jsdelivr) 251 + // Transform img src URLs (GitHub blob → raw, relative → GitHub raw) 173 252 transformTags: { 174 253 img: (tagName, attribs) => { 175 254 if (attribs.src) { 176 - attribs.src = resolveImageUrl(attribs.src, packageName) 255 + attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo) 177 256 } 178 257 return { tagName, attribs } 179 258 },
+75
test/unit/readme-url-resolution.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { parseRepositoryInfo } from '../../server/utils/readme' 3 + 4 + describe('parseRepositoryInfo', () => { 5 + it('returns undefined for undefined input', () => { 6 + expect(parseRepositoryInfo(undefined)).toBeUndefined() 7 + }) 8 + 9 + it('parses GitHub URL from object with git+ prefix', () => { 10 + const result = parseRepositoryInfo({ 11 + type: 'git', 12 + url: 'git+https://github.com/vercel/ai.git', 13 + }) 14 + expect(result).toEqual({ 15 + rawBaseUrl: 'https://raw.githubusercontent.com/vercel/ai/HEAD', 16 + directory: undefined, 17 + }) 18 + }) 19 + 20 + it('parses GitHub URL with directory (monorepo)', () => { 21 + const result = parseRepositoryInfo({ 22 + type: 'git', 23 + url: 'git+https://github.com/withastro/astro.git', 24 + directory: 'packages/astro', 25 + }) 26 + expect(result).toEqual({ 27 + rawBaseUrl: 'https://raw.githubusercontent.com/withastro/astro/HEAD', 28 + directory: 'packages/astro', 29 + }) 30 + }) 31 + 32 + it('parses shorthand GitHub string', () => { 33 + const result = parseRepositoryInfo('github:nuxt/nuxt') 34 + // This format doesn't match the regex, returns undefined 35 + expect(result).toBeUndefined() 36 + }) 37 + 38 + it('parses HTTPS GitHub URL without .git suffix', () => { 39 + const result = parseRepositoryInfo({ 40 + url: 'https://github.com/nuxt/nuxt', 41 + }) 42 + expect(result).toEqual({ 43 + rawBaseUrl: 'https://raw.githubusercontent.com/nuxt/nuxt/HEAD', 44 + directory: undefined, 45 + }) 46 + }) 47 + 48 + it('parses string URL directly', () => { 49 + const result = parseRepositoryInfo('https://github.com/owner/repo.git') 50 + expect(result).toEqual({ 51 + rawBaseUrl: 'https://raw.githubusercontent.com/owner/repo/HEAD', 52 + directory: undefined, 53 + }) 54 + }) 55 + 56 + it('removes trailing slash from directory', () => { 57 + const result = parseRepositoryInfo({ 58 + url: 'git+https://github.com/org/repo.git', 59 + directory: 'packages/foo/', 60 + }) 61 + expect(result?.directory).toBe('packages/foo') 62 + }) 63 + 64 + it('returns undefined for non-GitHub URLs', () => { 65 + const result = parseRepositoryInfo({ 66 + url: 'https://gitlab.com/owner/repo.git', 67 + }) 68 + expect(result).toBeUndefined() 69 + }) 70 + 71 + it('returns undefined for empty URL', () => { 72 + const result = parseRepositoryInfo({ url: '' }) 73 + expect(result).toBeUndefined() 74 + }) 75 + })