···11+import type { DocsResponse } from '#shared/types'
22+import { fetchNpmPackage } from '#server/utils/npm'
33+import { assertValidPackageName } from '#shared/utils/npm'
44+import { parsePackageParam } from '#shared/utils/parse-package-param'
55+import { generateDocsWithDeno } from '#server/utils/docs'
66+77+export default defineCachedEventHandler(
88+ async event => {
99+ const pkgParam = getRouterParam(event, 'pkg')
1010+ if (!pkgParam) {
1111+ throw createError({ statusCode: 400, message: 'Package name is required' })
1212+ }
1313+1414+ const { packageName, version: requestedVersion } = parsePackageParam(pkgParam)
1515+1616+ if (!packageName) {
1717+ throw createError({ statusCode: 400, message: 'Package name is required' })
1818+ }
1919+ assertValidPackageName(packageName)
2020+2121+ const packument = await fetchNpmPackage(packageName)
2222+ const version = requestedVersion ?? packument['dist-tags']?.latest
2323+2424+ if (!version) {
2525+ throw createError({ statusCode: 404, message: 'No latest version found' })
2626+ }
2727+2828+ let generated
2929+ try {
3030+ generated = await generateDocsWithDeno(packageName, version)
3131+ } catch (error) {
3232+ console.error(`Doc generation failed for ${packageName}@${version}:`, error)
3333+ return {
3434+ package: packageName,
3535+ version,
3636+ html: '',
3737+ toc: null,
3838+ status: 'error',
3939+ message: 'Failed to generate documentation. Please try again later.',
4040+ } satisfies DocsResponse
4141+ }
4242+4343+ if (!generated) {
4444+ return {
4545+ package: packageName,
4646+ version,
4747+ html: '',
4848+ toc: null,
4949+ status: 'missing',
5050+ message: 'Docs are not available for this package. It may not have TypeScript types.',
5151+ } satisfies DocsResponse
5252+ }
5353+5454+ return {
5555+ package: packageName,
5656+ version,
5757+ html: generated.html,
5858+ toc: generated.toc,
5959+ status: 'ok',
6060+ } satisfies DocsResponse
6161+ },
6262+ {
6363+ maxAge: 60 * 60, // 1 hour cache
6464+ swr: true,
6565+ getKey: event => {
6666+ const pkg = getRouterParam(event, 'pkg') ?? ''
6767+ return `docs:v1:${pkg}`
6868+ },
6969+ },
7070+)
+3
server/utils/code-highlight.ts
···268268 theme: 'github-dark',
269269 })
270270271271+ // Shiki doesn't encode > in text content (e.g., arrow functions)
272272+ html = escapeRawGt(html)
273273+271274 // Make import statements clickable for JS/TS languages
272275 if (IMPORT_LANGUAGES.has(language)) {
273276 html = linkifyImports(html, {
+173
server/utils/docs/client.ts
···11+/**
22+ * Deno Integration (WASM)
33+ *
44+ * Uses @deno/doc (WASM build of deno_doc) for documentation generation.
55+ * This runs entirely in Node.js without requiring a Deno subprocess.
66+ *
77+ * @module server/utils/docs/client
88+ */
99+1010+import { doc } from '@deno/doc'
1111+import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc'
1212+1313+// =============================================================================
1414+// Configuration
1515+// =============================================================================
1616+1717+/** Timeout for fetching modules in milliseconds */
1818+const FETCH_TIMEOUT_MS = 30 * 1000
1919+2020+// =============================================================================
2121+// Main Export
2222+// =============================================================================
2323+2424+/**
2525+ * Get documentation nodes for a package using @deno/doc WASM.
2626+ */
2727+export async function getDocNodes(packageName: string, version: string): Promise<DenoDocResult> {
2828+ // Get types URL from esm.sh header
2929+ const typesUrl = await getTypesUrl(packageName, version)
3030+3131+ if (!typesUrl) {
3232+ return { version: 1, nodes: [] }
3333+ }
3434+3535+ // Generate docs using @deno/doc WASM
3636+ const result = await doc([typesUrl], {
3737+ load: createLoader(),
3838+ resolve: createResolver(),
3939+ })
4040+4141+ // Collect all nodes from all specifiers
4242+ const allNodes: DenoDocNode[] = []
4343+ for (const nodes of Object.values(result)) {
4444+ allNodes.push(...(nodes as DenoDocNode[]))
4545+ }
4646+4747+ return { version: 1, nodes: allNodes }
4848+}
4949+5050+// =============================================================================
5151+// Module Loading
5252+// =============================================================================
5353+5454+/** Load response for the doc() function */
5555+interface LoadResponse {
5656+ kind: 'module'
5757+ specifier: string
5858+ headers?: Record<string, string>
5959+ content: string
6060+}
6161+6262+/**
6363+ * Create a custom module loader for @deno/doc.
6464+ *
6565+ * Fetches modules from URLs using fetch(), with proper timeout handling.
6666+ */
6767+function createLoader(): (
6868+ specifier: string,
6969+ isDynamic?: boolean,
7070+ cacheSetting?: string,
7171+ checksum?: string,
7272+) => Promise<LoadResponse | undefined> {
7373+ return async (
7474+ specifier: string,
7575+ _isDynamic?: boolean,
7676+ _cacheSetting?: string,
7777+ _checksum?: string,
7878+ ) => {
7979+ let url: URL
8080+ try {
8181+ url = new URL(specifier)
8282+ } catch {
8383+ return undefined
8484+ }
8585+8686+ // Only handle http/https URLs
8787+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
8888+ return undefined
8989+ }
9090+9191+ const controller = new AbortController()
9292+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
9393+9494+ try {
9595+ const response = await fetch(url.toString(), {
9696+ redirect: 'follow',
9797+ signal: controller.signal,
9898+ })
9999+ clearTimeout(timeoutId)
100100+101101+ if (response.status !== 200) {
102102+ return undefined
103103+ }
104104+105105+ const content = await response.text()
106106+ const headers: Record<string, string> = {}
107107+ for (const [key, value] of response.headers) {
108108+ headers[key.toLowerCase()] = value
109109+ }
110110+111111+ return {
112112+ kind: 'module',
113113+ specifier: response.url,
114114+ headers,
115115+ content,
116116+ }
117117+ } catch {
118118+ clearTimeout(timeoutId)
119119+ return undefined
120120+ }
121121+ }
122122+}
123123+124124+/**
125125+ * Create a module resolver for @deno/doc.
126126+ *
127127+ * Handles resolving relative imports and esm.sh redirects.
128128+ */
129129+function createResolver(): (specifier: string, referrer: string) => string {
130130+ return (specifier: string, referrer: string) => {
131131+ // Handle relative imports
132132+ if (specifier.startsWith('.') || specifier.startsWith('/')) {
133133+ return new URL(specifier, referrer).toString()
134134+ }
135135+136136+ // Handle bare specifiers - resolve through esm.sh
137137+ if (!specifier.startsWith('http://') && !specifier.startsWith('https://')) {
138138+ // Try to resolve bare specifier relative to esm.sh base
139139+ const baseUrl = new URL(referrer)
140140+ if (baseUrl.hostname === 'esm.sh') {
141141+ return `https://esm.sh/${specifier}`
142142+ }
143143+ }
144144+145145+ return specifier
146146+ }
147147+}
148148+149149+/**
150150+ * Get the TypeScript types URL from esm.sh's x-typescript-types header.
151151+ *
152152+ * esm.sh serves types URL in the `x-typescript-types` header, not at the main URL.
153153+ * Example: curl -sI 'https://esm.sh/ufo@1.5.0' returns header:
154154+ * x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts
155155+ */
156156+async function getTypesUrl(packageName: string, version: string): Promise<string | null> {
157157+ const url = `https://esm.sh/${packageName}@${version}`
158158+159159+ const controller = new AbortController()
160160+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
161161+162162+ try {
163163+ const response = await fetch(url, {
164164+ method: 'HEAD',
165165+ signal: controller.signal,
166166+ })
167167+ clearTimeout(timeoutId)
168168+ return response.headers.get('x-typescript-types')
169169+ } catch {
170170+ clearTimeout(timeoutId)
171171+ return null
172172+ }
173173+}
···11+/**
22+ * Text Processing Utilities
33+ *
44+ * Functions for escaping HTML, parsing JSDoc links, and rendering markdown.
55+ *
66+ * @module server/utils/docs/text
77+ */
88+99+import { highlightCodeBlock } from '../shiki'
1010+import type { SymbolLookup } from './types'
1111+1212+/**
1313+ * Strip ANSI escape codes from text.
1414+ * Deno doc output may contain terminal color codes that need to be removed.
1515+ */
1616+const ESC = String.fromCharCode(27)
1717+const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
1818+1919+export function stripAnsi(text: string): string {
2020+ return text.replace(ANSI_PATTERN, '')
2121+}
2222+2323+/**
2424+ * Escape HTML special characters.
2525+ *
2626+ * @internal Exported for testing
2727+ */
2828+export function escapeHtml(text: string): string {
2929+ return text
3030+ .replace(/&/g, '&')
3131+ .replace(/</g, '<')
3232+ .replace(/>/g, '>')
3333+ .replace(/"/g, '"')
3434+ .replace(/'/g, ''')
3535+}
3636+3737+/**
3838+ * Clean up symbol names by stripping esm.sh prefixes.
3939+ *
4040+ * Packages using @types/* definitions get "default." or "default_" prefixes
4141+ * from esm.sh that we need to remove for clean display.
4242+ */
4343+export function cleanSymbolName(name: string): string {
4444+ if (name.startsWith('default.')) {
4545+ return name.slice(8)
4646+ }
4747+ if (name.startsWith('default_')) {
4848+ return name.slice(8)
4949+ }
5050+ return name
5151+}
5252+5353+/**
5454+ * Create a URL-safe HTML anchor ID for a symbol.
5555+ */
5656+export function createSymbolId(kind: string, name: string): string {
5757+ return `${kind}-${name}`.replace(/[^a-zA-Z0-9-]/g, '_')
5858+}
5959+6060+/**
6161+ * Parse JSDoc {@link} tags into HTML links.
6262+ *
6363+ * Handles:
6464+ * - {@link https://example.com} - external URL
6565+ * - {@link https://example.com Link Text} - external URL with label
6666+ * - {@link SomeSymbol} - internal cross-reference
6767+ *
6868+ * @internal Exported for testing
6969+ */
7070+export function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): string {
7171+ let result = escapeHtml(text)
7272+7373+ result = result.replace(/\{@link\s+([^\s}]+)(?:\s+([^}]+))?\}/g, (_, target, label) => {
7474+ const displayText = label || target
7575+7676+ // External URL
7777+ if (target.startsWith('http://') || target.startsWith('https://')) {
7878+ return `<a href="${target}" target="_blank" rel="noopener" class="docs-link">${displayText}</a>`
7979+ }
8080+8181+ // Internal symbol reference
8282+ const symbolId = symbolLookup.get(target)
8383+ if (symbolId) {
8484+ return `<a href="#${symbolId}" class="docs-symbol-link">${displayText}</a>`
8585+ }
8686+8787+ // Unknown symbol
8888+ return `<code class="docs-symbol-ref">${displayText}</code>`
8989+ })
9090+9191+ return result
9292+}
9393+9494+/**
9595+ * Render simple markdown-like formatting.
9696+ * Uses <br> for line breaks to avoid nesting issues with inline elements.
9797+ * Fenced code blocks (```) are syntax-highlighted with Shiki.
9898+ *
9999+ * @internal Exported for testing
100100+ */
101101+export async function renderMarkdown(text: string, symbolLookup: SymbolLookup): Promise<string> {
102102+ // Extract fenced code blocks FIRST (before any HTML escaping)
103103+ // Pattern handles:
104104+ // - Optional whitespace before/after language identifier
105105+ // - \r\n, \n, or \r line endings
106106+ const codeBlockData: Array<{ lang: string; code: string }> = []
107107+ let result = text.replace(
108108+ /```[ \t]*(\w*)[ \t]*(?:\r\n|\r|\n)([\s\S]*?)(?:\r\n|\r|\n)?```/g,
109109+ (_, lang, code) => {
110110+ const index = codeBlockData.length
111111+ codeBlockData.push({ lang: lang || 'text', code: code.trim() })
112112+ return `__CODE_BLOCK_${index}__`
113113+ },
114114+ )
115115+116116+ // Now process the rest (JSDoc links, HTML escaping, etc.)
117117+ result = parseJsDocLinks(result, symbolLookup)
118118+119119+ // Handle inline code (single backticks) - won't interfere with fenced blocks
120120+ result = result
121121+ .replace(/`([^`]+)`/g, '<code class="docs-inline-code">$1</code>')
122122+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
123123+ .replace(/\n\n+/g, '<br><br>')
124124+ .replace(/\n/g, '<br>')
125125+126126+ // Highlight and restore code blocks
127127+ for (let i = 0; i < codeBlockData.length; i++) {
128128+ const { lang, code } = codeBlockData[i]!
129129+ const highlighted = await highlightCodeBlock(code, lang)
130130+ result = result.replace(`__CODE_BLOCK_${i}__`, highlighted)
131131+ }
132132+133133+ return result
134134+}
+24
server/utils/docs/types.ts
···11+/**
22+ * Internal Types for Docs Module
33+ * These are highly coupled to `deno doc`, hence they live here instead of shared types.
44+ *
55+ * @module server/utils/docs/types
66+ */
77+88+import type { DenoDocNode } from '#shared/types/deno-doc'
99+1010+/**
1111+ * Map of symbol names to anchor IDs for cross-referencing.
1212+ * @internal Exported for testing
1313+ */
1414+export type SymbolLookup = Map<string, string>
1515+1616+/**
1717+ * Symbol with merged overloads
1818+ */
1919+export interface MergedSymbol {
2020+ name: string
2121+ kind: string
2222+ nodes: DenoDocNode[]
2323+ jsDoc?: DenoDocNode['jsDoc']
2424+}
+2-18
server/utils/readme.ts
···33import { hasProtocol } from 'ufo'
44import type { ReadmeResponse } from '#shared/types/readme'
55import { convertBlobToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers'
66+import { highlightCodeSync } from './shiki'
6778/**
89 * Playground provider configuration
···266267267268 // Syntax highlighting for code blocks (uses shared highlighter)
268269 renderer.code = ({ text, lang }: Tokens.Code) => {
269269- const language = lang || 'text'
270270- const loadedLangs = shiki.getLoadedLanguages()
271271-272272- // Use Shiki if language is loaded, otherwise fall back to plain
273273- if (loadedLangs.includes(language as never)) {
274274- try {
275275- return shiki.codeToHtml(text, {
276276- lang: language,
277277- theme: 'github-dark',
278278- })
279279- } catch {
280280- // Fall back to plain code block
281281- }
282282- }
283283-284284- // Plain code block for unknown languages
285285- const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
286286- return `<pre><code class="language-${language}">${escaped}</code></pre>\n`
270270+ return highlightCodeSync(shiki, text, lang || 'text')
287271 }
288272289273 // Resolve image URLs (with GitHub blob → raw conversion)
+47-3
server/utils/shiki.ts
···4949 return highlighter
5050}
51515252-export async function highlightCodeBlock(code: string, language: string): Promise<string> {
5353- const shiki = await getShikiHighlighter()
5252+/**
5353+ * Synchronously highlight a code block using an already-initialized highlighter.
5454+ * Use this when you have already awaited getShikiHighlighter() and need to
5555+ * highlight multiple blocks without async overhead (e.g., in marked renderers).
5656+ *
5757+ * @param shiki - The initialized Shiki highlighter instance
5858+ * @param code - The code to highlight
5959+ * @param language - The language identifier (e.g., 'typescript', 'bash')
6060+ * @returns HTML string with syntax highlighting
6161+ */
6262+export function highlightCodeSync(shiki: HighlighterCore, code: string, language: string): string {
5463 const loadedLangs = shiki.getLoadedLanguages()
55645665 if (loadedLangs.includes(language as never)) {
5766 try {
5858- return shiki.codeToHtml(code, {
6767+ let html = shiki.codeToHtml(code, {
5968 lang: language,
6069 theme: 'github-dark',
6170 })
7171+ // Remove inline style from <pre> tag so CSS can control appearance
7272+ html = html.replace(/<pre([^>]*)\s+style="[^"]*"/, '<pre$1')
7373+ // Shiki doesn't encode > in text content (e.g., arrow functions =>)
7474+ // We need to encode them for HTML validation
7575+ return escapeRawGt(html)
6276 } catch {
6377 // Fall back to plain
6478 }
···6882 const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
6983 return `<pre><code class="language-${language}">${escaped}</code></pre>\n`
7084}
8585+8686+/**
8787+ * Highlight a code block with syntax highlighting (async convenience wrapper).
8888+ * Initializes the highlighter if needed, then delegates to highlightCodeSync.
8989+ *
9090+ * @param code - The code to highlight
9191+ * @param language - The language identifier (e.g., 'typescript', 'bash')
9292+ * @returns HTML string with syntax highlighting
9393+ */
9494+export async function highlightCodeBlock(code: string, language: string): Promise<string> {
9595+ const shiki = await getShikiHighlighter()
9696+ return highlightCodeSync(shiki, code, language)
9797+}
9898+9999+/**
100100+ * Escape raw > characters in HTML text content.
101101+ * Shiki outputs > without encoding in constructs like arrow functions (=>).
102102+ * This replaces > that appear in text content (after >) but not inside tags.
103103+ *
104104+ * @internal Exported for testing
105105+ */
106106+export function escapeRawGt(html: string): string {
107107+ // Match > that appears after a closing tag or other > (i.e., in text content)
108108+ // Pattern: after </...> or after >, match any > that isn't starting a tag
109109+ return html.replace(/>([^<]*)/g, (match, textContent) => {
110110+ // Encode any > in the text content portion
111111+ const escapedText = textContent.replace(/>/g, '>')
112112+ return `>${escapedText}`
113113+ })
114114+}