Skip to content

Commit b914c04

Browse files
fix: resolve relative markdown links to repository blob URLs (#716)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 5cf00f1 commit b914c04

File tree

5 files changed

+339
-5
lines changed

5 files changed

+339
-5
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default defineCachedEventHandler(
126126
swr: true,
127127
getKey: event => {
128128
const pkg = getRouterParam(event, 'pkg') ?? ''
129-
return `readme:v6:${pkg.replace(/\/+$/, '').trim()}`
129+
return `readme:v7:${pkg.replace(/\/+$/, '').trim()}`
130130
},
131131
},
132132
)

server/utils/readme.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ function slugify(text: string): string {
183183
/**
184184
* Resolve a relative URL to an absolute URL.
185185
* 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).
187188
*/
188189
function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string {
189190
if (!url) return url
@@ -207,7 +208,10 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
207208
// for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative
208209
}
209210

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
211215
// This handles assets that exist in the repo but not in the npm tarball
212216
if (repoInfo?.rawBaseUrl) {
213217
// Normalize the relative path (remove leading ./)
@@ -232,7 +236,16 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
232236
}
233237
}
234238

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
236249
}
237250

238251
// Fallback: relative URLs → jsdelivr CDN (may 404 if asset not in npm tarball)

shared/utils/git-providers.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface RepoRef {
2222
export interface RepositoryInfo extends RepoRef {
2323
/** Raw file URL base (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */
2424
rawBaseUrl: string
25+
/** Blob/rendered file URL base (e.g., https://github.com/owner/repo/blob/HEAD) */
26+
blobBaseUrl: string
2527
/** Subdirectory within repo where package lives (e.g., packages/ai) */
2628
directory?: string
2729
}
@@ -44,6 +46,8 @@ interface ProviderConfig {
4446
parsePath(parts: string[]): { owner: string; repo: string } | null
4547
/** Get raw file URL base for resolving relative paths */
4648
getRawBaseUrl(ref: RepoRef, branch?: string): string
49+
/** Get blob/rendered URL base for markdown files */
50+
getBlobBaseUrl(ref: RepoRef, branch?: string): string
4751
/** Convert blob URLs to raw URLs (for images) */
4852
blobToRaw?(url: string): string
4953
}
@@ -63,6 +67,8 @@ const providers: ProviderConfig[] = [
6367
},
6468
getRawBaseUrl: (ref, branch = 'HEAD') =>
6569
`https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/${branch}`,
70+
getBlobBaseUrl: (ref, branch = 'HEAD') =>
71+
`https://github.com/${ref.owner}/${ref.repo}/blob/${branch}`,
6672
blobToRaw: url => url.replace('/blob/', '/raw/'),
6773
},
6874
{
@@ -85,6 +91,10 @@ const providers: ProviderConfig[] = [
8591
const host = ref.host ?? 'gitlab.com'
8692
return `https://${host}/${ref.owner}/${ref.repo}/-/raw/${branch}`
8793
},
94+
getBlobBaseUrl: (ref, branch = 'HEAD') => {
95+
const host = ref.host ?? 'gitlab.com'
96+
return `https://${host}/${ref.owner}/${ref.repo}/-/blob/${branch}`
97+
},
8898
blobToRaw: url => url.replace('/-/blob/', '/-/raw/'),
8999
},
90100
{
@@ -101,6 +111,8 @@ const providers: ProviderConfig[] = [
101111
},
102112
getRawBaseUrl: (ref, branch = 'HEAD') =>
103113
`https://bitbucket.org/${ref.owner}/${ref.repo}/raw/${branch}`,
114+
getBlobBaseUrl: (ref, branch = 'HEAD') =>
115+
`https://bitbucket.org/${ref.owner}/${ref.repo}/src/${branch}`,
104116
blobToRaw: url => url.replace('/src/', '/raw/'),
105117
},
106118
{
@@ -117,6 +129,8 @@ const providers: ProviderConfig[] = [
117129
},
118130
getRawBaseUrl: (ref, branch = 'HEAD') =>
119131
`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}`,
120134
blobToRaw: url => url.replace('/src/', '/raw/'),
121135
},
122136
{
@@ -133,6 +147,8 @@ const providers: ProviderConfig[] = [
133147
},
134148
getRawBaseUrl: (ref, branch = 'master') =>
135149
`https://gitee.com/${ref.owner}/${ref.repo}/raw/${branch}`,
150+
getBlobBaseUrl: (ref, branch = 'master') =>
151+
`https://gitee.com/${ref.owner}/${ref.repo}/blob/${branch}`,
136152
blobToRaw: url => url.replace('/blob/', '/raw/'),
137153
},
138154
{
@@ -150,6 +166,8 @@ const providers: ProviderConfig[] = [
150166
},
151167
getRawBaseUrl: (ref, branch = 'HEAD') =>
152168
`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`,
153171
},
154172
{
155173
id: 'tangled',
@@ -170,6 +188,8 @@ const providers: ProviderConfig[] = [
170188
},
171189
getRawBaseUrl: (ref, branch = 'main') =>
172190
`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}`,
173193
blobToRaw: url => url.replace('/blob/', '/raw/branch/'),
174194
},
175195
{
@@ -187,6 +207,8 @@ const providers: ProviderConfig[] = [
187207
},
188208
getRawBaseUrl: (ref, branch = 'HEAD') =>
189209
`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}`,
190212
},
191213
{
192214
id: 'forgejo',
@@ -211,6 +233,10 @@ const providers: ProviderConfig[] = [
211233
const host = ref.host ?? 'codeberg.org'
212234
return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}`
213235
},
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+
},
214240
blobToRaw: url => url.replace('/src/', '/raw/'),
215241
},
216242
{
@@ -251,6 +277,10 @@ const providers: ProviderConfig[] = [
251277
const host = ref.host ?? 'gitea.io'
252278
return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}`
253279
},
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}`
283+
},
254284
blobToRaw: url => url.replace('/src/', '/raw/'),
255285
},
256286
]
@@ -347,6 +377,7 @@ export function parseRepositoryInfo(
347377
return {
348378
...ref,
349379
rawBaseUrl: provider.getRawBaseUrl(ref),
380+
blobBaseUrl: provider.getBlobBaseUrl(ref),
350381
directory: directory ? withoutTrailingSlash(directory) : undefined,
351382
}
352383
}

test/unit/server/utils/readme.spec.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { RepositoryInfo } from '#shared/utils/git-providers'
12
import { describe, expect, it, vi, beforeAll } from 'vitest'
23

34
// Mock the global Nuxt auto-import before importing the module
@@ -14,6 +15,18 @@ beforeAll(() => {
1415
// Import after mock is set up
1516
const { renderReadmeHtml } = await import('../../../../server/utils/readme')
1617

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+
}
29+
1730
describe('Playground Link Extraction', () => {
1831
describe('StackBlitz', () => {
1932
it('extracts stackblitz.com links', async () => {
@@ -131,3 +144,166 @@ describe('Playground Link Extraction', () => {
131144
})
132145
})
133146
})
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+
})

0 commit comments

Comments
 (0)