[READ-ONLY] a fast, modern browser for the npm registry
at main 396 lines 14 kB view raw
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 = `[![Edit CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](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 = `[![Open in StackBlitz](https://img.shields.io/badge/Open-StackBlitz-blue)](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})