forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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, '&').replace(/</g, '<').replace(/>/g, '>')
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}