[READ-ONLY] a fast, modern browser for the npm registry
at main 334 lines 9.8 kB view raw
1import { isBuiltin } from 'node:module' 2// File extension to language mapping 3const EXTENSION_MAP: Record<string, string> = { 4 // JavaScript/TypeScript 5 js: 'javascript', 6 mjs: 'javascript', 7 cjs: 'javascript', 8 ts: 'typescript', 9 mts: 'typescript', 10 cts: 'typescript', 11 jsx: 'jsx', 12 tsx: 'tsx', 13 14 // Web 15 html: 'html', 16 htm: 'html', 17 css: 'css', 18 scss: 'scss', 19 sass: 'scss', 20 less: 'less', 21 vue: 'vue', 22 svelte: 'svelte', 23 astro: 'astro', 24 gjs: 'glimmer-js', 25 gts: 'glimmer-ts', 26 27 // Data formats 28 json: 'json', 29 jsonc: 'jsonc', 30 json5: 'jsonc', 31 yaml: 'yaml', 32 yml: 'yaml', 33 toml: 'toml', 34 xml: 'xml', 35 svg: 'xml', 36 37 // Shell 38 sh: 'bash', 39 bash: 'bash', 40 zsh: 'bash', 41 fish: 'bash', 42 43 // Docs 44 md: 'markdown', 45 mdx: 'markdown', 46 markdown: 'markdown', 47 48 // Other languages 49 py: 'python', 50 rs: 'rust', 51 go: 'go', 52 sql: 'sql', 53 graphql: 'graphql', 54 gql: 'graphql', 55 diff: 'diff', 56 patch: 'diff', 57} 58 59// Special filenames that have specific languages 60const FILENAME_MAP: Record<string, string> = { 61 '.gitignore': 'bash', 62 '.npmignore': 'bash', 63 '.editorconfig': 'toml', 64 '.prettierrc': 'json', 65 '.eslintrc': 'json', 66 'tsconfig.json': 'jsonc', 67 'jsconfig.json': 'jsonc', 68 'package.json': 'json', 69 'package-lock.json': 'json', 70 'pnpm-lock.yaml': 'yaml', 71 'yarn.lock': 'yaml', 72 'Makefile': 'bash', 73 'Dockerfile': 'bash', 74 'LICENSE': 'text', 75 'CHANGELOG': 'markdown', 76 'CHANGELOG.md': 'markdown', 77 'README': 'markdown', 78 'README.md': 'markdown', 79 'README.markdown': 'markdown', 80} 81 82/** 83 * Determine the language for syntax highlighting based on file path 84 */ 85export function getLanguageFromPath(filePath: string): string { 86 const filename = filePath.split('/').pop() || '' 87 88 // Check for exact filename match first 89 if (FILENAME_MAP[filename]) { 90 return FILENAME_MAP[filename] 91 } 92 93 // Then check extension 94 const ext = filename.split('.').pop()?.toLowerCase() || '' 95 return EXTENSION_MAP[ext] || 'text' 96} 97 98/** 99 * Check if a module specifier is an npm package (not a relative/absolute path or Node built-in) 100 */ 101function isNpmPackage(specifier: string): boolean { 102 // Remove quotes 103 const pkg = specifier.replace(/^['"]|['"]$/g, '').trim() 104 // Relative or absolute paths 105 if (pkg.startsWith('.') || pkg.startsWith('/')) return false 106 // Node built-ins with node: prefix 107 if (pkg.startsWith('node:')) return false 108 // Node built-ins without prefix 109 if (isBuiltin(pkg)) return false 110 // Empty 111 if (!pkg) return false 112 return true 113} 114 115/** 116 * Extract the package name from a module specifier (handles scoped packages and subpaths) 117 */ 118function getPackageName(specifier: string): string { 119 const pkg = specifier.replace(/^['"]|['"]$/g, '').trim() 120 // Scoped package: @scope/name or @scope/name/subpath 121 if (pkg.startsWith('@')) { 122 const parts = pkg.split('/') 123 if (parts[0] && parts[1]) { 124 return `${parts[0]}/${parts[1]}` 125 } 126 } 127 // Regular package: name or name/subpath 128 const firstSlash = pkg.indexOf('/') 129 if (firstSlash > 0) { 130 return pkg.substring(0, firstSlash) 131 } 132 return pkg 133} 134 135/** 136 * Resolved dependency info for linking imports to specific versions 137 */ 138export interface ResolvedDependency { 139 version: string 140} 141 142/** 143 * Map of package name to resolved version for import linking 144 */ 145export type DependencyVersions = Record<string, ResolvedDependency> 146 147/** 148 * Function to resolve relative imports to URLs 149 */ 150export type RelativeImportResolver = (specifier: string) => string | null 151 152interface LinkifyOptions { 153 dependencies?: DependencyVersions 154 resolveRelative?: RelativeImportResolver 155} 156 157/** 158 * Make import/export module specifiers clickable links to package code browser. 159 * Handles: 160 * - import ... from 'package' 161 * - export ... from 'package' 162 * - import 'package' (side-effect imports) 163 * - require('package') 164 * - import('package') - dynamic imports 165 * - Relative imports (./foo, ../bar) when resolver is provided 166 * 167 * @param html - The HTML to process 168 * @param options - Dependencies map and optional relative import resolver 169 */ 170function linkifyImports(html: string, options?: LinkifyOptions): string { 171 const { dependencies, resolveRelative } = options ?? {} 172 173 const getHref = (moduleSpecifier: string): string | null => { 174 const cleanSpec = moduleSpecifier.replace(/^['"]|['"]$/g, '').trim() 175 176 // Try relative import resolution first 177 if (cleanSpec.startsWith('.') && resolveRelative) { 178 return resolveRelative(moduleSpecifier) 179 } 180 181 // Not a relative import - check if it's an npm package 182 if (!isNpmPackage(moduleSpecifier)) { 183 return null 184 } 185 186 const packageName = getPackageName(moduleSpecifier) 187 const dep = dependencies?.[packageName] 188 if (dep) { 189 // Link to code browser with resolved version 190 return `/package-code/${packageName}/v/${dep.version}` 191 } 192 // Fall back to package page if not a known dependency 193 return `/package/${packageName}` 194 } 195 196 // Match: from keyword span followed by string span containing module specifier 197 // Pattern: <span style="...">from</span><span style="..."> 'module'</span> 198 let result = html.replace( 199 /(<span[^>]*>from<\/span>)(<span[^>]*>) (['"][^'"]+['"])<\/span>/g, 200 (match, fromSpan, stringSpanOpen, moduleSpecifier) => { 201 const href = getHref(moduleSpecifier) 202 if (!href) return match 203 return `${fromSpan}${stringSpanOpen} <a href="${href}" class="import-link">${moduleSpecifier}</a></span>` 204 }, 205 ) 206 207 // Match: side-effect imports like `import 'package'` 208 // Pattern: <span>import</span><span> 'module'</span> 209 // But NOT: import ... from, import(, or import { 210 result = result.replace( 211 /(<span[^>]*>import<\/span>)(<span[^>]*>) (['"][^'"]+['"])<\/span>/g, 212 (match, importSpan, stringSpanOpen, moduleSpecifier) => { 213 const href = getHref(moduleSpecifier) 214 if (!href) return match 215 return `${importSpan}${stringSpanOpen} <a href="${href}" class="import-link">${moduleSpecifier}</a></span>` 216 }, 217 ) 218 219 // Match: require( or import( followed by string 220 // Pattern: <span> require</span><span>(</span><span>'module'</span> 221 // or: <span>import</span><span>(</span><span>'module'</span> 222 // Note: require often has a leading space in the span from Shiki 223 result = result.replace( 224 /(<span[^>]*>)(\s*)(require|import)(<\/span>)(<span[^>]*>\(<\/span>)(<span[^>]*>)(['"][^'"]+['"])<\/span>/g, 225 ( 226 match, 227 spanOpen, 228 whitespace, 229 keyword, 230 spanClose, 231 parenSpan, 232 stringSpanOpen, 233 moduleSpecifier, 234 ) => { 235 const href = getHref(moduleSpecifier) 236 if (!href) return match 237 return `${spanOpen}${whitespace}${keyword}${spanClose}${parenSpan}${stringSpanOpen}<a href="${href}" class="import-link">${moduleSpecifier}</a></span>` 238 }, 239 ) 240 241 return result 242} 243 244// Languages that support import/export statements 245const IMPORT_LANGUAGES = new Set([ 246 'javascript', 247 'typescript', 248 'jsx', 249 'tsx', 250 'vue', 251 'svelte', 252 'astro', 253]) 254 255export interface HighlightOptions { 256 /** Map of dependency names to resolved versions for import linking */ 257 dependencies?: DependencyVersions 258 /** Resolver function for relative imports (./foo, ../bar) */ 259 resolveRelative?: RelativeImportResolver 260} 261 262/** 263 * Highlight code using Shiki with line-by-line output for line highlighting. 264 * Each line is wrapped in a span.line for individual line highlighting. 265 */ 266export async function highlightCode( 267 code: string, 268 language: string, 269 options?: HighlightOptions, 270): Promise<string> { 271 const shiki = await getShikiHighlighter() 272 const loadedLangs = shiki.getLoadedLanguages() 273 274 // Use Shiki if language is loaded 275 if (loadedLangs.includes(language as never)) { 276 try { 277 let html = shiki.codeToHtml(code, { 278 lang: language, 279 themes: { light: 'github-light', dark: 'github-dark' }, 280 defaultColor: 'dark', 281 }) 282 283 // Shiki doesn't encode > in text content (e.g., arrow functions) 284 html = escapeRawGt(html) 285 286 // Make import statements clickable for JS/TS languages 287 if (IMPORT_LANGUAGES.has(language)) { 288 html = linkifyImports(html, { 289 dependencies: options?.dependencies, 290 resolveRelative: options?.resolveRelative, 291 }) 292 } 293 294 // Check if Shiki already outputs .line spans (newer versions do) 295 if (html.includes('<span class="line">')) { 296 // Shiki already wraps lines, but they're separated by newlines 297 // We need to remove the newlines since display:block handles line breaks 298 // Replace newlines between </span> and <span class="line"> with nothing 299 return html.replace(/<\/span>\n<span class="line">/g, '</span><span class="line">') 300 } 301 302 // Older Shiki without .line spans - wrap manually 303 const codeMatch = html.match(/<code[^>]*>([\s\S]*)<\/code>/) 304 if (codeMatch?.[1]) { 305 const codeContent = codeMatch[1] 306 const lines = codeContent.split('\n') 307 const wrappedLines = lines 308 .map((line: string, i: number) => { 309 if (i === lines.length - 1 && line === '') return null 310 return `<span class="line">${line}</span>` 311 }) 312 .filter((line: string | null): line is string => line !== null) 313 .join('') 314 315 return html.replace(codeMatch[1], wrappedLines) 316 } 317 318 return html 319 } catch { 320 // Fall back to plain 321 } 322 } 323 324 // Plain code for unknown languages - also wrap lines 325 const lines = code.split('\n') 326 const wrappedLines = lines 327 .map(line => { 328 const escaped = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') 329 return `<span class="line">${escaped}</span>` 330 }) 331 .join('') // No newlines - display:block handles it 332 333 return `<pre class="shiki github-dark"><code>${wrappedLines}</code></pre>` 334}