From 43864e077d7253418714a6b34d9ee813e08de429 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Sun, 25 Jan 2026 01:29:51 +0100 Subject: [PATCH 01/10] feat: add shared readme types --- shared/types/index.ts | 1 + shared/types/readme.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 shared/types/readme.ts diff --git a/shared/types/index.ts b/shared/types/index.ts index cf3289e003..2414a4f37f 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -1,3 +1,4 @@ export * from './npm-registry' export * from './jsr' export * from './osv' +export * from './readme' diff --git a/shared/types/readme.ts b/shared/types/readme.ts new file mode 100644 index 0000000000..58246fd2a9 --- /dev/null +++ b/shared/types/readme.ts @@ -0,0 +1,23 @@ +/** + * Playground/demo link extracted from README + */ +export interface PlaygroundLink { + /** The full URL */ + url: string + /** Provider identifier (e.g., 'stackblitz', 'codesandbox') */ + provider: string + /** Human-readable provider name (e.g., 'StackBlitz', 'CodeSandbox') */ + providerName: string + /** Link text from README (e.g., 'Demo', 'Try it online') */ + label: string +} + +/** + * Response from README API endpoint + */ +export interface ReadmeResponse { + /** Rendered HTML content */ + html: string + /** Extracted playground/demo links */ + playgroundLinks: PlaygroundLink[] +} From d1af9c255832a198e2cbc38323816da32a261261 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Sun, 25 Jan 2026 01:56:50 +0100 Subject: [PATCH 02/10] feat: add logic to extract playground urls --- server/api/registry/readme/[...pkg].get.ts | 7 +- server/utils/readme.ts | 123 ++++++++++++++++++++- 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/server/api/registry/readme/[...pkg].get.ts b/server/api/registry/readme/[...pkg].get.ts index 7cb299d636..bbcf566702 100644 --- a/server/api/registry/readme/[...pkg].get.ts +++ b/server/api/registry/readme/[...pkg].get.ts @@ -1,5 +1,3 @@ -import { parseRepositoryInfo } from '#server/utils/readme' - /** * Fetch README from jsdelivr CDN for a specific package version. * Falls back through common README filenames. @@ -82,14 +80,13 @@ export default defineCachedEventHandler( } if (!readmeContent) { - return { html: '' } + return { html: '', playgroundLinks: [] } } // Parse repository info for resolving relative URLs to GitHub const repoInfo = parseRepositoryInfo(packageData.repository) - const html = await renderReadmeHtml(readmeContent, packageName, repoInfo) - return { html } + return await renderReadmeHtml(readmeContent, packageName, repoInfo) } catch (error) { if (error && typeof error === 'object' && 'statusCode' in error) { throw error diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 8ac03c12ce..dedd867a0c 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -1,6 +1,98 @@ import { marked, type Tokens } from 'marked' import sanitizeHtml from 'sanitize-html' import { hasProtocol, withoutTrailingSlash } from 'ufo' +import { ReadmeResponse } from '#shared/types/readme.js' + +/** + * Playground provider configuration + */ +interface PlaygroundProvider { + id: string // Provider identifier + name: string + domains: string[] // Associated domains + icon?: string // Provider icon name +} + +/** + * Known playground/demo providers + */ +const PLAYGROUND_PROVIDERS: PlaygroundProvider[] = [ + { + id: 'stackblitz', + name: 'StackBlitz', + domains: ['stackblitz.com', 'stackblitz.io'], + icon: 'stackblitz', + }, + { + id: 'codesandbox', + name: 'CodeSandbox', + domains: ['codesandbox.io', 'githubbox.com', 'csb.app'], + icon: 'codesandbox', + }, + { + id: 'codepen', + name: 'CodePen', + domains: ['codepen.io'], + icon: 'codepen', + }, + { + id: 'jsfiddle', + name: 'JSFiddle', + domains: ['jsfiddle.net'], + icon: 'jsfiddle', + }, + { + id: 'replit', + name: 'Replit', + domains: ['repl.it', 'replit.com'], + icon: 'replit', + }, + { + id: 'gitpod', + name: 'Gitpod', + domains: ['gitpod.io'], + icon: 'gitpod', + }, + { + id: 'vue-playground', + name: 'Vue Playground', + domains: ['play.vuejs.org', 'sfc.vuejs.org'], + icon: 'vue', + }, + { + id: 'nuxt-new', + name: 'Nuxt Starter', + domains: ['nuxt.new'], + icon: 'nuxt', + }, + { + id: 'vite-new', + name: 'Vite Starter', + domains: ['vite.new'], + icon: 'vite', + }, +] + +/** + * Check if a URL is a playground link and return provider info + */ +function matchPlaygroundProvider(url: string): PlaygroundProvider | null { + try { + const parsed = new URL(url) + const hostname = parsed.hostname.toLowerCase() + + for (const provider of PLAYGROUND_PROVIDERS) { + for (const domain of provider.domains) { + if (hostname === domain || hostname.endsWith(`.${domain}`)) { + return provider + } + } + } + } catch { + // Invalid URL + } + return null +} export interface RepositoryInfo { /** GitHub raw base URL (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ @@ -166,12 +258,16 @@ export async function renderReadmeHtml( content: string, packageName: string, repoInfo?: RepositoryInfo, -): Promise { - if (!content) return '' +): Promise { + if (!content) return { html: '', playgroundLinks: [] } const shiki = await getShikiHighlighter() const renderer = new marked.Renderer() + // Collect playground links during parsing + const collectedLinks: PlaygroundLink[] = [] + const seenUrls = new Set() + // Shift heading levels down by 2 for semantic correctness // Page h1 = package name, h2 = "Readme" section heading // So README h1 → h3, h2 → h4, etc. (capped at h6) @@ -212,7 +308,7 @@ export async function renderReadmeHtml( return `` } - // Resolve link URLs and add security attributes + // Resolve link URLs, add security attributes, and collect playground links renderer.link = function ({ href, title, tokens }: Tokens.Link) { const resolvedHref = resolveUrl(href, packageName, repoInfo) const text = this.parser.parseInline(tokens) @@ -222,6 +318,22 @@ export async function renderReadmeHtml( const relAttr = isExternal ? ' rel="nofollow noreferrer noopener"' : '' const targetAttr = isExternal ? ' target="_blank"' : '' + // Check if this is a playground link + const provider = matchPlaygroundProvider(resolvedHref) + if (provider && !seenUrls.has(resolvedHref)) { + seenUrls.add(resolvedHref) + + // Extract label from link text (strip HTML tags for plain text) + const plainText = text.replace(/<[^>]*>/g, '').trim() + + collectedLinks.push({ + url: resolvedHref, + provider: provider.id, + providerName: provider.name, + label: plainText || title || provider.name, + }) + } + return `${text}` } @@ -267,5 +379,8 @@ export async function renderReadmeHtml( }, }) - return sanitized + return { + html: sanitized, + playgroundLinks: collectedLinks, + } } From 79f9d2cc746759a10d613c97ad8f06f89c466b50 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Sun, 25 Jan 2026 01:57:06 +0100 Subject: [PATCH 03/10] feat: add global tooltip component --- app/components/AppTooltip.vue | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 app/components/AppTooltip.vue diff --git a/app/components/AppTooltip.vue b/app/components/AppTooltip.vue new file mode 100644 index 0000000000..df306f98cc --- /dev/null +++ b/app/components/AppTooltip.vue @@ -0,0 +1,47 @@ + + + From a281be6fcf4d3e5dd4a36c15a919dc455753dcc3 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Sun, 25 Jan 2026 02:00:36 +0100 Subject: [PATCH 04/10] feat: add package-playground button --- app/components/PackagePlaygrounds.vue | 130 ++++++++++++++++++++++++++ uno.config.ts | 12 +++ 2 files changed, 142 insertions(+) create mode 100644 app/components/PackagePlaygrounds.vue diff --git a/app/components/PackagePlaygrounds.vue b/app/components/PackagePlaygrounds.vue new file mode 100644 index 0000000000..45e98bc089 --- /dev/null +++ b/app/components/PackagePlaygrounds.vue @@ -0,0 +1,130 @@ + + + diff --git a/uno.config.ts b/uno.config.ts index 1cc521b9b7..5a56bbfbc7 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -49,6 +49,18 @@ export default defineConfig({ kw: '#f97583', // keyword - red/pink comment: '#6a737d', // comment - gray }, + // Playground provider brand colors + provider: { + stackblitz: '#1389FD', + codesandbox: '#FFCC00', + codepen: '#47CF73', + replit: '#F26207', + gitpod: '#FFAE33', + vue: '#4FC08D', + nuxt: '#00DC82', + vite: '#646CFF', + jsfiddle: '#0084FF', + }, }, animation: { keyframes: { From cbf8f98bc29c2c6fd9059b0abd21c8ede010a633 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Sun, 25 Jan 2026 02:01:03 +0100 Subject: [PATCH 05/10] feat: implement package-playground button --- app/pages/[...package].vue | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 144e2b17c7..efbf4c36ad 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -1,6 +1,6 @@