Skip to content

Commit 8b834ed

Browse files
committed
fix: render images with relative paths correctly
1 parent 0086606 commit 8b834ed

3 files changed

Lines changed: 170 additions & 11 deletions

File tree

server/api/registry/readme/[...pkg].get.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { parseRepositoryInfo } from '#server/utils/readme'
2+
13
/**
24
* Fetch README from jsdelivr CDN for a specific package version.
35
* Falls back through common README filenames.
@@ -82,7 +84,10 @@ export default defineCachedEventHandler(
8284
return { html: '' }
8385
}
8486

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)
8691
return { html }
8792
} catch (error) {
8893
if (error && typeof error === 'object' && 'statusCode' in error) {

server/utils/readme.ts

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { marked, type Tokens } from 'marked'
22
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+
}
411

512
// only allow h3-h6 since we shift README headings down by 2 levels
613
// (page h1 = package name, h2 = "Readme" section, so README h1 → h3)
@@ -63,31 +70,103 @@ const ALLOWED_ATTR: Record<string, string[]> = {
6370
// GitHub-style callout types
6471
// Format: > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION]
6572

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 {
67113
if (!url) return url
68114
if (url.startsWith('#')) {
69115
return url
70116
}
71117
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) {
72118
return url
73119
}
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)
75150
return `https://cdn.jsdelivr.net/npm/${packageName}/${url.replace(/^\.\//, '')}`
76151
}
77152

78153
// Convert GitHub blob URLs to raw URLs for images
79154
// e.g. https://github.com/nuxt/nuxt/blob/main/.github/assets/banner.svg
80155
// → 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)
83158
// GitHub blob → raw
84159
if (resolved.includes('github.com') && resolved.includes('/blob/')) {
85160
return resolved.replace('/blob/', '/raw/')
86161
}
87162
return resolved
88163
}
89164

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> {
91170
if (!content) return ''
92171

93172
const shiki = await getShikiHighlighter()
@@ -127,15 +206,15 @@ export async function renderReadmeHtml(content: string, packageName: string): Pr
127206

128207
// Resolve image URLs (with GitHub blob → raw conversion)
129208
renderer.image = ({ href, title, text }: Tokens.Image) => {
130-
const resolvedHref = resolveImageUrl(href, packageName)
209+
const resolvedHref = resolveImageUrl(href, packageName, repoInfo)
131210
const titleAttr = title ? ` title="${title}"` : ''
132211
const altAttr = text ? ` alt="${text}"` : ''
133212
return `<img src="${resolvedHref}"${altAttr}${titleAttr}>`
134213
}
135214

136215
// Resolve link URLs and add security attributes
137216
renderer.link = function ({ href, title, tokens }: Tokens.Link) {
138-
const resolvedHref = resolveUrl(href, packageName)
217+
const resolvedHref = resolveUrl(href, packageName, repoInfo)
139218
const text = this.parser.parseInline(tokens)
140219
const titleAttr = title ? ` title="${title}"` : ''
141220

@@ -169,11 +248,11 @@ export async function renderReadmeHtml(content: string, packageName: string): Pr
169248
allowedTags: ALLOWED_TAGS,
170249
allowedAttributes: ALLOWED_ATTR,
171250
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)
173252
transformTags: {
174253
img: (tagName, attribs) => {
175254
if (attribs.src) {
176-
attribs.src = resolveImageUrl(attribs.src, packageName)
255+
attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo)
177256
}
178257
return { tagName, attribs }
179258
},
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
})

0 commit comments

Comments
 (0)