[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.

feat: extract and surface playground links from READMEs (#55)

authored by

Florian Heuberger and committed by
GitHub
5e3ec21e e8e18c3d

+551 -13
+2
README.md
··· 33 33 - **Vulnerability warnings** – security advisories from the OSV database 34 34 - **Download statistics** – weekly download counts with sparkline charts 35 35 - **Install size** – total install size including dependencies 36 + - **Playground links** – quick access to StackBlitz, CodeSandbox, and other demo environments from READMEs 36 37 - **Infinite search** – auto-load additional search pages as you scroll 37 38 38 39 ### User & org pages ··· 62 63 | JSR cross-reference | ❌ | ✅ | 63 64 | Vulnerability warnings | ✅ | ✅ | 64 65 | Download charts | ✅ | ✅ | 66 + | Playground links | ❌ | ✅ | 65 67 | Dependents list | ✅ | 🚧 | 66 68 | Package admin (access/owners) | ✅ | 🚧 | 67 69 | Org/team management | ✅ | 🚧 |
+58
app/components/AppTooltip.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + /** Tooltip text */ 4 + text: string 5 + /** Position: 'top' | 'bottom' | 'left' | 'right' */ 6 + position?: 'top' | 'bottom' | 'left' | 'right' 7 + }>() 8 + 9 + const isVisible = ref(false) 10 + const tooltipId = useId() 11 + 12 + const positionClasses: Record<string, string> = { 13 + top: 'bottom-full left-1/2 -translate-x-1/2 mb-1', 14 + bottom: 'top-full left-0 mt-1', 15 + left: 'right-full top-1/2 -translate-y-1/2 mr-2', 16 + right: 'left-full top-1/2 -translate-y-1/2 ml-2', 17 + } 18 + 19 + const tooltipPosition = computed(() => positionClasses[props.position || 'bottom']) 20 + 21 + function show() { 22 + isVisible.value = true 23 + } 24 + 25 + function hide() { 26 + isVisible.value = false 27 + } 28 + </script> 29 + 30 + <template> 31 + <div 32 + class="relative inline-flex" 33 + :aria-describedby="isVisible ? tooltipId : undefined" 34 + @mouseenter="show" 35 + @mouseleave="hide" 36 + @focusin="show" 37 + @focusout="hide" 38 + > 39 + <slot /> 40 + 41 + <Transition 42 + enter-active-class="transition-opacity duration-150 motion-reduce:transition-none" 43 + leave-active-class="transition-opacity duration-100 motion-reduce:transition-none" 44 + enter-from-class="opacity-0" 45 + leave-to-class="opacity-0" 46 + > 47 + <div 48 + v-if="isVisible" 49 + :id="tooltipId" 50 + role="tooltip" 51 + class="absolute px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-nowrap z-[100] pointer-events-none" 52 + :class="tooltipPosition" 53 + > 54 + {{ text }} 55 + </div> 56 + </Transition> 57 + </div> 58 + </template>
+191
app/components/PackagePlaygrounds.vue
··· 1 + <script setup lang="ts"> 2 + import { onClickOutside } from '@vueuse/core' 3 + import type { PlaygroundLink } from '#shared/types' 4 + 5 + const props = defineProps<{ 6 + links: PlaygroundLink[] 7 + }>() 8 + 9 + // Map provider id to icon class 10 + const providerIcons: Record<string, string> = { 11 + 'stackblitz': 'i-simple-icons-stackblitz', 12 + 'codesandbox': 'i-simple-icons-codesandbox', 13 + 'codepen': 'i-simple-icons-codepen', 14 + 'replit': 'i-simple-icons-replit', 15 + 'gitpod': 'i-simple-icons-gitpod', 16 + 'vue-playground': 'i-simple-icons-vuedotjs', 17 + 'nuxt-new': 'i-simple-icons-nuxtdotjs', 18 + 'vite-new': 'i-simple-icons-vite', 19 + 'jsfiddle': 'i-carbon-code', 20 + } 21 + 22 + // Map provider id to color class 23 + const providerColors: Record<string, string> = { 24 + 'stackblitz': 'text-provider-stackblitz', 25 + 'codesandbox': 'text-provider-codesandbox', 26 + 'codepen': 'text-provider-codepen', 27 + 'replit': 'text-provider-replit', 28 + 'gitpod': 'text-provider-gitpod', 29 + 'vue-playground': 'text-provider-vue', 30 + 'nuxt-new': 'text-provider-nuxt', 31 + 'vite-new': 'text-provider-vite', 32 + 'jsfiddle': 'text-provider-jsfiddle', 33 + } 34 + 35 + function getIcon(provider: string): string { 36 + return providerIcons[provider] || 'i-carbon-play' 37 + } 38 + 39 + function getColor(provider: string): string { 40 + return providerColors[provider] || 'text-fg-muted' 41 + } 42 + 43 + // Dropdown state 44 + const isOpen = ref(false) 45 + const dropdownRef = ref<HTMLElement>() 46 + const menuRef = ref<HTMLElement>() 47 + const focusedIndex = ref(-1) 48 + 49 + onClickOutside(dropdownRef, () => { 50 + isOpen.value = false 51 + }) 52 + 53 + // Single vs multiple 54 + const hasSingleLink = computed(() => props.links.length === 1) 55 + const hasMultipleLinks = computed(() => props.links.length > 1) 56 + const firstLink = computed(() => props.links[0]) 57 + 58 + function closeDropdown() { 59 + isOpen.value = false 60 + focusedIndex.value = -1 61 + } 62 + 63 + function handleKeydown(event: KeyboardEvent) { 64 + if (!isOpen.value) { 65 + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { 66 + event.preventDefault() 67 + isOpen.value = true 68 + focusedIndex.value = 0 69 + nextTick(() => focusMenuItem(0)) 70 + } 71 + return 72 + } 73 + 74 + switch (event.key) { 75 + case 'Escape': 76 + event.preventDefault() 77 + closeDropdown() 78 + break 79 + case 'ArrowDown': 80 + event.preventDefault() 81 + focusedIndex.value = (focusedIndex.value + 1) % props.links.length 82 + focusMenuItem(focusedIndex.value) 83 + break 84 + case 'ArrowUp': 85 + event.preventDefault() 86 + focusedIndex.value = focusedIndex.value <= 0 ? props.links.length - 1 : focusedIndex.value - 1 87 + focusMenuItem(focusedIndex.value) 88 + break 89 + case 'Home': 90 + event.preventDefault() 91 + focusedIndex.value = 0 92 + focusMenuItem(0) 93 + break 94 + case 'End': 95 + event.preventDefault() 96 + focusedIndex.value = props.links.length - 1 97 + focusMenuItem(props.links.length - 1) 98 + break 99 + case 'Tab': 100 + closeDropdown() 101 + break 102 + } 103 + } 104 + 105 + function focusMenuItem(index: number) { 106 + const items = menuRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]') 107 + items?.[index]?.focus() 108 + } 109 + </script> 110 + 111 + <template> 112 + <section v-if="links.length > 0" aria-labelledby="playgrounds-heading"> 113 + <h2 id="playgrounds-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3"> 114 + Try it out 115 + </h2> 116 + 117 + <div ref="dropdownRef" class="relative"> 118 + <!-- Single link: direct button --> 119 + <AppTooltip v-if="hasSingleLink && firstLink" :text="firstLink.providerName" class="w-full"> 120 + <a 121 + :href="firstLink.url" 122 + target="_blank" 123 + rel="noopener noreferrer" 124 + class="w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200" 125 + > 126 + <span 127 + :class="[getIcon(firstLink.provider), getColor(firstLink.provider), 'w-4 h-4 shrink-0']" 128 + aria-hidden="true" 129 + /> 130 + <span class="truncate text-fg-muted">{{ firstLink.label }}</span> 131 + </a> 132 + </AppTooltip> 133 + 134 + <!-- Multiple links: dropdown button --> 135 + <button 136 + v-if="hasMultipleLinks" 137 + type="button" 138 + aria-haspopup="true" 139 + :aria-expanded="isOpen" 140 + class="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200" 141 + @click="isOpen = !isOpen" 142 + @keydown="handleKeydown" 143 + > 144 + <span class="flex items-center gap-2"> 145 + <span class="i-carbon-play w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" /> 146 + <span class="text-fg-muted">choose playground ({{ links.length }})</span> 147 + </span> 148 + <span 149 + class="i-carbon-chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200 motion-reduce:transition-none" 150 + :class="{ 'rotate-180': isOpen }" 151 + aria-hidden="true" 152 + /> 153 + </button> 154 + 155 + <!-- Dropdown menu --> 156 + <Transition 157 + enter-active-class="transition duration-150 ease-out motion-reduce:transition-none" 158 + enter-from-class="opacity-0 scale-95 motion-reduce:scale-100" 159 + enter-to-class="opacity-100 scale-100" 160 + leave-active-class="transition duration-100 ease-in motion-reduce:transition-none" 161 + leave-from-class="opacity-100 scale-100" 162 + leave-to-class="opacity-0 scale-95 motion-reduce:scale-100" 163 + > 164 + <div 165 + v-if="isOpen && hasMultipleLinks" 166 + ref="menuRef" 167 + role="menu" 168 + class="absolute top-full left-0 right-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 overflow-visible" 169 + @keydown="handleKeydown" 170 + > 171 + <AppTooltip v-for="link in links" :key="link.url" :text="link.providerName" class="block"> 172 + <a 173 + :href="link.url" 174 + target="_blank" 175 + rel="noopener noreferrer" 176 + role="menuitem" 177 + class="flex items-center gap-2 px-3 py-2 text-sm font-mono text-fg-muted hover:text-fg hover:bg-bg-muted focus-visible:outline-none focus-visible:text-fg focus-visible:bg-bg-muted transition-colors duration-150" 178 + @click="closeDropdown" 179 + > 180 + <span 181 + :class="[getIcon(link.provider), getColor(link.provider), 'w-4 h-4 shrink-0']" 182 + aria-hidden="true" 183 + /> 184 + <span class="truncate">{{ link.label }}</span> 185 + </a> 186 + </AppTooltip> 187 + </div> 188 + </Transition> 189 + </div> 190 + </section> 191 + </template>
+10 -4
app/pages/[...package].vue
··· 1 1 <script setup lang="ts"> 2 2 import { joinURL } from 'ufo' 3 - import type { PackumentVersion, NpmVersionDist } from '#shared/types' 3 + import type { PackumentVersion, NpmVersionDist, ReadmeResponse } from '#shared/types' 4 4 import type { JsrPackageInfo } from '#shared/types/jsr' 5 5 import { assertValidPackageName } from '#shared/utils/npm' 6 6 ··· 56 56 const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 }) 57 57 58 58 // Fetch README for specific version if requested, otherwise latest 59 - const { data: readmeData } = useLazyFetch<{ html: string }>( 59 + const { data: readmeData } = useLazyFetch<ReadmeResponse>( 60 60 () => { 61 61 const base = `/api/registry/readme/${packageName.value}` 62 62 const version = requestedVersion.value 63 63 return version ? `${base}/v/${version}` : base 64 64 }, 65 - { default: () => ({ html: '' }) }, 65 + { default: () => ({ html: '', playgroundLinks: [] }) }, 66 66 ) 67 67 68 68 // Check if package exists on JSR (only for scoped packages) ··· 686 686 </ul> 687 687 </section> 688 688 689 - <!-- Donwload stats --> 689 + <!-- Download stats --> 690 690 <PackageDownloadStats :downloads="weeklyDownloads" /> 691 + 692 + <!-- Playground links --> 693 + <PackagePlaygrounds 694 + v-if="readmeData?.playgroundLinks?.length" 695 + :links="readmeData.playgroundLinks" 696 + /> 691 697 692 698 <section 693 699 v-if="
+2 -5
server/api/registry/readme/[...pkg].get.ts
··· 1 - import { parseRepositoryInfo } from '#server/utils/readme' 2 - 3 1 /** 4 2 * Fetch README from jsdelivr CDN for a specific package version. 5 3 * Falls back through common README filenames. ··· 82 80 } 83 81 84 82 if (!readmeContent) { 85 - return { html: '' } 83 + return { html: '', playgroundLinks: [] } 86 84 } 87 85 88 86 // Parse repository info for resolving relative URLs to GitHub 89 87 const repoInfo = parseRepositoryInfo(packageData.repository) 90 88 91 - const html = await renderReadmeHtml(readmeContent, packageName, repoInfo) 92 - return { html } 89 + return await renderReadmeHtml(readmeContent, packageName, repoInfo) 93 90 } catch (error) { 94 91 if (error && typeof error === 'object' && 'statusCode' in error) { 95 92 throw error
+119 -4
server/utils/readme.ts
··· 1 1 import { marked, type Tokens } from 'marked' 2 2 import sanitizeHtml from 'sanitize-html' 3 3 import { hasProtocol, withoutTrailingSlash } from 'ufo' 4 + import type { ReadmeResponse } from '#shared/types/readme.js' 5 + 6 + /** 7 + * Playground provider configuration 8 + */ 9 + interface PlaygroundProvider { 10 + id: string // Provider identifier 11 + name: string 12 + domains: string[] // Associated domains 13 + icon?: string // Provider icon name 14 + } 15 + 16 + /** 17 + * Known playground/demo providers 18 + */ 19 + const PLAYGROUND_PROVIDERS: PlaygroundProvider[] = [ 20 + { 21 + id: 'stackblitz', 22 + name: 'StackBlitz', 23 + domains: ['stackblitz.com', 'stackblitz.io'], 24 + icon: 'stackblitz', 25 + }, 26 + { 27 + id: 'codesandbox', 28 + name: 'CodeSandbox', 29 + domains: ['codesandbox.io', 'githubbox.com', 'csb.app'], 30 + icon: 'codesandbox', 31 + }, 32 + { 33 + id: 'codepen', 34 + name: 'CodePen', 35 + domains: ['codepen.io'], 36 + icon: 'codepen', 37 + }, 38 + { 39 + id: 'jsfiddle', 40 + name: 'JSFiddle', 41 + domains: ['jsfiddle.net'], 42 + icon: 'jsfiddle', 43 + }, 44 + { 45 + id: 'replit', 46 + name: 'Replit', 47 + domains: ['repl.it', 'replit.com'], 48 + icon: 'replit', 49 + }, 50 + { 51 + id: 'gitpod', 52 + name: 'Gitpod', 53 + domains: ['gitpod.io'], 54 + icon: 'gitpod', 55 + }, 56 + { 57 + id: 'vue-playground', 58 + name: 'Vue Playground', 59 + domains: ['play.vuejs.org', 'sfc.vuejs.org'], 60 + icon: 'vue', 61 + }, 62 + { 63 + id: 'nuxt-new', 64 + name: 'Nuxt Starter', 65 + domains: ['nuxt.new'], 66 + icon: 'nuxt', 67 + }, 68 + { 69 + id: 'vite-new', 70 + name: 'Vite Starter', 71 + domains: ['vite.new'], 72 + icon: 'vite', 73 + }, 74 + ] 75 + 76 + /** 77 + * Check if a URL is a playground link and return provider info 78 + */ 79 + function matchPlaygroundProvider(url: string): PlaygroundProvider | null { 80 + try { 81 + const parsed = new URL(url) 82 + const hostname = parsed.hostname.toLowerCase() 83 + 84 + for (const provider of PLAYGROUND_PROVIDERS) { 85 + for (const domain of provider.domains) { 86 + if (hostname === domain || hostname.endsWith(`.${domain}`)) { 87 + return provider 88 + } 89 + } 90 + } 91 + } catch { 92 + // Invalid URL 93 + } 94 + return null 95 + } 4 96 5 97 export interface RepositoryInfo { 6 98 /** GitHub raw base URL (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ ··· 166 258 content: string, 167 259 packageName: string, 168 260 repoInfo?: RepositoryInfo, 169 - ): Promise<string> { 170 - if (!content) return '' 261 + ): Promise<ReadmeResponse> { 262 + if (!content) return { html: '', playgroundLinks: [] } 171 263 172 264 const shiki = await getShikiHighlighter() 173 265 const renderer = new marked.Renderer() 266 + 267 + // Collect playground links during parsing 268 + const collectedLinks: PlaygroundLink[] = [] 269 + const seenUrls = new Set<string>() 174 270 175 271 // Shift heading levels down by 2 for semantic correctness 176 272 // Page h1 = package name, h2 = "Readme" section heading ··· 212 308 return `<img src="${resolvedHref}"${altAttr}${titleAttr}>` 213 309 } 214 310 215 - // Resolve link URLs and add security attributes 311 + // Resolve link URLs, add security attributes, and collect playground links 216 312 renderer.link = function ({ href, title, tokens }: Tokens.Link) { 217 313 const resolvedHref = resolveUrl(href, packageName, repoInfo) 218 314 const text = this.parser.parseInline(tokens) ··· 222 318 const relAttr = isExternal ? ' rel="nofollow noreferrer noopener"' : '' 223 319 const targetAttr = isExternal ? ' target="_blank"' : '' 224 320 321 + // Check if this is a playground link 322 + const provider = matchPlaygroundProvider(resolvedHref) 323 + if (provider && !seenUrls.has(resolvedHref)) { 324 + seenUrls.add(resolvedHref) 325 + 326 + // Extract label from link text (strip HTML tags for plain text) 327 + const plainText = text.replace(/<[^>]*>/g, '').trim() 328 + 329 + collectedLinks.push({ 330 + url: resolvedHref, 331 + provider: provider.id, 332 + providerName: provider.name, 333 + label: plainText || title || provider.name, 334 + }) 335 + } 336 + 225 337 return `<a href="${resolvedHref}"${titleAttr}${relAttr}${targetAttr}>${text}</a>` 226 338 } 227 339 ··· 267 379 }, 268 380 }) 269 381 270 - return sanitized 382 + return { 383 + html: sanitized, 384 + playgroundLinks: collectedLinks, 385 + } 271 386 }
+1
shared/types/index.ts
··· 1 1 export * from './npm-registry' 2 2 export * from './jsr' 3 3 export * from './osv' 4 + export * from './readme'
+23
shared/types/readme.ts
··· 1 + /** 2 + * Playground/demo link extracted from README 3 + */ 4 + export interface PlaygroundLink { 5 + /** The full URL */ 6 + url: string 7 + /** Provider identifier (e.g., 'stackblitz', 'codesandbox') */ 8 + provider: string 9 + /** Human-readable provider name (e.g., 'StackBlitz', 'CodeSandbox') */ 10 + providerName: string 11 + /** Link text from README (e.g., 'Demo', 'Try it online') */ 12 + label: string 13 + } 14 + 15 + /** 16 + * Response from README API endpoint 17 + */ 18 + export interface ReadmeResponse { 19 + /** Rendered HTML content */ 20 + html: string 21 + /** Extracted playground/demo links */ 22 + playgroundLinks: PlaygroundLink[] 23 + }
+133
test/unit/playground-links.spec.ts
··· 1 + import { describe, expect, it, vi, beforeAll } from 'vitest' 2 + 3 + // Mock the global Nuxt auto-import before importing the module 4 + beforeAll(() => { 5 + vi.stubGlobal( 6 + 'getShikiHighlighter', 7 + vi.fn().mockResolvedValue({ 8 + getLoadedLanguages: () => [], 9 + codeToHtml: (code: string) => `<pre><code>${code}</code></pre>`, 10 + }), 11 + ) 12 + }) 13 + 14 + // Import after mock is set up 15 + const { renderReadmeHtml } = await import('../../server/utils/readme') 16 + 17 + describe('Playground Link Extraction', () => { 18 + describe('StackBlitz', () => { 19 + it('extracts stackblitz.com links', async () => { 20 + const markdown = `Check out [Demo on StackBlitz](https://stackblitz.com/github/user/repo)` 21 + const result = await renderReadmeHtml(markdown, 'test-pkg') 22 + 23 + expect(result.playgroundLinks).toHaveLength(1) 24 + expect(result.playgroundLinks[0]).toMatchObject({ 25 + provider: 'stackblitz', 26 + providerName: 'StackBlitz', 27 + label: 'Demo on StackBlitz', 28 + url: 'https://stackblitz.com/github/user/repo', 29 + }) 30 + }) 31 + }) 32 + 33 + describe('CodeSandbox', () => { 34 + it('extracts codesandbox.io links', async () => { 35 + const markdown = `[Try it](https://codesandbox.io/s/example-abc123)` 36 + const result = await renderReadmeHtml(markdown, 'test-pkg') 37 + 38 + expect(result.playgroundLinks).toHaveLength(1) 39 + expect(result.playgroundLinks[0]).toMatchObject({ 40 + provider: 'codesandbox', 41 + providerName: 'CodeSandbox', 42 + }) 43 + }) 44 + 45 + it('extracts githubbox.com links as CodeSandbox', async () => { 46 + const markdown = `[Demo](https://githubbox.com/user/repo/tree/main/examples)` 47 + const result = await renderReadmeHtml(markdown, 'test-pkg') 48 + 49 + expect(result.playgroundLinks).toHaveLength(1) 50 + expect(result.playgroundLinks[0].provider).toBe('codesandbox') 51 + }) 52 + }) 53 + 54 + describe('Other Providers', () => { 55 + it('extracts CodePen links', async () => { 56 + const markdown = `[Pen](https://codepen.io/user/pen/abc123)` 57 + const result = await renderReadmeHtml(markdown, 'test-pkg') 58 + 59 + expect(result.playgroundLinks[0].provider).toBe('codepen') 60 + }) 61 + 62 + it('extracts Replit links', async () => { 63 + const markdown = `[Repl](https://replit.com/@user/project)` 64 + const result = await renderReadmeHtml(markdown, 'test-pkg') 65 + 66 + expect(result.playgroundLinks[0].provider).toBe('replit') 67 + }) 68 + 69 + it('extracts Gitpod links', async () => { 70 + const markdown = `[Open in Gitpod](https://gitpod.io/#https://github.com/user/repo)` 71 + const result = await renderReadmeHtml(markdown, 'test-pkg') 72 + 73 + expect(result.playgroundLinks[0].provider).toBe('gitpod') 74 + }) 75 + }) 76 + 77 + describe('Multiple Links', () => { 78 + it('extracts multiple playground links', async () => { 79 + const markdown = ` 80 + - [StackBlitz](https://stackblitz.com/example1) 81 + - [CodeSandbox](https://codesandbox.io/s/example2) 82 + ` 83 + const result = await renderReadmeHtml(markdown, 'test-pkg') 84 + 85 + expect(result.playgroundLinks).toHaveLength(2) 86 + expect(result.playgroundLinks[0].provider).toBe('stackblitz') 87 + expect(result.playgroundLinks[1].provider).toBe('codesandbox') 88 + }) 89 + 90 + it('deduplicates same URL', async () => { 91 + const markdown = ` 92 + [Demo 1](https://stackblitz.com/example) 93 + [Demo 2](https://stackblitz.com/example) 94 + ` 95 + const result = await renderReadmeHtml(markdown, 'test-pkg') 96 + 97 + expect(result.playgroundLinks).toHaveLength(1) 98 + }) 99 + }) 100 + 101 + describe('Non-Playground Links', () => { 102 + it('ignores regular GitHub links', async () => { 103 + const markdown = `[Repo](https://github.com/user/repo)` 104 + const result = await renderReadmeHtml(markdown, 'test-pkg') 105 + 106 + expect(result.playgroundLinks).toHaveLength(0) 107 + }) 108 + 109 + it('ignores npm links', async () => { 110 + const markdown = `[Package](https://npmjs.com/package/test)` 111 + const result = await renderReadmeHtml(markdown, 'test-pkg') 112 + 113 + expect(result.playgroundLinks).toHaveLength(0) 114 + }) 115 + }) 116 + 117 + describe('Edge Cases', () => { 118 + it('returns empty array for empty content', async () => { 119 + const result = await renderReadmeHtml('', 'test-pkg') 120 + 121 + expect(result.playgroundLinks).toEqual([]) 122 + expect(result.html).toBe('') 123 + }) 124 + 125 + it('handles badge images wrapped in links', async () => { 126 + const markdown = `[![Open in StackBlitz](https://img.shields.io/badge/Open-StackBlitz-blue)](https://stackblitz.com/example)` 127 + const result = await renderReadmeHtml(markdown, 'test-pkg') 128 + 129 + expect(result.playgroundLinks).toHaveLength(1) 130 + expect(result.playgroundLinks[0].provider).toBe('stackblitz') 131 + }) 132 + }) 133 + })
+12
uno.config.ts
··· 49 49 kw: '#f97583', // keyword - red/pink 50 50 comment: '#6a737d', // comment - gray 51 51 }, 52 + // Playground provider brand colors 53 + provider: { 54 + stackblitz: '#1389FD', 55 + codesandbox: '#FFCC00', 56 + codepen: '#47CF73', 57 + replit: '#F26207', 58 + gitpod: '#FFAE33', 59 + vue: '#4FC08D', 60 + nuxt: '#00DC82', 61 + vite: '#646CFF', 62 + jsfiddle: '#0084FF', 63 + }, 52 64 }, 53 65 animation: { 54 66 keyframes: {