Skip to content

Commit d1af9c2

Browse files
committed
feat: add logic to extract playground urls
1 parent 43864e0 commit d1af9c2

2 files changed

Lines changed: 121 additions & 9 deletions

File tree

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { parseRepositoryInfo } from '#server/utils/readme'
2-
31
/**
42
* Fetch README from jsdelivr CDN for a specific package version.
53
* Falls back through common README filenames.
@@ -82,14 +80,13 @@ export default defineCachedEventHandler(
8280
}
8381

8482
if (!readmeContent) {
85-
return { html: '' }
83+
return { html: '', playgroundLinks: [] }
8684
}
8785

8886
// Parse repository info for resolving relative URLs to GitHub
8987
const repoInfo = parseRepositoryInfo(packageData.repository)
9088

91-
const html = await renderReadmeHtml(readmeContent, packageName, repoInfo)
92-
return { html }
89+
return await renderReadmeHtml(readmeContent, packageName, repoInfo)
9390
} catch (error) {
9491
if (error && typeof error === 'object' && 'statusCode' in error) {
9592
throw error

server/utils/readme.ts

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,98 @@
11
import { marked, type Tokens } from 'marked'
22
import sanitizeHtml from 'sanitize-html'
33
import { hasProtocol, withoutTrailingSlash } from 'ufo'
4+
import { 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+
}
496

597
export interface RepositoryInfo {
698
/** GitHub raw base URL (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */
@@ -166,12 +258,16 @@ export async function renderReadmeHtml(
166258
content: string,
167259
packageName: string,
168260
repoInfo?: RepositoryInfo,
169-
): Promise<string> {
170-
if (!content) return ''
261+
): Promise<ReadmeResponse> {
262+
if (!content) return { html: '', playgroundLinks: [] }
171263

172264
const shiki = await getShikiHighlighter()
173265
const renderer = new marked.Renderer()
174266

267+
// Collect playground links during parsing
268+
const collectedLinks: PlaygroundLink[] = []
269+
const seenUrls = new Set<string>()
270+
175271
// Shift heading levels down by 2 for semantic correctness
176272
// Page h1 = package name, h2 = "Readme" section heading
177273
// So README h1 → h3, h2 → h4, etc. (capped at h6)
@@ -212,7 +308,7 @@ export async function renderReadmeHtml(
212308
return `<img src="${resolvedHref}"${altAttr}${titleAttr}>`
213309
}
214310

215-
// Resolve link URLs and add security attributes
311+
// Resolve link URLs, add security attributes, and collect playground links
216312
renderer.link = function ({ href, title, tokens }: Tokens.Link) {
217313
const resolvedHref = resolveUrl(href, packageName, repoInfo)
218314
const text = this.parser.parseInline(tokens)
@@ -222,6 +318,22 @@ export async function renderReadmeHtml(
222318
const relAttr = isExternal ? ' rel="nofollow noreferrer noopener"' : ''
223319
const targetAttr = isExternal ? ' target="_blank"' : ''
224320

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+
225337
return `<a href="${resolvedHref}"${titleAttr}${relAttr}${targetAttr}>${text}</a>`
226338
}
227339

@@ -267,5 +379,8 @@ export async function renderReadmeHtml(
267379
},
268380
})
269381

270-
return sanitized
382+
return {
383+
html: sanitized,
384+
playgroundLinks: collectedLinks,
385+
}
271386
}

0 commit comments

Comments
 (0)