[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add code browsing/display

+1858 -45
+119
app/components/CodeDirectoryListing.vue
··· 1 + <script setup lang="ts"> 2 + import type { PackageFileTree } from '#shared/types' 3 + import { getFileIcon } from '~/utils/file-icons' 4 + 5 + const props = defineProps<{ 6 + tree: PackageFileTree[] 7 + currentPath: string 8 + baseUrl: string 9 + }>() 10 + 11 + // Get the current directory's contents 12 + const currentContents = computed(() => { 13 + if (!props.currentPath) { 14 + return props.tree 15 + } 16 + 17 + const parts = props.currentPath.split('/') 18 + let current: PackageFileTree[] | undefined = props.tree 19 + 20 + for (const part of parts) { 21 + const found: PackageFileTree | undefined = current?.find(n => n.name === part) 22 + if (!found || found.type === 'file') { 23 + return [] 24 + } 25 + current = found.children 26 + } 27 + 28 + return current ?? [] 29 + }) 30 + 31 + // Get parent directory path 32 + const parentPath = computed(() => { 33 + if (!props.currentPath) return null 34 + const parts = props.currentPath.split('/') 35 + if (parts.length <= 1) return '' 36 + return parts.slice(0, -1).join('/') 37 + }) 38 + 39 + // Format file size 40 + function formatBytes(bytes: number): string { 41 + if (bytes < 1024) return `${bytes} B` 42 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB` 43 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 44 + } 45 + </script> 46 + 47 + <template> 48 + <div class="directory-listing"> 49 + <!-- Empty state --> 50 + <div 51 + v-if="currentContents.length === 0" 52 + class="py-20 text-center text-fg-muted" 53 + > 54 + <p>No files in this directory</p> 55 + </div> 56 + 57 + <!-- File list --> 58 + <table 59 + v-else 60 + class="w-full" 61 + > 62 + <thead class="sr-only"> 63 + <tr> 64 + <th>Name</th> 65 + <th>Size</th> 66 + </tr> 67 + </thead> 68 + <tbody> 69 + <!-- Parent directory link --> 70 + <tr 71 + v-if="parentPath !== null" 72 + class="border-b border-border hover:bg-bg-subtle transition-colors" 73 + > 74 + <td class="py-2 px-4"> 75 + <NuxtLink 76 + :to="parentPath ? `${baseUrl}/${parentPath}` : baseUrl" 77 + class="flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors" 78 + > 79 + <span class="i-carbon-folder w-4 h-4 text-yellow-600" /> 80 + <span>..</span> 81 + </NuxtLink> 82 + </td> 83 + <td /> 84 + </tr> 85 + 86 + <!-- Directory/file rows --> 87 + <tr 88 + v-for="node in currentContents" 89 + :key="node.path" 90 + class="border-b border-border hover:bg-bg-subtle transition-colors" 91 + > 92 + <td class="py-2 px-4"> 93 + <NuxtLink 94 + :to="`${baseUrl}/${node.path}`" 95 + class="flex items-center gap-2 font-mono text-sm hover:text-fg transition-colors" 96 + :class="node.type === 'directory' ? 'text-fg' : 'text-fg-muted'" 97 + > 98 + <span 99 + v-if="node.type === 'directory'" 100 + class="i-carbon-folder w-4 h-4 text-yellow-600" 101 + /> 102 + <span 103 + v-else 104 + class="w-4 h-4" 105 + :class="getFileIcon(node.name)" 106 + /> 107 + <span>{{ node.name }}</span> 108 + </NuxtLink> 109 + </td> 110 + <td class="py-2 px-4 text-right font-mono text-xs text-fg-subtle"> 111 + <span v-if="node.type === 'file' && node.size"> 112 + {{ formatBytes(node.size) }} 113 + </span> 114 + </td> 115 + </tr> 116 + </tbody> 117 + </table> 118 + </div> 119 + </template>
+102
app/components/CodeFileTree.vue
··· 1 + <script setup lang="ts"> 2 + import type { PackageFileTree } from '#shared/types' 3 + import { getFileIcon } from '~/utils/file-icons' 4 + 5 + const props = defineProps<{ 6 + tree: PackageFileTree[] 7 + currentPath: string 8 + baseUrl: string 9 + depth?: number 10 + }>() 11 + 12 + const depth = computed(() => props.depth ?? 0) 13 + 14 + // Check if a node or any of its children is currently selected 15 + function isNodeActive(node: PackageFileTree): boolean { 16 + if (props.currentPath === node.path) return true 17 + if (props.currentPath.startsWith(node.path + '/')) return true 18 + return false 19 + } 20 + 21 + // State for expanded directories 22 + const expandedDirs = ref<Set<string>>(new Set()) 23 + 24 + // Auto-expand directories in the current path 25 + watch(() => props.currentPath, (path) => { 26 + if (!path) return 27 + const parts = path.split('/') 28 + for (let i = 1; i <= parts.length; i++) { 29 + expandedDirs.value.add(parts.slice(0, i).join('/')) 30 + } 31 + }, { immediate: true }) 32 + 33 + function toggleDir(path: string) { 34 + if (expandedDirs.value.has(path)) { 35 + expandedDirs.value.delete(path) 36 + } 37 + else { 38 + expandedDirs.value.add(path) 39 + } 40 + } 41 + 42 + function isExpanded(path: string): boolean { 43 + return expandedDirs.value.has(path) 44 + } 45 + </script> 46 + 47 + <template> 48 + <ul 49 + class="list-none m-0 p-0" 50 + :class="depth === 0 ? 'py-2' : ''" 51 + > 52 + <li 53 + v-for="node in tree" 54 + :key="node.path" 55 + > 56 + <!-- Directory --> 57 + <template v-if="node.type === 'directory'"> 58 + <button 59 + class="w-full flex items-center gap-1.5 py-1.5 px-3 text-left font-mono text-sm transition-colors hover:bg-bg-muted" 60 + :class="isNodeActive(node) ? 'text-fg' : 'text-fg-muted'" 61 + :style="{ paddingLeft: `${depth * 12 + 12}px` }" 62 + @click="toggleDir(node.path)" 63 + > 64 + <span 65 + class="w-4 h-4 shrink-0 transition-transform" 66 + :class="[ 67 + isExpanded(node.path) ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right', 68 + ]" 69 + /> 70 + <span 71 + class="w-4 h-4 shrink-0" 72 + :class="isExpanded(node.path) ? 'i-carbon-folder-open text-yellow-500' : 'i-carbon-folder text-yellow-600'" 73 + /> 74 + <span class="truncate">{{ node.name }}</span> 75 + </button> 76 + <CodeFileTree 77 + v-if="isExpanded(node.path) && node.children" 78 + :tree="node.children" 79 + :current-path="currentPath" 80 + :base-url="baseUrl" 81 + :depth="depth + 1" 82 + /> 83 + </template> 84 + 85 + <!-- File --> 86 + <template v-else> 87 + <NuxtLink 88 + :to="`${baseUrl}/${node.path}`" 89 + class="flex items-center gap-1.5 py-1.5 px-3 font-mono text-sm transition-colors hover:bg-bg-muted" 90 + :class="currentPath === node.path ? 'bg-bg-muted text-fg' : 'text-fg-muted'" 91 + :style="{ paddingLeft: `${depth * 12 + 32}px` }" 92 + > 93 + <span 94 + class="w-4 h-4 shrink-0" 95 + :class="getFileIcon(node.name)" 96 + /> 97 + <span class="truncate">{{ node.name }}</span> 98 + </NuxtLink> 99 + </template> 100 + </li> 101 + </ul> 102 + </template>
+93
app/components/CodeMobileTreeDrawer.vue
··· 1 + <script setup lang="ts"> 2 + import type { PackageFileTree } from '#shared/types' 3 + 4 + defineProps<{ 5 + tree: PackageFileTree[] 6 + currentPath: string 7 + baseUrl: string 8 + }>() 9 + 10 + const isOpen = ref(false) 11 + 12 + // Close drawer on navigation 13 + const route = useRoute() 14 + watch(() => route.fullPath, () => { 15 + isOpen.value = false 16 + }) 17 + 18 + // Prevent body scroll when drawer is open 19 + watch(isOpen, (open) => { 20 + if (open) { 21 + document.body.style.overflow = 'hidden' 22 + } 23 + else { 24 + document.body.style.overflow = '' 25 + } 26 + }) 27 + 28 + // Cleanup on unmount 29 + onUnmounted(() => { 30 + document.body.style.overflow = '' 31 + }) 32 + </script> 33 + 34 + <template> 35 + <!-- Toggle button (mobile only) --> 36 + <button 37 + class="md:hidden fixed bottom-4 right-4 z-40 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg flex items-center justify-center text-fg-muted hover:text-fg transition-colors" 38 + aria-label="Toggle file tree" 39 + @click="isOpen = !isOpen" 40 + > 41 + <span 42 + class="w-5 h-5" 43 + :class="isOpen ? 'i-carbon-close' : 'i-carbon-folder'" 44 + /> 45 + </button> 46 + 47 + <!-- Backdrop --> 48 + <Transition 49 + enter-active-class="transition-opacity duration-200" 50 + enter-from-class="opacity-0" 51 + enter-to-class="opacity-100" 52 + leave-active-class="transition-opacity duration-200" 53 + leave-from-class="opacity-100" 54 + leave-to-class="opacity-0" 55 + > 56 + <div 57 + v-if="isOpen" 58 + class="md:hidden fixed inset-0 z-40 bg-black/50" 59 + @click="isOpen = false" 60 + /> 61 + </Transition> 62 + 63 + <!-- Drawer --> 64 + <Transition 65 + enter-active-class="transition-transform duration-200" 66 + enter-from-class="-translate-x-full" 67 + enter-to-class="translate-x-0" 68 + leave-active-class="transition-transform duration-200" 69 + leave-from-class="translate-x-0" 70 + leave-to-class="-translate-x-full" 71 + > 72 + <aside 73 + v-if="isOpen" 74 + class="md:hidden fixed inset-y-0 left-0 z-50 w-72 bg-bg-subtle border-r border-border overflow-y-auto" 75 + > 76 + <div class="sticky top-0 bg-bg-subtle border-b border-border px-4 py-3 flex items-center justify-between"> 77 + <span class="font-mono text-sm text-fg-muted">Files</span> 78 + <button 79 + class="text-fg-muted hover:text-fg transition-colors" 80 + aria-label="Close file tree" 81 + @click="isOpen = false" 82 + > 83 + <span class="i-carbon-close w-5 h-5" /> 84 + </button> 85 + </div> 86 + <CodeFileTree 87 + :tree="tree" 88 + :current-path="currentPath" 89 + :base-url="baseUrl" 90 + /> 91 + </aside> 92 + </Transition> 93 + </template>
+129
app/components/CodeViewer.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + html: string 4 + lines: number 5 + selectedLines: { start: number, end: number } | null 6 + }>() 7 + 8 + const emit = defineEmits<{ 9 + lineClick: [lineNum: number, event: MouseEvent] 10 + }>() 11 + 12 + const codeRef = ref<HTMLElement>() 13 + 14 + // Generate line numbers array 15 + const lineNumbers = computed(() => { 16 + return Array.from({ length: props.lines }, (_, i) => i + 1) 17 + }) 18 + 19 + // Check if a line is selected 20 + function isLineSelected(lineNum: number): boolean { 21 + if (!props.selectedLines) return false 22 + return lineNum >= props.selectedLines.start && lineNum <= props.selectedLines.end 23 + } 24 + 25 + // Handle line number click 26 + function onLineClick(lineNum: number, event: MouseEvent) { 27 + emit('lineClick', lineNum, event) 28 + } 29 + 30 + // Apply highlighting to code lines when selection changes 31 + function updateLineHighlighting() { 32 + if (!codeRef.value) return 33 + 34 + // Lines are inside pre > code > .line 35 + const lines = codeRef.value.querySelectorAll('code > .line') 36 + lines.forEach((line, index) => { 37 + const lineNum = index + 1 38 + if (isLineSelected(lineNum)) { 39 + line.classList.add('highlighted') 40 + } 41 + else { 42 + line.classList.remove('highlighted') 43 + } 44 + }) 45 + } 46 + 47 + // Watch for changes to selection and HTML content 48 + // Use deep watch and nextTick to ensure DOM is updated 49 + watch( 50 + () => [props.selectedLines, props.html] as const, 51 + () => { 52 + nextTick(updateLineHighlighting) 53 + }, 54 + { immediate: true }, 55 + ) 56 + </script> 57 + 58 + <template> 59 + <div class="code-viewer flex min-h-full"> 60 + <!-- Line numbers column --> 61 + <div 62 + class="line-numbers shrink-0 bg-bg-subtle border-r border-border text-right select-none" 63 + aria-hidden="true" 64 + > 65 + <a 66 + v-for="lineNum in lineNumbers" 67 + :id="`L${lineNum}`" 68 + :key="lineNum" 69 + :href="`#L${lineNum}`" 70 + class="line-number block px-3 py-0 font-mono text-sm leading-6 cursor-pointer transition-colors no-underline" 71 + :class="[ 72 + isLineSelected(lineNum) 73 + ? 'bg-yellow-500/20 text-fg' 74 + : 'text-fg-subtle hover:text-fg-muted', 75 + ]" 76 + @click.prevent="onLineClick(lineNum, $event)" 77 + > 78 + {{ lineNum }} 79 + </a> 80 + </div> 81 + 82 + <!-- Code content --> 83 + <div class="code-content flex-1 overflow-x-auto min-w-0"> 84 + <!-- eslint-disable vue/no-v-html -- HTML is generated server-side by Shiki --> 85 + <div 86 + ref="codeRef" 87 + class="code-lines" 88 + v-html="html" 89 + /> 90 + <!-- eslint-enable vue/no-v-html --> 91 + </div> 92 + </div> 93 + </template> 94 + 95 + <style scoped> 96 + .code-viewer { 97 + font-size: 14px; 98 + } 99 + 100 + .line-numbers { 101 + min-width: 3.5rem; 102 + } 103 + 104 + .code-content :deep(pre) { 105 + margin: 0; 106 + padding: 0; 107 + background: transparent !important; 108 + overflow: visible; 109 + } 110 + 111 + .code-content :deep(code) { 112 + display: block; 113 + background: transparent !important; 114 + } 115 + 116 + .code-content :deep(.line) { 117 + display: block; 118 + padding: 0 1rem; 119 + /* Ensure consistent line height matching line numbers */ 120 + line-height: 1.5rem; 121 + min-height: 1.5rem; 122 + transition: background-color 0.1s; 123 + } 124 + 125 + /* Highlighted lines in code content */ 126 + .code-content :deep(.line.highlighted) { 127 + background: rgb(234 179 8 / 0.2); /* yellow-500/20 */ 128 + } 129 + </style>
+10 -6
app/pages/package/[...name].vue
··· 437 437 size 438 438 </a> 439 439 </li> 440 + <li v-if="displayVersion"> 441 + <NuxtLink 442 + :to="`/package/code/${pkg.name}/v/${displayVersion.version}`" 443 + class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 444 + > 445 + <span class="i-carbon-code w-4 h-4" /> 446 + code 447 + </NuxtLink> 448 + </li> 440 449 </ul> 441 450 </nav> 442 451 </header> ··· 509 518 class="absolute top-3 right-3 px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-all duration-200 hover:(text-fg border-border-hover) active:scale-95" 510 519 @click="copyInstallCommand" 511 520 > 512 - <ClientOnly> 513 - {{ copied ? 'copied!' : 'copy' }} 514 - <template #fallback> 515 - copy 516 - </template> 517 - </ClientOnly> 521 + {{ copied ? 'copied!' : 'copy' }} 518 522 </button> 519 523 </div> 520 524 </section>
+554
app/pages/package/code/[...path].vue
··· 1 + <script setup lang="ts"> 2 + import type { PackageFileTree, PackageFileTreeResponse, PackageFileContentResponse } from '#shared/types' 3 + 4 + const route = useRoute('package-code-path') 5 + const router = useRouter() 6 + 7 + // Parse package name, version, and file path from URL 8 + // Patterns: 9 + // /package/code/nuxt/v/4.2.0 → packageName: "nuxt", version: "4.2.0", filePath: null (show tree) 10 + // /package/code/nuxt/v/4.2.0/src/index.ts → packageName: "nuxt", version: "4.2.0", filePath: "src/index.ts" 11 + // /package/code/@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", version: "1.0.0", filePath: null 12 + const parsedRoute = computed(() => { 13 + const segments = Array.isArray(route.params.path) 14 + ? route.params.path 15 + : [route.params.path ?? ''] 16 + 17 + // Find the /v/ separator for version 18 + const vIndex = segments.indexOf('v') 19 + if (vIndex === -1 || vIndex >= segments.length - 1) { 20 + // No version specified - redirect or error 21 + return { 22 + packageName: segments.join('/'), 23 + version: null as string | null, 24 + filePath: null as string | null, 25 + } 26 + } 27 + 28 + const packageName = segments.slice(0, vIndex).join('/') 29 + const afterVersion = segments.slice(vIndex + 1) 30 + const version = afterVersion[0] ?? null 31 + const filePath = afterVersion.length > 1 ? afterVersion.slice(1).join('/') : null 32 + 33 + return { packageName, version, filePath } 34 + }) 35 + 36 + const packageName = computed(() => parsedRoute.value.packageName) 37 + const version = computed(() => parsedRoute.value.version) 38 + const filePath = computed(() => parsedRoute.value.filePath) 39 + 40 + // Fetch package data for version list 41 + const { data: pkg } = usePackage(packageName) 42 + 43 + // Get available versions sorted by dist-tags first, then by semver 44 + const availableVersions = computed(() => { 45 + if (!pkg.value) return [] 46 + 47 + const distTags = pkg.value['dist-tags'] ?? {} 48 + const allVersions = Object.keys(pkg.value.versions) 49 + 50 + // Get dist-tag versions first (latest, next, beta, etc.) 51 + const taggedVersions = new Set(Object.values(distTags)) 52 + const taggedList = Object.entries(distTags) 53 + .map(([tag, ver]) => ({ version: ver, tag })) 54 + 55 + // Get other versions (not in dist-tags), sorted by semver descending 56 + const otherVersions = allVersions 57 + .filter(v => !taggedVersions.has(v)) 58 + .sort((a, b) => { 59 + // Simple semver comparison (major.minor.patch) 60 + const partsA = a.split('.').map(p => parseInt(p, 10) || 0) 61 + const partsB = b.split('.').map(p => parseInt(p, 10) || 0) 62 + for (let i = 0; i < 3; i++) { 63 + const diff = (partsB[i] ?? 0) - (partsA[i] ?? 0) 64 + if (diff !== 0) return diff 65 + } 66 + return 0 67 + }) 68 + .slice(0, 20) // Limit to 20 most recent 69 + .map(v => ({ version: v, tag: undefined as string | undefined })) 70 + 71 + return [...taggedList, ...otherVersions] 72 + }) 73 + 74 + // Version switch handler 75 + function switchVersion(newVersion: string) { 76 + const newPath = filePath.value 77 + ? `/package/code/${packageName.value}/v/${newVersion}/${filePath.value}` 78 + : `/package/code/${packageName.value}/v/${newVersion}` 79 + router.push(newPath) 80 + } 81 + 82 + // Fetch file tree 83 + const { data: fileTree, status: treeStatus } = useFetch<PackageFileTreeResponse>( 84 + () => `/api/registry/files/${packageName.value}/v/${version.value}`, 85 + { 86 + watch: [packageName, version], 87 + immediate: !!version.value, 88 + }, 89 + ) 90 + 91 + // Determine what to show based on the current path 92 + // Note: This needs fileTree to be loaded first 93 + const currentNode = computed(() => { 94 + if (!fileTree.value?.tree || !filePath.value) return null 95 + 96 + const parts = filePath.value.split('/') 97 + let current: PackageFileTree[] | undefined = fileTree.value.tree 98 + 99 + for (const part of parts) { 100 + const found: PackageFileTree | undefined = current?.find(n => n.name === part) 101 + if (!found) return null 102 + if (found.type === 'file') return found 103 + current = found.children 104 + } 105 + 106 + return null 107 + }) 108 + 109 + const isViewingFile = computed(() => currentNode.value?.type === 'file') 110 + 111 + // Maximum file size we'll try to load (500KB) - must match server 112 + const MAX_FILE_SIZE = 500 * 1024 113 + const isFileTooLarge = computed(() => { 114 + const size = currentNode.value?.size 115 + return size !== undefined && size > MAX_FILE_SIZE 116 + }) 117 + 118 + // Fetch file content when a file is selected (and not too large) 119 + const fileContentUrl = computed(() => { 120 + // Don't fetch if no file path, file tree not loaded, or file is too large 121 + if (!filePath.value || !fileTree.value || isFileTooLarge.value) { 122 + return null 123 + } 124 + return `/api/registry/file/${packageName.value}/v/${version.value}/${filePath.value}` 125 + }) 126 + 127 + const { data: fileContent, status: fileStatus } = useFetch<PackageFileContentResponse>( 128 + () => fileContentUrl.value!, 129 + { 130 + watch: [fileContentUrl], 131 + immediate: !!fileContentUrl.value, 132 + }, 133 + ) 134 + 135 + // Track hash manually since we update it via history API to avoid scroll 136 + const currentHash = ref('') 137 + 138 + // Initialize from route and listen for popstate (back/forward) 139 + onMounted(() => { 140 + currentHash.value = window.location.hash 141 + window.addEventListener('popstate', () => { 142 + currentHash.value = window.location.hash 143 + }) 144 + }) 145 + 146 + // Also sync when route changes (e.g., navigating to a different file) 147 + watch(() => route.hash, (hash) => { 148 + currentHash.value = hash 149 + }) 150 + 151 + // Line number handling from hash 152 + const selectedLines = computed(() => { 153 + const hash = currentHash.value 154 + if (!hash) return null 155 + 156 + // Parse #L10 or #L10-L20 157 + const match = hash.match(/^#L(\d+)(?:-L(\d+))?$/) 158 + if (!match) return null 159 + 160 + const start = parseInt(match[1] ?? '0', 10) 161 + const end = match[2] ? parseInt(match[2], 10) : start 162 + 163 + return { start, end } 164 + }) 165 + 166 + // Scroll to selected line only on initial load or file change (not on click) 167 + const shouldScrollOnHashChange = ref(true) 168 + 169 + function scrollToLine() { 170 + if (!shouldScrollOnHashChange.value) return 171 + if (!selectedLines.value) return 172 + const lineEl = document.getElementById(`L${selectedLines.value.start}`) 173 + if (lineEl) { 174 + lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' }) 175 + } 176 + } 177 + 178 + // Scroll on file content load (initial or file change) 179 + watch(fileContent, () => { 180 + shouldScrollOnHashChange.value = true 181 + nextTick(scrollToLine) 182 + }) 183 + 184 + // Build breadcrumb path segments 185 + const breadcrumbs = computed(() => { 186 + const parts = filePath.value?.split('/').filter(Boolean) ?? [] 187 + const result: { name: string, path: string }[] = [] 188 + 189 + for (let i = 0; i < parts.length; i++) { 190 + const part = parts[i] 191 + if (part) { 192 + result.push({ 193 + name: part, 194 + path: parts.slice(0, i + 1).join('/'), 195 + }) 196 + } 197 + } 198 + 199 + return result 200 + }) 201 + 202 + // Navigation helper - build URL for a path 203 + function getCodeUrl(path?: string): string { 204 + const base = `/package/code/${packageName.value}/v/${version.value}` 205 + return path ? `${base}/${path}` : base 206 + } 207 + 208 + // Extract org name from scoped package 209 + const orgName = computed(() => { 210 + const name = packageName.value 211 + if (!name.startsWith('@')) return null 212 + const match = name.match(/^@([^/]+)\//) 213 + return match ? match[1] : null 214 + }) 215 + 216 + // Format file size 217 + function formatBytes(bytes: number): string { 218 + if (bytes < 1024) return `${bytes} B` 219 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB` 220 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 221 + } 222 + 223 + // Line number click handler - update URL hash without scrolling 224 + function handleLineClick(lineNum: number, event: MouseEvent) { 225 + let newHash: string 226 + if (event.shiftKey && selectedLines.value) { 227 + // Shift+click: select range 228 + const start = Math.min(selectedLines.value.start, lineNum) 229 + const end = Math.max(selectedLines.value.end, lineNum) 230 + newHash = `#L${start}-L${end}` 231 + } 232 + else { 233 + // Single click: select line 234 + newHash = `#L${lineNum}` 235 + } 236 + 237 + // Don't scroll when user clicks - only scroll on initial load 238 + shouldScrollOnHashChange.value = false 239 + 240 + // Update URL without triggering scroll - use history API directly 241 + const url = new URL(window.location.href) 242 + url.hash = newHash 243 + window.history.replaceState(history.state, '', url.toString()) 244 + 245 + // Update our reactive hash tracker 246 + currentHash.value = newHash 247 + } 248 + 249 + // Copy link to current line(s) 250 + async function copyPermalink() { 251 + const url = new URL(window.location.href) 252 + await navigator.clipboard.writeText(url.toString()) 253 + } 254 + 255 + useSeoMeta({ 256 + title: () => { 257 + if (filePath.value) { 258 + return `${filePath.value} - ${packageName.value}@${version.value} - npmx` 259 + } 260 + return `Code - ${packageName.value}@${version.value} - npmx` 261 + }, 262 + description: () => `Browse source code for ${packageName.value}@${version.value}`, 263 + }) 264 + </script> 265 + 266 + <template> 267 + <main class="min-h-screen flex flex-col"> 268 + <!-- Header --> 269 + <header class="border-b border-border bg-bg sticky top-0 z-10"> 270 + <div class="container py-4"> 271 + <!-- Package info and navigation --> 272 + <div class="flex items-center gap-2 mb-3 flex-wrap"> 273 + <NuxtLink 274 + :to="`/package/${packageName}${version ? `/v/${version}` : ''}`" 275 + class="font-mono text-lg font-medium hover:text-fg transition-colors" 276 + > 277 + <span 278 + v-if="orgName" 279 + class="text-fg-muted" 280 + >@{{ orgName }}/</span>{{ orgName ? packageName.replace(`@${orgName}/`, '') : packageName }} 281 + </NuxtLink> 282 + <!-- Version selector --> 283 + <div 284 + v-if="version && availableVersions.length > 0" 285 + class="relative" 286 + > 287 + <select 288 + :value="version" 289 + class="appearance-none pl-2 pr-6 py-0.5 font-mono text-sm bg-bg-muted border border-border rounded cursor-pointer hover:border-border-hover transition-colors" 290 + @change="switchVersion(($event.target as HTMLSelectElement).value)" 291 + > 292 + <option 293 + v-for="v in availableVersions" 294 + :key="v.version" 295 + :value="v.version" 296 + > 297 + v{{ v.version }}{{ v.tag ? ` (${v.tag})` : '' }} 298 + </option> 299 + </select> 300 + <span class="i-carbon-chevron-down w-3 h-3 absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none text-fg-muted" /> 301 + </div> 302 + <span 303 + v-else-if="version" 304 + class="px-2 py-0.5 font-mono text-sm bg-bg-muted border border-border rounded" 305 + > 306 + v{{ version }} 307 + </span> 308 + <span class="text-fg-subtle">/</span> 309 + <span class="font-mono text-sm text-fg-muted">code</span> 310 + </div> 311 + 312 + <!-- Breadcrumb navigation --> 313 + <nav 314 + v-if="filePath" 315 + aria-label="File path" 316 + class="flex items-center gap-1 font-mono text-sm overflow-x-auto" 317 + > 318 + <NuxtLink 319 + :to="getCodeUrl()" 320 + class="text-fg-muted hover:text-fg transition-colors shrink-0" 321 + > 322 + root 323 + </NuxtLink> 324 + <template 325 + v-for="(crumb, i) in breadcrumbs" 326 + :key="crumb.path" 327 + > 328 + <span class="text-fg-subtle">/</span> 329 + <NuxtLink 330 + v-if="i < breadcrumbs.length - 1" 331 + :to="getCodeUrl(crumb.path)" 332 + class="text-fg-muted hover:text-fg transition-colors" 333 + > 334 + {{ crumb.name }} 335 + </NuxtLink> 336 + <span 337 + v-else 338 + class="text-fg" 339 + >{{ crumb.name }}</span> 340 + </template> 341 + </nav> 342 + </div> 343 + </header> 344 + 345 + <!-- Error: no version --> 346 + <div 347 + v-if="!version" 348 + class="container py-20 text-center" 349 + > 350 + <p class="text-fg-muted mb-4"> 351 + Version is required to browse code 352 + </p> 353 + <NuxtLink 354 + :to="`/package/${packageName}`" 355 + class="btn" 356 + > 357 + Go to package 358 + </NuxtLink> 359 + </div> 360 + 361 + <!-- Loading state --> 362 + <div 363 + v-else-if="treeStatus === 'pending'" 364 + class="container py-20 text-center" 365 + > 366 + <div class="i-svg-spinners-ring-resize w-8 h-8 mx-auto text-fg-muted" /> 367 + <p class="mt-4 text-fg-muted"> 368 + Loading file tree... 369 + </p> 370 + </div> 371 + 372 + <!-- Error state --> 373 + <div 374 + v-else-if="treeStatus === 'error'" 375 + class="container py-20 text-center" 376 + role="alert" 377 + > 378 + <p class="text-fg-muted mb-4"> 379 + Failed to load files for this package version 380 + </p> 381 + <NuxtLink 382 + :to="`/package/${packageName}${version ? `/v/${version}` : ''}`" 383 + class="btn" 384 + > 385 + Back to package 386 + </NuxtLink> 387 + </div> 388 + 389 + <!-- Main content: file tree + file viewer --> 390 + <div 391 + v-else-if="fileTree" 392 + class="flex-1 flex min-h-0" 393 + > 394 + <!-- File tree sidebar --> 395 + <aside class="w-64 lg:w-72 border-r border-border overflow-y-auto shrink-0 hidden md:block bg-bg-subtle"> 396 + <CodeFileTree 397 + :tree="fileTree.tree" 398 + :current-path="filePath ?? ''" 399 + :base-url="getCodeUrl()" 400 + /> 401 + </aside> 402 + 403 + <!-- File content / Directory listing --> 404 + <div class="flex-1 overflow-auto min-w-0"> 405 + <!-- File viewer --> 406 + <template v-if="isViewingFile && fileContent"> 407 + <div class="sticky top-0 bg-bg border-b border-border px-4 py-2 flex items-center justify-between"> 408 + <div class="flex items-center gap-3 text-sm"> 409 + <span class="text-fg-muted">{{ fileContent.lines }} lines</span> 410 + <span 411 + v-if="currentNode?.size" 412 + class="text-fg-subtle" 413 + >{{ formatBytes(currentNode.size) }}</span> 414 + </div> 415 + <div class="flex items-center gap-2"> 416 + <button 417 + v-if="selectedLines" 418 + class="px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded hover:text-fg hover:border-border-hover transition-colors" 419 + @click="copyPermalink" 420 + > 421 + Copy link 422 + </button> 423 + <a 424 + :href="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`" 425 + target="_blank" 426 + rel="noopener noreferrer" 427 + class="px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded hover:text-fg hover:border-border-hover transition-colors inline-flex items-center gap-1" 428 + > 429 + Raw 430 + <span class="i-carbon-launch w-3 h-3" /> 431 + </a> 432 + </div> 433 + </div> 434 + <CodeViewer 435 + :html="fileContent.html" 436 + :lines="fileContent.lines" 437 + :selected-lines="selectedLines" 438 + @line-click="handleLineClick" 439 + /> 440 + </template> 441 + 442 + <!-- File too large warning --> 443 + <div 444 + v-else-if="isViewingFile && isFileTooLarge" 445 + class="py-20 text-center" 446 + > 447 + <div class="i-carbon-document w-12 h-12 mx-auto text-fg-subtle mb-4" /> 448 + <p class="text-fg-muted mb-2"> 449 + File too large to preview 450 + </p> 451 + <p class="text-fg-subtle text-sm mb-4"> 452 + {{ formatBytes(currentNode?.size ?? 0) }} exceeds the 500KB limit for syntax highlighting 453 + </p> 454 + <a 455 + :href="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`" 456 + target="_blank" 457 + rel="noopener noreferrer" 458 + class="btn inline-flex items-center gap-2" 459 + > 460 + View raw file 461 + <span class="i-carbon-launch w-4 h-4" /> 462 + </a> 463 + </div> 464 + 465 + <!-- Loading file content --> 466 + <div 467 + v-else-if="filePath && fileStatus === 'pending'" 468 + class="flex min-h-full" 469 + aria-busy="true" 470 + aria-label="Loading file content" 471 + > 472 + <!-- Fake line numbers column --> 473 + <div class="shrink-0 bg-bg-subtle border-r border-border w-14 py-0"> 474 + <div 475 + v-for="n in 20" 476 + :key="n" 477 + class="px-3 h-6 flex items-center justify-end" 478 + > 479 + <span class="skeleton w-4 h-3 rounded-sm" /> 480 + </div> 481 + </div> 482 + <!-- Fake code content --> 483 + <div class="flex-1 p-4 space-y-1.5"> 484 + <div class="skeleton h-4 w-32 rounded-sm" /> 485 + <div class="skeleton h-4 w-48 rounded-sm" /> 486 + <div class="skeleton h-4 w-24 rounded-sm" /> 487 + <div class="h-4" /> 488 + <div class="skeleton h-4 w-64 rounded-sm" /> 489 + <div class="skeleton h-4 w-56 rounded-sm" /> 490 + <div class="skeleton h-4 w-40 rounded-sm" /> 491 + <div class="skeleton h-4 w-72 rounded-sm" /> 492 + <div class="h-4" /> 493 + <div class="skeleton h-4 w-36 rounded-sm" /> 494 + <div class="skeleton h-4 w-52 rounded-sm" /> 495 + <div class="skeleton h-4 w-44 rounded-sm" /> 496 + <div class="skeleton h-4 w-28 rounded-sm" /> 497 + <div class="h-4" /> 498 + <div class="skeleton h-4 w-60 rounded-sm" /> 499 + <div class="skeleton h-4 w-48 rounded-sm" /> 500 + <div class="skeleton h-4 w-32 rounded-sm" /> 501 + <div class="skeleton h-4 w-56 rounded-sm" /> 502 + <div class="skeleton h-4 w-40 rounded-sm" /> 503 + <div class="skeleton h-4 w-24 rounded-sm" /> 504 + </div> 505 + </div> 506 + 507 + <!-- Error loading file --> 508 + <div 509 + v-else-if="filePath && fileStatus === 'error'" 510 + class="py-20 text-center" 511 + role="alert" 512 + > 513 + <div class="i-carbon-warning-alt w-8 h-8 mx-auto text-fg-subtle mb-4" /> 514 + <p class="text-fg-muted mb-2"> 515 + Failed to load file 516 + </p> 517 + <p class="text-fg-subtle text-sm mb-4"> 518 + The file may be too large or unavailable 519 + </p> 520 + <a 521 + :href="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`" 522 + target="_blank" 523 + rel="noopener noreferrer" 524 + class="btn inline-flex items-center gap-2" 525 + > 526 + View raw file 527 + <span class="i-carbon-launch w-4 h-4" /> 528 + </a> 529 + </div> 530 + 531 + <!-- Directory listing (when no file selected or viewing a directory) --> 532 + <template v-else> 533 + <CodeDirectoryListing 534 + :tree="fileTree.tree" 535 + :current-path="filePath ?? ''" 536 + :base-url="getCodeUrl()" 537 + /> 538 + </template> 539 + </div> 540 + </div> 541 + 542 + <!-- Mobile file tree toggle --> 543 + <ClientOnly> 544 + <Teleport to="body"> 545 + <CodeMobileTreeDrawer 546 + v-if="fileTree" 547 + :tree="fileTree.tree" 548 + :current-path="filePath ?? ''" 549 + :base-url="getCodeUrl()" 550 + /> 551 + </Teleport> 552 + </ClientOnly> 553 + </main> 554 + </template>
+327
app/utils/file-icons.ts
··· 1 + /** 2 + * Get icon class for a file based on its name/extension. 3 + * Uses vscode-icons and carbon icons. 4 + */ 5 + 6 + // Extension to icon mapping 7 + // @unocss-include 8 + const EXTENSION_ICONS: Record<string, string> = { 9 + // JavaScript/TypeScript 10 + 'js': 'i-vscode-icons-file-type-js-official', 11 + 'mjs': 'i-vscode-icons-file-type-js-official', 12 + 'cjs': 'i-vscode-icons-file-type-js-official', 13 + 'ts': 'i-vscode-icons-file-type-typescript-official', 14 + 'mts': 'i-vscode-icons-file-type-typescript-official', 15 + 'cts': 'i-vscode-icons-file-type-typescript-official', 16 + 'jsx': 'i-vscode-icons-file-type-reactjs', 17 + 'tsx': 'i-vscode-icons-file-type-reactts', 18 + 19 + // Web 20 + 'html': 'i-vscode-icons-file-type-html', 21 + 'htm': 'i-vscode-icons-file-type-html', 22 + 'css': 'i-vscode-icons-file-type-css', 23 + 'scss': 'i-vscode-icons-file-type-scss', 24 + 'sass': 'i-vscode-icons-file-type-sass', 25 + 'less': 'i-vscode-icons-file-type-less', 26 + 'styl': 'i-vscode-icons-file-type-stylus', 27 + 'vue': 'i-vscode-icons-file-type-vue', 28 + 'svelte': 'i-vscode-icons-file-type-svelte', 29 + 'astro': 'i-vscode-icons-file-type-astro', 30 + 31 + // Config/Data 32 + 'json': 'i-vscode-icons-file-type-json', 33 + 'jsonc': 'i-vscode-icons-file-type-json', 34 + 'json5': 'i-vscode-icons-file-type-json5', 35 + 'yaml': 'i-vscode-icons-file-type-yaml', 36 + 'yml': 'i-vscode-icons-file-type-yaml', 37 + 'toml': 'i-vscode-icons-file-type-toml', 38 + 'xml': 'i-vscode-icons-file-type-xml', 39 + 'svg': 'i-vscode-icons-file-type-svg', 40 + 'graphql': 'i-vscode-icons-file-type-graphql', 41 + 'gql': 'i-vscode-icons-file-type-graphql', 42 + 'prisma': 'i-vscode-icons-file-type-prisma', 43 + 44 + // Documentation 45 + 'md': 'i-vscode-icons-file-type-markdown', 46 + 'mdx': 'i-vscode-icons-file-type-mdx', 47 + 'txt': 'i-vscode-icons-file-type-text', 48 + 'rst': 'i-vscode-icons-file-type-rst', 49 + 'pdf': 'i-vscode-icons-file-type-pdf', 50 + 51 + // Shell/Scripts 52 + 'sh': 'i-vscode-icons-file-type-shell', 53 + 'bash': 'i-vscode-icons-file-type-shell', 54 + 'zsh': 'i-vscode-icons-file-type-shell', 55 + 'fish': 'i-vscode-icons-file-type-shell', 56 + 'ps1': 'i-vscode-icons-file-type-powershell', 57 + 'bat': 'i-vscode-icons-file-type-bat', 58 + 'cmd': 'i-vscode-icons-file-type-bat', 59 + 60 + // Programming languages 61 + 'py': 'i-vscode-icons-file-type-python', 62 + 'pyi': 'i-vscode-icons-file-type-python', 63 + 'rb': 'i-vscode-icons-file-type-ruby', 64 + 'go': 'i-vscode-icons-file-type-go', 65 + 'rs': 'i-vscode-icons-file-type-rust', 66 + 'java': 'i-vscode-icons-file-type-java', 67 + 'kt': 'i-vscode-icons-file-type-kotlin', 68 + 'swift': 'i-vscode-icons-file-type-swift', 69 + 'c': 'i-vscode-icons-file-type-c', 70 + 'cpp': 'i-vscode-icons-file-type-cpp', 71 + 'h': 'i-vscode-icons-file-type-cheader', 72 + 'hpp': 'i-vscode-icons-file-type-cppheader', 73 + 'cs': 'i-vscode-icons-file-type-csharp', 74 + 'php': 'i-vscode-icons-file-type-php', 75 + 'lua': 'i-vscode-icons-file-type-lua', 76 + 'r': 'i-vscode-icons-file-type-r', 77 + 'sql': 'i-vscode-icons-file-type-sql', 78 + 'pl': 'i-vscode-icons-file-type-perl', 79 + 'ex': 'i-vscode-icons-file-type-elixir', 80 + 'exs': 'i-vscode-icons-file-type-elixir', 81 + 'erl': 'i-vscode-icons-file-type-erlang', 82 + 'hs': 'i-vscode-icons-file-type-haskell', 83 + 'clj': 'i-vscode-icons-file-type-clojure', 84 + 'scala': 'i-vscode-icons-file-type-scala', 85 + 'zig': 'i-vscode-icons-file-type-zig', 86 + 'nim': 'i-vscode-icons-file-type-nim', 87 + 'v': 'i-vscode-icons-file-type-vlang', 88 + 'wasm': 'i-vscode-icons-file-type-wasm', 89 + 90 + // Images 91 + 'png': 'i-vscode-icons-file-type-image', 92 + 'jpg': 'i-vscode-icons-file-type-image', 93 + 'jpeg': 'i-vscode-icons-file-type-image', 94 + 'gif': 'i-vscode-icons-file-type-image', 95 + 'webp': 'i-vscode-icons-file-type-image', 96 + 'ico': 'i-vscode-icons-file-type-image', 97 + 'bmp': 'i-vscode-icons-file-type-image', 98 + 99 + // Fonts 100 + 'woff': 'i-vscode-icons-file-type-font', 101 + 'woff2': 'i-vscode-icons-file-type-font', 102 + 'ttf': 'i-vscode-icons-file-type-font', 103 + 'otf': 'i-vscode-icons-file-type-font', 104 + 'eot': 'i-vscode-icons-file-type-font', 105 + 106 + // Archives 107 + 'zip': 'i-vscode-icons-file-type-zip', 108 + 'tar': 'i-vscode-icons-file-type-zip', 109 + 'gz': 'i-vscode-icons-file-type-zip', 110 + 'tgz': 'i-vscode-icons-file-type-zip', 111 + 'bz2': 'i-vscode-icons-file-type-zip', 112 + '7z': 'i-vscode-icons-file-type-zip', 113 + 'rar': 'i-vscode-icons-file-type-zip', 114 + 115 + // Certificates/Keys 116 + 'pem': 'i-vscode-icons-file-type-cert', 117 + 'crt': 'i-vscode-icons-file-type-cert', 118 + 'key': 'i-vscode-icons-file-type-key', 119 + 120 + // Diff/Patch 121 + 'diff': 'i-vscode-icons-file-type-diff', 122 + 'patch': 'i-vscode-icons-file-type-diff', 123 + 124 + // Other 125 + 'log': 'i-vscode-icons-file-type-log', 126 + 'lock': 'i-vscode-icons-file-type-lock', 127 + 'map': 'i-vscode-icons-file-type-map', 128 + 'wrl': 'i-vscode-icons-file-type-binary', 129 + 'bin': 'i-vscode-icons-file-type-binary', 130 + 'node': 'i-vscode-icons-file-type-node', 131 + } 132 + 133 + // Special filenames that have specific icons 134 + const FILENAME_ICONS: Record<string, string> = { 135 + // Package managers 136 + 'package.json': 'i-vscode-icons-file-type-npm', 137 + 'package-lock.json': 'i-vscode-icons-file-type-npm', 138 + 'pnpm-lock.yaml': 'i-vscode-icons-file-type-pnpm', 139 + 'pnpm-workspace.yaml': 'i-vscode-icons-file-type-pnpm', 140 + 'yarn.lock': 'i-vscode-icons-file-type-yarn', 141 + '.yarnrc': 'i-vscode-icons-file-type-yarn', 142 + '.yarnrc.yml': 'i-vscode-icons-file-type-yarn', 143 + 'bun.lockb': 'i-vscode-icons-file-type-bun', 144 + 'bunfig.toml': 'i-vscode-icons-file-type-bun', 145 + 'deno.json': 'i-vscode-icons-file-type-deno', 146 + 'deno.jsonc': 'i-vscode-icons-file-type-deno', 147 + 148 + // TypeScript configs 149 + 'tsconfig.json': 'i-vscode-icons-file-type-tsconfig', 150 + 'tsconfig.base.json': 'i-vscode-icons-file-type-tsconfig', 151 + 'tsconfig.build.json': 'i-vscode-icons-file-type-tsconfig', 152 + 'tsconfig.node.json': 'i-vscode-icons-file-type-tsconfig', 153 + 'jsconfig.json': 'i-vscode-icons-file-type-jsconfig', 154 + 155 + // Build tools 156 + 'vite.config.ts': 'i-vscode-icons-file-type-vite', 157 + 'vite.config.js': 'i-vscode-icons-file-type-vite', 158 + 'vite.config.mts': 'i-vscode-icons-file-type-vite', 159 + 'vite.config.mjs': 'i-vscode-icons-file-type-vite', 160 + 'webpack.config.js': 'i-vscode-icons-file-type-webpack', 161 + 'webpack.config.ts': 'i-vscode-icons-file-type-webpack', 162 + 'rollup.config.js': 'i-vscode-icons-file-type-rollup', 163 + 'rollup.config.ts': 'i-vscode-icons-file-type-rollup', 164 + 'rollup.config.mjs': 'i-vscode-icons-file-type-rollup', 165 + 'esbuild.config.js': 'i-vscode-icons-file-type-esbuild', 166 + 'turbo.json': 'i-vscode-icons-file-type-turbo', 167 + 'nx.json': 'i-vscode-icons-file-type-nx', 168 + 169 + // Framework configs 170 + 'nuxt.config.ts': 'i-vscode-icons-file-type-nuxt', 171 + 'nuxt.config.js': 'i-vscode-icons-file-type-nuxt', 172 + 'next.config.js': 'i-vscode-icons-file-type-next', 173 + 'next.config.mjs': 'i-vscode-icons-file-type-next', 174 + 'next.config.ts': 'i-vscode-icons-file-type-next', 175 + 'svelte.config.js': 'i-vscode-icons-file-type-svelte', 176 + 'astro.config.mjs': 'i-vscode-icons-file-type-astro', 177 + 'astro.config.ts': 'i-vscode-icons-file-type-astro', 178 + 'remix.config.js': 'i-vscode-icons-file-type-remix', 179 + 'angular.json': 'i-vscode-icons-file-type-angular', 180 + 'nest-cli.json': 'i-vscode-icons-file-type-nestjs', 181 + 182 + // Linting/Formatting 183 + '.eslintrc': 'i-vscode-icons-file-type-eslint', 184 + '.eslintrc.js': 'i-vscode-icons-file-type-eslint', 185 + '.eslintrc.cjs': 'i-vscode-icons-file-type-eslint', 186 + '.eslintrc.json': 'i-vscode-icons-file-type-eslint', 187 + '.eslintrc.yml': 'i-vscode-icons-file-type-eslint', 188 + 'eslint.config.js': 'i-vscode-icons-file-type-eslint', 189 + 'eslint.config.mjs': 'i-vscode-icons-file-type-eslint', 190 + 'eslint.config.ts': 'i-vscode-icons-file-type-eslint', 191 + '.prettierrc': 'i-vscode-icons-file-type-prettier', 192 + '.prettierrc.js': 'i-vscode-icons-file-type-prettier', 193 + '.prettierrc.json': 'i-vscode-icons-file-type-prettier', 194 + 'prettier.config.js': 'i-vscode-icons-file-type-prettier', 195 + 'prettier.config.mjs': 'i-vscode-icons-file-type-prettier', 196 + '.prettierignore': 'i-vscode-icons-file-type-prettier', 197 + 'biome.json': 'i-vscode-icons-file-type-biome', 198 + '.stylelintrc': 'i-vscode-icons-file-type-stylelint', 199 + '.stylelintrc.json': 'i-vscode-icons-file-type-stylelint', 200 + 201 + // Testing 202 + 'jest.config.js': 'i-vscode-icons-file-type-jest', 203 + 'jest.config.ts': 'i-vscode-icons-file-type-jest', 204 + 'vitest.config.ts': 'i-vscode-icons-file-type-vitest', 205 + 'vitest.config.js': 'i-vscode-icons-file-type-vitest', 206 + 'vitest.config.mts': 'i-vscode-icons-file-type-vitest', 207 + 'playwright.config.ts': 'i-vscode-icons-file-type-playwright', 208 + 'playwright.config.js': 'i-vscode-icons-file-type-playwright', 209 + 'cypress.config.ts': 'i-vscode-icons-file-type-cypress', 210 + 'cypress.config.js': 'i-vscode-icons-file-type-cypress', 211 + 212 + // Git 213 + '.gitignore': 'i-vscode-icons-file-type-git', 214 + '.gitattributes': 'i-vscode-icons-file-type-git', 215 + '.gitmodules': 'i-vscode-icons-file-type-git', 216 + '.gitkeep': 'i-vscode-icons-file-type-git', 217 + 218 + // CI/CD 219 + '.travis.yml': 'i-vscode-icons-file-type-travis', 220 + '.gitlab-ci.yml': 'i-vscode-icons-file-type-gitlab', 221 + 'Jenkinsfile': 'i-vscode-icons-file-type-jenkins', 222 + 'azure-pipelines.yml': 'i-vscode-icons-file-type-azure-pipelines', 223 + 'cloudbuild.yaml': 'i-vscode-icons-file-type-gcp', 224 + 'vercel.json': 'i-vscode-icons-file-type-vercel', 225 + 'netlify.toml': 'i-vscode-icons-file-type-netlify', 226 + 227 + // Docker 228 + 'Dockerfile': 'i-vscode-icons-file-type-docker', 229 + 'docker-compose.yml': 'i-vscode-icons-file-type-docker', 230 + 'docker-compose.yaml': 'i-vscode-icons-file-type-docker', 231 + '.dockerignore': 'i-vscode-icons-file-type-docker', 232 + 233 + // Environment 234 + '.env': 'i-vscode-icons-file-type-dotenv', 235 + '.env.local': 'i-vscode-icons-file-type-dotenv', 236 + '.env.development': 'i-vscode-icons-file-type-dotenv', 237 + '.env.production': 'i-vscode-icons-file-type-dotenv', 238 + '.env.test': 'i-vscode-icons-file-type-dotenv', 239 + '.env.example': 'i-vscode-icons-file-type-dotenv', 240 + 241 + // Editor configs 242 + '.editorconfig': 'i-vscode-icons-file-type-editorconfig', 243 + '.vscode': 'i-vscode-icons-file-type-vscode', 244 + 'settings.json': 'i-vscode-icons-file-type-vscode', 245 + 'launch.json': 'i-vscode-icons-file-type-vscode', 246 + 'extensions.json': 'i-vscode-icons-file-type-vscode', 247 + 248 + // Documentation 249 + 'README': 'i-vscode-icons-file-type-readme', 250 + 'README.md': 'i-vscode-icons-file-type-readme', 251 + 'readme.md': 'i-vscode-icons-file-type-readme', 252 + 'CHANGELOG': 'i-vscode-icons-file-type-changelog', 253 + 'CHANGELOG.md': 'i-vscode-icons-file-type-changelog', 254 + 'CONTRIBUTING.md': 'i-vscode-icons-file-type-contributing', 255 + 'CODE_OF_CONDUCT.md': 'i-vscode-icons-file-type-codeofconduct', 256 + 'LICENSE': 'i-vscode-icons-file-type-license', 257 + 'LICENSE.md': 'i-vscode-icons-file-type-license', 258 + 'LICENSE.txt': 'i-vscode-icons-file-type-license', 259 + 260 + // Node 261 + '.npmrc': 'i-vscode-icons-file-type-npm', 262 + '.npmignore': 'i-vscode-icons-file-type-npm', 263 + '.nvmrc': 'i-vscode-icons-file-type-node', 264 + '.node-version': 'i-vscode-icons-file-type-node', 265 + 266 + // Misc 267 + 'Makefile': 'i-vscode-icons-file-type-makefile', 268 + '.browserslistrc': 'i-vscode-icons-file-type-browserslist', 269 + 'browserslist': 'i-vscode-icons-file-type-browserslist', 270 + '.babelrc': 'i-vscode-icons-file-type-babel', 271 + 'babel.config.js': 'i-vscode-icons-file-type-babel', 272 + 'tailwind.config.js': 'i-vscode-icons-file-type-tailwind', 273 + 'tailwind.config.ts': 'i-vscode-icons-file-type-tailwind', 274 + 'postcss.config.js': 'i-vscode-icons-file-type-postcss', 275 + 'postcss.config.cjs': 'i-vscode-icons-file-type-postcss', 276 + 'uno.config.ts': 'i-vscode-icons-file-type-unocss', 277 + 'unocss.config.ts': 'i-vscode-icons-file-type-unocss', 278 + } 279 + 280 + // Patterns for .d.ts and similar compound extensions 281 + const COMPOUND_EXTENSIONS: Record<string, string> = { 282 + '.d.ts': 'i-vscode-icons-file-type-typescriptdef', 283 + '.d.mts': 'i-vscode-icons-file-type-typescriptdef', 284 + '.d.cts': 'i-vscode-icons-file-type-typescriptdef', 285 + '.test.ts': 'i-vscode-icons-file-type-testts', 286 + '.test.js': 'i-vscode-icons-file-type-testjs', 287 + '.spec.ts': 'i-vscode-icons-file-type-testts', 288 + '.spec.js': 'i-vscode-icons-file-type-testjs', 289 + '.test.tsx': 'i-vscode-icons-file-type-testts', 290 + '.test.jsx': 'i-vscode-icons-file-type-testjs', 291 + '.spec.tsx': 'i-vscode-icons-file-type-testts', 292 + '.spec.jsx': 'i-vscode-icons-file-type-testjs', 293 + '.stories.tsx': 'i-vscode-icons-file-type-storybook', 294 + '.stories.ts': 'i-vscode-icons-file-type-storybook', 295 + '.stories.jsx': 'i-vscode-icons-file-type-storybook', 296 + '.stories.js': 'i-vscode-icons-file-type-storybook', 297 + '.min.js': 'i-vscode-icons-file-type-jsmin', 298 + '.min.css': 'i-vscode-icons-file-type-cssmin', 299 + } 300 + 301 + // Default icon for unknown files 302 + const DEFAULT_ICON = 'i-vscode-icons-default-file' 303 + 304 + /** 305 + * Get the icon class for a file based on its name 306 + */ 307 + export function getFileIcon(filename: string): string { 308 + // Check exact filename match first 309 + if (FILENAME_ICONS[filename]) { 310 + return FILENAME_ICONS[filename] 311 + } 312 + 313 + // Check for compound extensions (e.g., .d.ts, .test.ts) 314 + for (const [suffix, icon] of Object.entries(COMPOUND_EXTENSIONS)) { 315 + if (filename.endsWith(suffix)) { 316 + return icon 317 + } 318 + } 319 + 320 + // Check simple extension 321 + const ext = filename.split('.').pop()?.toLowerCase() ?? '' 322 + if (EXTENSION_ICONS[ext]) { 323 + return EXTENSION_ICONS[ext] 324 + } 325 + 326 + return DEFAULT_ICON 327 + }
+1
package.json
··· 25 25 "test:browser:update": "playwright test --update-snapshots" 26 26 }, 27 27 "dependencies": { 28 + "@iconify-json/vscode-icons": "^1.2.40", 28 29 "@nuxt/eslint": "^1.12.1", 29 30 "@nuxt/fonts": "^0.13.0", 30 31 "@nuxt/scripts": "^0.13.2",
+10
pnpm-lock.yaml
··· 10 10 11 11 .: 12 12 dependencies: 13 + '@iconify-json/vscode-icons': 14 + specifier: ^1.2.40 15 + version: 1.2.40 13 16 '@nuxt/eslint': 14 17 specifier: ^1.12.1 15 18 version: 1.12.1(@typescript-eslint/utils@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.27)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ··· 1220 1223 1221 1224 '@iconify-json/solar@1.2.5': 1222 1225 resolution: {integrity: sha512-WMAiNwchU8zhfrySww6KQBRIBbsQ6SvgIu2yA+CHGyMima/0KQwT5MXogrZPJGoQF+1Ye3Qj6K+1CiyNn3YkoA==} 1226 + 1227 + '@iconify-json/vscode-icons@1.2.40': 1228 + resolution: {integrity: sha512-Q7JIWAxENwmcRg4EGRY+u16gBwrAj6mWeuSmuyuPvNvoTJHh8Ss8qoeDhrFYNgtWqNkzH5hSf4b2T9XLK5MsrA==} 1223 1229 1224 1230 '@iconify/types@2.0.0': 1225 1231 resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} ··· 8303 8309 '@iconify/types': 2.0.0 8304 8310 8305 8311 '@iconify-json/solar@1.2.5': 8312 + dependencies: 8313 + '@iconify/types': 2.0.0 8314 + 8315 + '@iconify-json/vscode-icons@1.2.40': 8306 8316 dependencies: 8307 8317 '@iconify/types': 2.0.0 8308 8318
+102
server/api/registry/file/[...pkg].get.ts
··· 1 + // Maximum file size to fetch and highlight (500KB) 2 + const MAX_FILE_SIZE = 500 * 1024 3 + 4 + /** 5 + * Fetch file content from jsDelivr CDN. 6 + */ 7 + async function fetchFileContent(packageName: string, version: string, filePath: string): Promise<string> { 8 + const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}` 9 + const response = await fetch(url) 10 + 11 + if (!response.ok) { 12 + if (response.status === 404) { 13 + throw createError({ statusCode: 404, message: 'File not found' }) 14 + } 15 + throw createError({ statusCode: 502, message: 'Failed to fetch file from jsDelivr' }) 16 + } 17 + 18 + // Check content-length header if available 19 + const contentLength = response.headers.get('content-length') 20 + if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) { 21 + throw createError({ 22 + statusCode: 413, 23 + message: `File too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_FILE_SIZE / 1024}KB.`, 24 + }) 25 + } 26 + 27 + const content = await response.text() 28 + 29 + // Double-check size after fetching (in case content-length wasn't set) 30 + if (content.length > MAX_FILE_SIZE) { 31 + throw createError({ 32 + statusCode: 413, 33 + message: `File too large (${(content.length / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_FILE_SIZE / 1024}KB.`, 34 + }) 35 + } 36 + 37 + return content 38 + } 39 + 40 + /** 41 + * Returns syntax-highlighted HTML for a file in a package. 42 + * 43 + * URL patterns: 44 + * - /api/registry/file/packageName/v/1.2.3/path/to/file.ts 45 + * - /api/registry/file/@scope/packageName/v/1.2.3/path/to/file.ts 46 + */ 47 + export default defineCachedEventHandler( 48 + async (event) => { 49 + const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] 50 + if (segments.length === 0) { 51 + throw createError({ statusCode: 400, message: 'Package name, version, and file path are required' }) 52 + } 53 + 54 + // Parse: [pkg, 'v', version, ...filePath] or [@scope, pkg, 'v', version, ...filePath] 55 + const vIndex = segments.indexOf('v') 56 + if (vIndex === -1 || vIndex >= segments.length - 2) { 57 + throw createError({ statusCode: 400, message: 'Version and file path are required' }) 58 + } 59 + 60 + const packageName = segments.slice(0, vIndex).join('/') 61 + // Find where version ends (next segment after 'v') and file path begins 62 + // Version could be like "1.2.3" or "1.2.3-beta.1" 63 + const versionAndPath = segments.slice(vIndex + 1) 64 + 65 + // The version is the first segment after 'v', and everything else is the file path 66 + const version = versionAndPath[0] 67 + const filePath = versionAndPath.slice(1).join('/') 68 + 69 + if (!packageName || !version || !filePath) { 70 + throw createError({ statusCode: 400, message: 'Package name, version, and file path are required' }) 71 + } 72 + 73 + try { 74 + const content = await fetchFileContent(packageName, version, filePath) 75 + const language = getLanguageFromPath(filePath) 76 + const html = await highlightCode(content, language) 77 + 78 + return { 79 + package: packageName, 80 + version, 81 + path: filePath, 82 + language, 83 + content, 84 + html, 85 + lines: content.split('\n').length, 86 + } 87 + } 88 + catch (error) { 89 + if (error && typeof error === 'object' && 'statusCode' in error) { 90 + throw error 91 + } 92 + throw createError({ statusCode: 502, message: 'Failed to fetch file content' }) 93 + } 94 + }, 95 + { 96 + maxAge: 60 * 60, // Cache for 1 hour (files don't change for a given version) 97 + getKey: (event) => { 98 + const pkg = getRouterParam(event, 'pkg') ?? '' 99 + return `file:${pkg}` 100 + }, 101 + }, 102 + )
+112
server/api/registry/files/[...pkg].get.ts
··· 1 + import type { JsDelivrPackageResponse, JsDelivrFileNode, PackageFileTree, PackageFileTreeResponse } from '#shared/types' 2 + 3 + /** 4 + * Fetch the file tree from jsDelivr API. 5 + * Returns a nested tree structure of all files in the package. 6 + */ 7 + async function fetchFileTree(packageName: string, version: string): Promise<JsDelivrPackageResponse> { 8 + const url = `https://data.jsdelivr.com/v1/packages/npm/${packageName}@${version}` 9 + const response = await fetch(url) 10 + 11 + if (!response.ok) { 12 + if (response.status === 404) { 13 + throw createError({ statusCode: 404, message: 'Package or version not found' }) 14 + } 15 + throw createError({ statusCode: 502, message: 'Failed to fetch file list from jsDelivr' }) 16 + } 17 + 18 + return response.json() 19 + } 20 + 21 + /** 22 + * Convert jsDelivr nested structure to our PackageFileTree format 23 + */ 24 + function convertToFileTree(nodes: JsDelivrFileNode[], parentPath: string = ''): PackageFileTree[] { 25 + const result: PackageFileTree[] = [] 26 + 27 + for (const node of nodes) { 28 + const path = parentPath ? `${parentPath}/${node.name}` : node.name 29 + 30 + if (node.type === 'directory') { 31 + result.push({ 32 + name: node.name, 33 + path, 34 + type: 'directory', 35 + children: node.files ? convertToFileTree(node.files, path) : [], 36 + }) 37 + } 38 + else { 39 + result.push({ 40 + name: node.name, 41 + path, 42 + type: 'file', 43 + size: node.size, 44 + }) 45 + } 46 + } 47 + 48 + // Sort: directories first, then files, alphabetically within each group 49 + result.sort((a, b) => { 50 + if (a.type !== b.type) { 51 + return a.type === 'directory' ? -1 : 1 52 + } 53 + return a.name.localeCompare(b.name) 54 + }) 55 + 56 + return result 57 + } 58 + 59 + /** 60 + * Returns the file tree for a package version. 61 + * 62 + * URL patterns: 63 + * - /api/registry/files/packageName/v/1.2.3 - required version 64 + * - /api/registry/files/@scope/packageName/v/1.2.3 - scoped package 65 + */ 66 + export default defineCachedEventHandler( 67 + async (event) => { 68 + const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] 69 + if (segments.length === 0) { 70 + throw createError({ statusCode: 400, message: 'Package name and version are required' }) 71 + } 72 + 73 + // Parse package name and version from URL segments 74 + // Patterns: [pkg, 'v', version] or [@scope, pkg, 'v', version] 75 + const vIndex = segments.indexOf('v') 76 + if (vIndex === -1 || vIndex >= segments.length - 1) { 77 + throw createError({ statusCode: 400, message: 'Version is required (use /v/{version})' }) 78 + } 79 + 80 + const packageName = segments.slice(0, vIndex).join('/') 81 + const version = segments.slice(vIndex + 1).join('/') 82 + 83 + if (!packageName || !version) { 84 + throw createError({ statusCode: 400, message: 'Package name and version are required' }) 85 + } 86 + 87 + try { 88 + const jsDelivrData = await fetchFileTree(packageName, version) 89 + const tree = convertToFileTree(jsDelivrData.files) 90 + 91 + return { 92 + package: packageName, 93 + version, 94 + default: jsDelivrData.default ?? undefined, 95 + tree, 96 + } satisfies PackageFileTreeResponse 97 + } 98 + catch (error) { 99 + if (error && typeof error === 'object' && 'statusCode' in error) { 100 + throw error 101 + } 102 + throw createError({ statusCode: 502, message: 'Failed to fetch file list' }) 103 + } 104 + }, 105 + { 106 + maxAge: 60 * 60, // Cache for 1 hour (files don't change for a given version) 107 + getKey: (event) => { 108 + const pkg = getRouterParam(event, 'pkg') ?? '' 109 + return `files:${pkg}` 110 + }, 111 + }, 112 + )
+149
server/utils/code-highlight.ts
··· 1 + // File extension to language mapping 2 + const EXTENSION_MAP: Record<string, string> = { 3 + // JavaScript/TypeScript 4 + js: 'javascript', 5 + mjs: 'javascript', 6 + cjs: 'javascript', 7 + ts: 'typescript', 8 + mts: 'typescript', 9 + cts: 'typescript', 10 + jsx: 'jsx', 11 + tsx: 'tsx', 12 + 13 + // Web 14 + html: 'html', 15 + htm: 'html', 16 + css: 'css', 17 + scss: 'scss', 18 + sass: 'scss', 19 + less: 'less', 20 + vue: 'vue', 21 + svelte: 'svelte', 22 + astro: 'astro', 23 + 24 + // Data formats 25 + json: 'json', 26 + jsonc: 'jsonc', 27 + json5: 'jsonc', 28 + yaml: 'yaml', 29 + yml: 'yaml', 30 + toml: 'toml', 31 + xml: 'xml', 32 + svg: 'xml', 33 + 34 + // Shell 35 + sh: 'bash', 36 + bash: 'bash', 37 + zsh: 'bash', 38 + fish: 'bash', 39 + 40 + // Docs 41 + md: 'markdown', 42 + mdx: 'markdown', 43 + markdown: 'markdown', 44 + 45 + // Other languages 46 + py: 'python', 47 + rs: 'rust', 48 + go: 'go', 49 + sql: 'sql', 50 + graphql: 'graphql', 51 + gql: 'graphql', 52 + diff: 'diff', 53 + patch: 'diff', 54 + } 55 + 56 + // Special filenames that have specific languages 57 + const FILENAME_MAP: Record<string, string> = { 58 + '.gitignore': 'bash', 59 + '.npmignore': 'bash', 60 + '.editorconfig': 'toml', 61 + '.prettierrc': 'json', 62 + '.eslintrc': 'json', 63 + 'tsconfig.json': 'jsonc', 64 + 'jsconfig.json': 'jsonc', 65 + 'package.json': 'json', 66 + 'package-lock.json': 'json', 67 + 'pnpm-lock.yaml': 'yaml', 68 + 'yarn.lock': 'yaml', 69 + 'Makefile': 'bash', 70 + 'Dockerfile': 'bash', 71 + 'LICENSE': 'text', 72 + 'CHANGELOG': 'markdown', 73 + 'CHANGELOG.md': 'markdown', 74 + 'README': 'markdown', 75 + 'README.md': 'markdown', 76 + } 77 + 78 + /** 79 + * Determine the language for syntax highlighting based on file path 80 + */ 81 + export function getLanguageFromPath(filePath: string): string { 82 + const filename = filePath.split('/').pop() || '' 83 + 84 + // Check for exact filename match first 85 + if (FILENAME_MAP[filename]) { 86 + return FILENAME_MAP[filename] 87 + } 88 + 89 + // Then check extension 90 + const ext = filename.split('.').pop()?.toLowerCase() || '' 91 + return EXTENSION_MAP[ext] || 'text' 92 + } 93 + 94 + /** 95 + * Highlight code using Shiki with line-by-line output for line highlighting. 96 + * Each line is wrapped in a span.line for individual line highlighting. 97 + */ 98 + export async function highlightCode(code: string, language: string): Promise<string> { 99 + const shiki = await getShikiHighlighter() 100 + const loadedLangs = shiki.getLoadedLanguages() 101 + 102 + // Use Shiki if language is loaded 103 + if (loadedLangs.includes(language as never)) { 104 + try { 105 + const html = shiki.codeToHtml(code, { 106 + lang: language, 107 + theme: 'github-dark', 108 + }) 109 + 110 + // Check if Shiki already outputs .line spans (newer versions do) 111 + if (html.includes('<span class="line">')) { 112 + // Shiki already wraps lines, but they're separated by newlines 113 + // We need to remove the newlines since display:block handles line breaks 114 + // Replace newlines between </span> and <span class="line"> with nothing 115 + return html.replace(/<\/span>\n<span class="line">/g, '</span><span class="line">') 116 + } 117 + 118 + // Older Shiki without .line spans - wrap manually 119 + const codeMatch = html.match(/<code[^>]*>([\s\S]*)<\/code>/) 120 + if (codeMatch?.[1]) { 121 + const codeContent = codeMatch[1] 122 + const lines = codeContent.split('\n') 123 + const wrappedLines = lines.map((line: string, i: number) => { 124 + if (i === lines.length - 1 && line === '') return null 125 + return `<span class="line">${line}</span>` 126 + }).filter((line: string | null): line is string => line !== null).join('') 127 + 128 + return html.replace(codeMatch[1], wrappedLines) 129 + } 130 + 131 + return html 132 + } 133 + catch { 134 + // Fall back to plain 135 + } 136 + } 137 + 138 + // Plain code for unknown languages - also wrap lines 139 + const lines = code.split('\n') 140 + const wrappedLines = lines.map((line) => { 141 + const escaped = line 142 + .replace(/&/g, '&amp;') 143 + .replace(/</g, '&lt;') 144 + .replace(/>/g, '&gt;') 145 + return `<span class="line">${escaped}</span>` 146 + }).join('') // No newlines - display:block handles it 147 + 148 + return `<pre class="shiki github-dark"><code>${wrappedLines}</code></pre>` 149 + }
+2 -39
server/utils/readme.ts
··· 1 1 import { marked, type Tokens } from 'marked' 2 2 import sanitizeHtml from 'sanitize-html' 3 - import { createHighlighterCore, type HighlighterCore } from 'shiki/core' 4 - import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' 5 3 import { hasProtocol } from 'ufo' 6 4 7 5 // only allow h3-h6 since we shift README headings down by 2 levels ··· 41 39 // GitHub-style callout types 42 40 // Format: > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION] 43 41 44 - // Singleton highlighter instance using JavaScript engine (no WASM needed) 45 - let highlighter: HighlighterCore | null = null 46 - 47 - async function getHighlighter(): Promise<HighlighterCore> { 48 - if (!highlighter) { 49 - highlighter = await createHighlighterCore({ 50 - themes: [ 51 - import('@shikijs/themes/github-dark'), 52 - ], 53 - langs: [ 54 - import('@shikijs/langs/javascript'), 55 - import('@shikijs/langs/typescript'), 56 - import('@shikijs/langs/json'), 57 - import('@shikijs/langs/html'), 58 - import('@shikijs/langs/css'), 59 - import('@shikijs/langs/bash'), 60 - import('@shikijs/langs/shell'), 61 - import('@shikijs/langs/markdown'), 62 - import('@shikijs/langs/yaml'), 63 - import('@shikijs/langs/vue'), 64 - import('@shikijs/langs/jsx'), 65 - import('@shikijs/langs/tsx'), 66 - import('@shikijs/langs/diff'), 67 - import('@shikijs/langs/sql'), 68 - import('@shikijs/langs/graphql'), 69 - import('@shikijs/langs/python'), 70 - import('@shikijs/langs/rust'), 71 - import('@shikijs/langs/go'), 72 - ], 73 - engine: createJavaScriptRegexEngine(), 74 - }) 75 - } 76 - return highlighter 77 - } 78 - 79 42 function resolveUrl(url: string, packageName: string): string { 80 43 if (!url) return url 81 44 if (url.startsWith('#')) { ··· 103 66 export async function renderReadmeHtml(content: string, packageName: string): Promise<string> { 104 67 if (!content) return '' 105 68 106 - const shiki = await getHighlighter() 69 + const shiki = await getShikiHighlighter() 107 70 const renderer = new marked.Renderer() 108 71 109 72 // Shift heading levels down by 2 for semantic correctness ··· 116 79 return `<h${semanticLevel} data-level="${depth}">${text}</h${semanticLevel}>\n` 117 80 } 118 81 119 - // Syntax highlighting for code blocks 82 + // Syntax highlighting for code blocks (uses shared highlighter) 120 83 renderer.code = ({ text, lang }: Tokens.Code) => { 121 84 const language = lang || 'text' 122 85 const loadedLangs = shiki.getLoadedLanguages()
+76
server/utils/shiki.ts
··· 1 + import { createHighlighterCore, type HighlighterCore } from 'shiki/core' 2 + import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' 3 + 4 + let highlighter: HighlighterCore | null = null 5 + 6 + export async function getShikiHighlighter(): Promise<HighlighterCore> { 7 + if (!highlighter) { 8 + highlighter = await createHighlighterCore({ 9 + themes: [ 10 + import('@shikijs/themes/github-dark'), 11 + ], 12 + langs: [ 13 + // Core web languages 14 + import('@shikijs/langs/javascript'), 15 + import('@shikijs/langs/typescript'), 16 + import('@shikijs/langs/json'), 17 + import('@shikijs/langs/jsonc'), 18 + import('@shikijs/langs/html'), 19 + import('@shikijs/langs/css'), 20 + import('@shikijs/langs/scss'), 21 + import('@shikijs/langs/less'), 22 + 23 + // Frameworks 24 + import('@shikijs/langs/vue'), 25 + import('@shikijs/langs/jsx'), 26 + import('@shikijs/langs/tsx'), 27 + import('@shikijs/langs/svelte'), 28 + import('@shikijs/langs/astro'), 29 + 30 + // Shell/CLI 31 + import('@shikijs/langs/bash'), 32 + import('@shikijs/langs/shell'), 33 + 34 + // Config/Data formats 35 + import('@shikijs/langs/yaml'), 36 + import('@shikijs/langs/toml'), 37 + import('@shikijs/langs/xml'), 38 + import('@shikijs/langs/markdown'), 39 + 40 + // Other languages 41 + import('@shikijs/langs/diff'), 42 + import('@shikijs/langs/sql'), 43 + import('@shikijs/langs/graphql'), 44 + import('@shikijs/langs/python'), 45 + import('@shikijs/langs/rust'), 46 + import('@shikijs/langs/go'), 47 + ], 48 + engine: createJavaScriptRegexEngine(), 49 + }) 50 + } 51 + return highlighter 52 + } 53 + 54 + export async function highlightCodeBlock(code: string, language: string): Promise<string> { 55 + const shiki = await getShikiHighlighter() 56 + const loadedLangs = shiki.getLoadedLanguages() 57 + 58 + if (loadedLangs.includes(language as never)) { 59 + try { 60 + return shiki.codeToHtml(code, { 61 + lang: language, 62 + theme: 'github-dark', 63 + }) 64 + } 65 + catch { 66 + // Fall back to plain 67 + } 68 + } 69 + 70 + // Plain code block for unknown languages 71 + const escaped = code 72 + .replace(/&/g, '&amp;') 73 + .replace(/</g, '&lt;') 74 + .replace(/>/g, '&gt;') 75 + return `<pre><code class="language-${language}">${escaped}</code></pre>\n` 76 + }
+72
shared/types/npm-registry.ts
··· 218 218 project?: string 219 219 ciConfigPath?: string 220 220 } 221 + 222 + /** 223 + * jsDelivr API Types 224 + * Used for package file browsing 225 + */ 226 + 227 + /** 228 + * Response from jsDelivr package API (nested structure) 229 + * GET https://data.jsdelivr.com/v1/packages/npm/{package}@{version} 230 + */ 231 + export interface JsDelivrPackageResponse { 232 + type: 'npm' 233 + name: string 234 + version: string 235 + /** Default entry point file */ 236 + default: string | null 237 + /** Nested file tree */ 238 + files: JsDelivrFileNode[] 239 + } 240 + 241 + /** 242 + * A file or directory node from jsDelivr API 243 + */ 244 + export interface JsDelivrFileNode { 245 + type: 'file' | 'directory' 246 + name: string 247 + /** File hash (only for files) */ 248 + hash?: string 249 + /** File size in bytes (only for files) */ 250 + size?: number 251 + /** Child nodes (only for directories) */ 252 + files?: JsDelivrFileNode[] 253 + } 254 + 255 + /** 256 + * Tree node for package file browser 257 + */ 258 + export interface PackageFileTree { 259 + /** File or directory name */ 260 + name: string 261 + /** Full path from package root */ 262 + path: string 263 + /** Node type */ 264 + type: 'file' | 'directory' 265 + /** File size in bytes (only for files) */ 266 + size?: number 267 + /** Child nodes (only for directories) */ 268 + children?: PackageFileTree[] 269 + } 270 + 271 + /** 272 + * Response from file tree API 273 + */ 274 + export interface PackageFileTreeResponse { 275 + package: string 276 + version: string 277 + default?: string 278 + tree: PackageFileTree[] 279 + } 280 + 281 + /** 282 + * Response from file content API 283 + */ 284 + export interface PackageFileContentResponse { 285 + package: string 286 + version: string 287 + path: string 288 + language: string 289 + content: string 290 + html: string 291 + lines: number 292 + }