forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { RepositoryInfo } from '#shared/utils/git-providers'
2import { describe, expect, it, vi, beforeAll } from 'vitest'
3
4// Mock the global Nuxt auto-import before importing the module
5beforeAll(() => {
6 vi.stubGlobal(
7 'getShikiHighlighter',
8 vi.fn().mockResolvedValue({
9 getLoadedLanguages: () => [],
10 codeToHtml: (code: string) => `<pre><code>${code}</code></pre>`,
11 }),
12 )
13})
14
15// Import after mock is set up
16const { renderReadmeHtml } = await import('../../../../server/utils/readme')
17
18// Helper to create mock repository info
19function 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
30describe('Playground Link Extraction', () => {
31 describe('StackBlitz', () => {
32 it('extracts stackblitz.com links', async () => {
33 const markdown = `Check out [Demo on StackBlitz](https://stackblitz.com/github/user/repo)`
34 const result = await renderReadmeHtml(markdown, 'test-pkg')
35
36 expect(result.playgroundLinks).toHaveLength(1)
37 expect(result.playgroundLinks[0]).toMatchObject({
38 provider: 'stackblitz',
39 providerName: 'StackBlitz',
40 label: 'Demo on StackBlitz',
41 url: 'https://stackblitz.com/github/user/repo',
42 })
43 })
44 })
45
46 describe('CodeSandbox', () => {
47 it('extracts codesandbox.io links', async () => {
48 const markdown = `[Try it](https://codesandbox.io/s/example-abc123)`
49 const result = await renderReadmeHtml(markdown, 'test-pkg')
50
51 expect(result.playgroundLinks).toHaveLength(1)
52 expect(result.playgroundLinks[0]).toMatchObject({
53 provider: 'codesandbox',
54 providerName: 'CodeSandbox',
55 })
56 })
57
58 it('extracts githubbox.com links as CodeSandbox', async () => {
59 const markdown = `[Demo](https://githubbox.com/user/repo/tree/main/examples)`
60 const result = await renderReadmeHtml(markdown, 'test-pkg')
61
62 expect(result.playgroundLinks).toHaveLength(1)
63 expect(result.playgroundLinks[0]!.provider).toBe('codesandbox')
64 })
65
66 it('extracts label from image link', async () => {
67 const markdown = `[](https://codesandbox.io/s/example-abc123)`
68 const result = await renderReadmeHtml(markdown, 'test-pkg')
69
70 expect(result.playgroundLinks).toHaveLength(1)
71 expect(result.playgroundLinks[0]).toMatchObject({
72 provider: 'codesandbox',
73 providerName: 'CodeSandbox',
74 label: 'Edit CodeSandbox',
75 url: 'https://codesandbox.io/s/example-abc123',
76 })
77 })
78 })
79
80 describe('Other Providers', () => {
81 it('extracts CodePen links', async () => {
82 const markdown = `[Pen](https://codepen.io/user/pen/abc123)`
83 const result = await renderReadmeHtml(markdown, 'test-pkg')
84
85 expect(result.playgroundLinks[0]!.provider).toBe('codepen')
86 })
87
88 it('extracts Replit links', async () => {
89 const markdown = `[Repl](https://replit.com/@user/project)`
90 const result = await renderReadmeHtml(markdown, 'test-pkg')
91
92 expect(result.playgroundLinks[0]!.provider).toBe('replit')
93 })
94
95 it('extracts Gitpod links', async () => {
96 const markdown = `[Open in Gitpod](https://gitpod.io/#https://github.com/user/repo)`
97 const result = await renderReadmeHtml(markdown, 'test-pkg')
98
99 expect(result.playgroundLinks[0]!.provider).toBe('gitpod')
100 })
101 })
102
103 describe('Multiple Links', () => {
104 it('extracts multiple playground links', async () => {
105 const markdown = `
106- [StackBlitz](https://stackblitz.com/example1)
107- [CodeSandbox](https://codesandbox.io/s/example2)
108`
109 const result = await renderReadmeHtml(markdown, 'test-pkg')
110
111 expect(result.playgroundLinks).toHaveLength(2)
112 expect(result.playgroundLinks[0]!.provider).toBe('stackblitz')
113 expect(result.playgroundLinks[1]!.provider).toBe('codesandbox')
114 })
115
116 it('deduplicates same URL', async () => {
117 const markdown = `
118[Demo 1](https://stackblitz.com/example)
119[Demo 2](https://stackblitz.com/example)
120`
121 const result = await renderReadmeHtml(markdown, 'test-pkg')
122
123 expect(result.playgroundLinks).toHaveLength(1)
124 })
125 })
126
127 describe('Non-Playground Links', () => {
128 it('ignores regular GitHub links', async () => {
129 const markdown = `[Repo](https://github.com/user/repo)`
130 const result = await renderReadmeHtml(markdown, 'test-pkg')
131
132 expect(result.playgroundLinks).toHaveLength(0)
133 })
134
135 it('ignores npm links', async () => {
136 const markdown = `[Package](https://npmjs.com/package/test)`
137 const result = await renderReadmeHtml(markdown, 'test-pkg')
138
139 expect(result.playgroundLinks).toHaveLength(0)
140 })
141 })
142
143 describe('Edge Cases', () => {
144 it('returns empty array for empty content', async () => {
145 const result = await renderReadmeHtml('', 'test-pkg')
146
147 expect(result.playgroundLinks).toEqual([])
148 expect(result.html).toBe('')
149 })
150
151 it('handles badge images wrapped in links', async () => {
152 const markdown = `[](https://stackblitz.com/example)`
153 const result = await renderReadmeHtml(markdown, 'test-pkg')
154
155 expect(result.playgroundLinks).toHaveLength(1)
156 expect(result.playgroundLinks[0]!.provider).toBe('stackblitz')
157 })
158 })
159})
160
161describe('Markdown File URL Resolution', () => {
162 describe('with repository info', () => {
163 it('resolves relative .md links to blob URL for rendered viewing', async () => {
164 const repoInfo = createRepoInfo()
165 const markdown = `[Contributing](./CONTRIBUTING.md)`
166 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
167
168 expect(result.html).toContain(
169 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
170 )
171 })
172
173 it('resolves relative .MD links (uppercase) to blob URL', async () => {
174 const repoInfo = createRepoInfo()
175 const markdown = `[Guide](./GUIDE.MD)`
176 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
177
178 expect(result.html).toContain(
179 'href="https://github.com/test-owner/test-repo/blob/HEAD/GUIDE.MD"',
180 )
181 })
182
183 it('resolves nested relative .md links to blob URL', async () => {
184 const repoInfo = createRepoInfo()
185 const markdown = `[API Docs](./docs/api/reference.md)`
186 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
187
188 expect(result.html).toContain(
189 'href="https://github.com/test-owner/test-repo/blob/HEAD/docs/api/reference.md"',
190 )
191 })
192
193 it('resolves relative .md links with query strings to blob URL', async () => {
194 const repoInfo = createRepoInfo()
195 const markdown = `[FAQ](./FAQ.md?ref=main)`
196 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
197
198 expect(result.html).toContain(
199 'href="https://github.com/test-owner/test-repo/blob/HEAD/FAQ.md?ref=main"',
200 )
201 })
202
203 it('resolves relative .md links with anchors to blob URL', async () => {
204 const repoInfo = createRepoInfo()
205 const markdown = `[Install Section](./CONTRIBUTING.md#installation)`
206 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
207
208 expect(result.html).toContain(
209 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md#installation"',
210 )
211 })
212
213 it('resolves non-.md files to raw URL (not blob)', async () => {
214 const repoInfo = createRepoInfo()
215 const markdown = `[Image](./assets/logo.png)`
216 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
217
218 expect(result.html).toContain(
219 'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"',
220 )
221 })
222
223 it('handles monorepo directory for .md links', async () => {
224 const repoInfo = createRepoInfo({
225 directory: 'packages/core',
226 })
227 const markdown = `[Changelog](./CHANGELOG.md)`
228 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
229
230 expect(result.html).toContain(
231 'href="https://github.com/test-owner/test-repo/blob/HEAD/packages/core/CHANGELOG.md"',
232 )
233 })
234
235 it('handles parent directory navigation for .md links', async () => {
236 const repoInfo = createRepoInfo({
237 directory: 'packages/core',
238 })
239 const markdown = `[Root Contributing](../../CONTRIBUTING.md)`
240 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
241
242 expect(result.html).toContain(
243 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
244 )
245 })
246 })
247
248 describe('without repository info', () => {
249 it('leaves relative .md links unchanged (no jsdelivr fallback)', async () => {
250 const markdown = `[Contributing](./CONTRIBUTING.md)`
251 const result = await renderReadmeHtml(markdown, 'test-pkg')
252
253 // Should remain unchanged, not converted to jsdelivr
254 expect(result.html).toContain('href="./CONTRIBUTING.md"')
255 })
256
257 it('resolves non-.md files to jsdelivr CDN', async () => {
258 const markdown = `[Schema](./schema.json)`
259 const result = await renderReadmeHtml(markdown, 'test-pkg')
260
261 expect(result.html).toContain('href="https://cdn.jsdelivr.net/npm/test-pkg/schema.json"')
262 })
263 })
264
265 describe('absolute URLs', () => {
266 it('leaves absolute .md URLs unchanged', async () => {
267 const repoInfo = createRepoInfo()
268 const markdown = `[External Guide](https://example.com/guide.md)`
269 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
270
271 expect(result.html).toContain('href="https://example.com/guide.md"')
272 })
273
274 it('leaves absolute non-.md URLs unchanged', async () => {
275 const repoInfo = createRepoInfo()
276 const markdown = `[Docs](https://docs.example.com/)`
277 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
278
279 expect(result.html).toContain('href="https://docs.example.com/"')
280 })
281 })
282
283 describe('anchor links', () => {
284 it('prefixes anchor links with user-content-', async () => {
285 const markdown = `[Jump to section](#installation)`
286 const result = await renderReadmeHtml(markdown, 'test-pkg')
287
288 expect(result.html).toContain('href="#user-content-installation"')
289 })
290 })
291
292 describe('different git providers', () => {
293 it('uses correct blob URL format for GitLab', async () => {
294 const repoInfo = createRepoInfo({
295 provider: 'gitlab',
296 host: 'gitlab.com',
297 rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD',
298 blobBaseUrl: 'https://gitlab.com/owner/repo/-/blob/HEAD',
299 })
300 const markdown = `[Docs](./docs/guide.md)`
301 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
302
303 expect(result.html).toContain(
304 'href="https://gitlab.com/owner/repo/-/blob/HEAD/docs/guide.md"',
305 )
306 })
307
308 it('uses correct blob URL format for Bitbucket', async () => {
309 const repoInfo = createRepoInfo({
310 provider: 'bitbucket',
311 rawBaseUrl: 'https://bitbucket.org/owner/repo/raw/HEAD',
312 blobBaseUrl: 'https://bitbucket.org/owner/repo/src/HEAD',
313 })
314 const markdown = `[Readme](./other/README.md)`
315 const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
316
317 expect(result.html).toContain(
318 'href="https://bitbucket.org/owner/repo/src/HEAD/other/README.md"',
319 )
320 })
321 })
322
323 describe('npm.js urls', () => {
324 it('redirects npmjs.com urls to local', async () => {
325 const markdown = `[Some npmjs.com link](https://www.npmjs.com/package/test-pkg)`
326 const result = await renderReadmeHtml(markdown, 'test-pkg')
327
328 expect(result.html).toContain('href="/package/test-pkg"')
329 })
330
331 it('redirects npmjs.com urls to local (no www and http)', async () => {
332 const markdown = `[Some npmjs.com link](http://npmjs.com/package/test-pkg)`
333 const result = await renderReadmeHtml(markdown, 'test-pkg')
334
335 expect(result.html).toContain('href="/package/test-pkg"')
336 })
337
338 it('does not redirect npmjs.com to local if they are in the list of exceptions', async () => {
339 const markdown = `[Root Contributing](https://www.npmjs.com/products)`
340 const result = await renderReadmeHtml(markdown, 'test-pkg')
341
342 expect(result.html).toContain('href="https://www.npmjs.com/products"')
343 })
344 })
345})
346
347describe('ReadmeResponse shape (HTML route contract)', () => {
348 it('returns ReadmeResponse with html, mdExists, playgroundLinks, toc', async () => {
349 const markdown = `# Title\n\nSome **bold** text.`
350 const result = await renderReadmeHtml(markdown, 'test-pkg')
351
352 expect(result).toMatchObject({
353 html: expect.any(String),
354 mdExists: true,
355 playgroundLinks: [],
356 toc: expect.any(Array),
357 })
358 expect(result.html).toContain('Title')
359 expect(result.html).toContain('bold')
360 })
361
362 it('returns empty-state shape when content is empty', async () => {
363 const result = await renderReadmeHtml('', 'test-pkg')
364
365 expect(result).toMatchObject({
366 html: '',
367 playgroundLinks: [],
368 toc: [],
369 })
370 expect(result.playgroundLinks).toHaveLength(0)
371 expect(result.toc).toHaveLength(0)
372 })
373
374 it('extracts toc from headings', async () => {
375 const markdown = `# Install\n\n## CLI\n\n## API`
376 const result = await renderReadmeHtml(markdown, 'test-pkg')
377
378 expect(result.toc).toHaveLength(3)
379 expect(result.toc[0]).toMatchObject({ text: 'Install', depth: 1 })
380 expect(result.toc[1]).toMatchObject({ text: 'CLI', depth: 2 })
381 expect(result.toc[2]).toMatchObject({ text: 'API', depth: 2 })
382 expect(result.toc.every(t => t.id.startsWith('user-content-'))).toBe(true)
383 })
384})
385
386describe('HTML output', () => {
387 it('returns sanitized html', async () => {
388 const markdown = `# Title\n\nSome **bold** text and a [link](https://example.com).`
389 const result = await renderReadmeHtml(markdown, 'test-pkg')
390
391 expect(result.html)
392 .toBe(`<h3 id="user-content-title" data-level="1"><a href="#user-content-title">Title</a></h3>
393<p>Some <strong>bold</strong> text and a <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">link</a>.</p>
394`)
395 })
396})