11import { marked , type Tokens } from 'marked'
22import sanitizeHtml from 'sanitize-html'
33import { 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
597export 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