diff --git a/app/components/diff/Hunk.vue b/app/components/diff/Hunk.vue new file mode 100644 index 00000000..741689f0 --- /dev/null +++ b/app/components/diff/Hunk.vue @@ -0,0 +1,11 @@ + + + diff --git a/app/components/diff/Line.vue b/app/components/diff/Line.vue new file mode 100644 index 00000000..2c890635 --- /dev/null +++ b/app/components/diff/Line.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/app/components/diff/SkipBlock.vue b/app/components/diff/SkipBlock.vue new file mode 100644 index 00000000..6200903e --- /dev/null +++ b/app/components/diff/SkipBlock.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/components/diff/Table.vue b/app/components/diff/Table.vue new file mode 100644 index 00000000..e207d798 --- /dev/null +++ b/app/components/diff/Table.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/app/components/diff/ViewerPanel.vue b/app/components/diff/ViewerPanel.vue new file mode 100644 index 00000000..0f7a3568 --- /dev/null +++ b/app/components/diff/ViewerPanel.vue @@ -0,0 +1,645 @@ + + + + + diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 0be15840..78d1e10e 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -448,7 +448,7 @@ function handleClick(event: MouseEvent) { - + diff --git a/app/pages/compare/[...path].vue b/app/pages/compare/[...path].vue new file mode 100644 index 00000000..8f147456 --- /dev/null +++ b/app/pages/compare/[...path].vue @@ -0,0 +1,453 @@ + + + diff --git a/app/utils/language-detection.ts b/app/utils/language-detection.ts new file mode 100644 index 00000000..5848a9c8 --- /dev/null +++ b/app/utils/language-detection.ts @@ -0,0 +1,75 @@ +/** + * Guess the programming language from a file path for syntax highlighting. + * NOTE: We aren't using this for any other language besides JS/TS/JSON. + */ +export function guessLanguageFromPath(filePath: string): string { + const fileName = filePath.split('/').pop() || '' + const ext = fileName.split('.').pop()?.toLowerCase() || '' + + // Extension-based mapping + const extMap: Record = { + // JavaScript/TypeScript + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + jsx: 'jsx', + ts: 'typescript', + mts: 'typescript', + cts: 'typescript', + tsx: 'tsx', + + // Web + html: 'html', + htm: 'html', + css: 'css', + scss: 'scss', + sass: 'scss', + less: 'less', + vue: 'vue', + svelte: 'svelte', + astro: 'astro', + + // Data/Config + json: 'json', + jsonc: 'jsonc', + json5: 'json', + yaml: 'yaml', + yml: 'yaml', + toml: 'toml', + xml: 'xml', + svg: 'xml', + + // Documentation + md: 'markdown', + mdx: 'markdown', + txt: 'text', + + // Shell + sh: 'bash', + bash: 'bash', + zsh: 'bash', + fish: 'bash', + + // Other languages + py: 'python', + rs: 'rust', + go: 'go', + sql: 'sql', + graphql: 'graphql', + gql: 'graphql', + diff: 'diff', + patch: 'diff', + } + + // Special filename mapping + const filenameMap: Record = { + Dockerfile: 'dockerfile', + Makefile: 'makefile', + } + + if (filenameMap[fileName]) { + return filenameMap[fileName] + } + + return extMap[ext] || 'text' +} diff --git a/app/utils/shiki-client.ts b/app/utils/shiki-client.ts new file mode 100644 index 00000000..62974580 --- /dev/null +++ b/app/utils/shiki-client.ts @@ -0,0 +1,24 @@ +import { createHighlighterCore, type HighlighterCore } from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import githubDark from '@shikijs/themes/github-dark' +import githubLight from '@shikijs/themes/github-light' +import javascript from '@shikijs/langs/javascript' +import typescript from '@shikijs/langs/typescript' +import json from '@shikijs/langs/json' + +let highlighterPromise: Promise | null = null + +/** + * Lightweight client-side Shiki highlighter (JS/TS/JSON only). + */ +export function getClientHighlighter(): Promise { + if (highlighterPromise) return highlighterPromise + + highlighterPromise = createHighlighterCore({ + themes: [githubDark, githubLight], + langs: [javascript, typescript, json], + engine: createJavaScriptRegexEngine(), + }) + + return highlighterPromise +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 546e4c62..41b7da3c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -748,5 +748,29 @@ "empty": "No organizations found", "view_all": "View all" } + }, + "compare": { + "title": "Compare Versions", + "from": "From", + "to": "To", + "comparing": "Comparing versions...", + "invalid_url": "Invalid comparison URL. Use format: /compare/package/v/from...to", + "failed": "Failed to compare versions", + "files_added": "Files added", + "files_removed": "Files removed", + "files_modified": "Files modified", + "deps_changed": "Deps changed", + "dependency_changes": "Dependency Changes", + "file_changes": "File Changes", + "files_count": "{count} files", + "no_file_changes": "No file changes detected", + "no_content_changes": "No content changes detected", + "loading_diff": "Loading diff...", + "failed_to_load_diff": "Failed to load diff", + "view_file": "View file", + "view_in_browser": "View in code browser", + "close_diff": "Close diff", + "lines_hidden": "{count} lines hidden", + "compare_versions": "Compare versions" } } diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 546e4c62..41b7da3c 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -748,5 +748,29 @@ "empty": "No organizations found", "view_all": "View all" } + }, + "compare": { + "title": "Compare Versions", + "from": "From", + "to": "To", + "comparing": "Comparing versions...", + "invalid_url": "Invalid comparison URL. Use format: /compare/package/v/from...to", + "failed": "Failed to compare versions", + "files_added": "Files added", + "files_removed": "Files removed", + "files_modified": "Files modified", + "deps_changed": "Deps changed", + "dependency_changes": "Dependency Changes", + "file_changes": "File Changes", + "files_count": "{count} files", + "no_file_changes": "No file changes detected", + "no_content_changes": "No content changes detected", + "loading_diff": "Loading diff...", + "failed_to_load_diff": "Failed to load diff", + "view_file": "View file", + "view_in_browser": "View in code browser", + "close_diff": "Close diff", + "lines_hidden": "{count} lines hidden", + "compare_versions": "Compare versions" } } diff --git a/package.json b/package.json index 84597047..fb199dee 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "@shikijs/themes": "3.21.0", "@vueuse/core": "14.1.0", "@vueuse/nuxt": "14.1.0", + "diff": "8.0.3", "module-replacements": "2.11.0", + "motion-v": "1.10.2", "nuxt": "4.3.0", "nuxt-og-image": "5.1.13", "ohash": "2.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab85b8cb..b02b9587 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,9 +71,15 @@ importers: '@vueuse/nuxt': specifier: 14.1.0 version: 14.1.0(magicast@0.5.1)(nuxt@4.3.0(@parcel/watcher@2.5.6)(@types/node@24.10.9)(@vue/compiler-sfc@3.5.27)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(oxlint@1.42.0(oxlint-tsgolint@0.11.3))(rolldown@1.0.0-rc.1)(rollup@4.57.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + diff: + specifier: 8.0.3 + version: 8.0.3 module-replacements: specifier: 2.11.0 version: 2.11.0 + motion-v: + specifier: 1.10.2 + version: 1.10.2(@vueuse/core@14.1.0(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) nuxt: specifier: 4.3.0 version: 4.3.0(@parcel/watcher@2.5.6)(@types/node@24.10.9)(@vue/compiler-sfc@3.5.27)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(oxlint@1.42.0(oxlint-tsgolint@0.11.3))(rolldown@1.0.0-rc.1)(rollup@4.57.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2) diff --git a/server/api/registry/compare-file/[...pkg].get.ts b/server/api/registry/compare-file/[...pkg].get.ts new file mode 100644 index 00000000..b7fdacca --- /dev/null +++ b/server/api/registry/compare-file/[...pkg].get.ts @@ -0,0 +1,248 @@ +import * as v from 'valibot' +import { PackageFileDiffQuerySchema } from '#shared/schemas/package' +import type { FileDiffResponse, DiffHunk } from '#shared/types' +import { CACHE_MAX_AGE_ONE_YEAR } from '#shared/utils/constants' +import { createDiff, insertSkipBlocks, countDiffStats } from '#server/utils/diff' + +const CACHE_VERSION = 1 +const DIFF_TIMEOUT = 15000 // 15 sec + +/** Maximum file size for diffing (250KB - smaller than viewing since we diff two files) */ +const MAX_DIFF_FILE_SIZE = 250 * 1024 + +/** + * Parse the version range from the URL. + * Supports formats like: "1.0.0...2.0.0" or "1.0.0..2.0.0" + */ +function parseVersionRange(versionRange: string): { from: string; to: string } | null { + // Try triple dot first (GitHub style) + let parts = versionRange.split('...') + if (parts.length === 2) { + return { from: parts[0]!, to: parts[1]! } + } + + // Try double dot + parts = versionRange.split('..') + if (parts.length === 2) { + return { from: parts[0]!, to: parts[1]! } + } + + return null +} + +/** + * Fetch file content from jsDelivr with size check + */ +async function fetchFileContentForDiff( + packageName: string, + version: string, + filePath: string, + signal?: AbortSignal, +): Promise { + const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}` + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), DIFF_TIMEOUT) + if (signal) { + signal.addEventListener('abort', () => controller.abort(signal.reason as any), { once: true }) + } + + try { + const response = await fetch(url, { signal: controller.signal }) + + if (!response.ok) { + if (response.status === 404) return null + throw createError({ + statusCode: response.status >= 500 ? 502 : response.status, + message: `Failed to fetch file (${response.status})`, + }) + } + + const contentLength = response.headers.get('content-length') + if (contentLength && parseInt(contentLength, 10) > MAX_DIFF_FILE_SIZE) { + throw createError({ + statusCode: 413, + message: `File too large to diff (${(parseInt(contentLength, 10) / 1024).toFixed(0)}KB). Maximum is ${MAX_DIFF_FILE_SIZE / 1024}KB.`, + }) + } + + const content = await response.text() + + if (content.length > MAX_DIFF_FILE_SIZE) { + throw createError({ + statusCode: 413, + message: `File too large to diff (${(content.length / 1024).toFixed(0)}KB). Maximum is ${MAX_DIFF_FILE_SIZE / 1024}KB.`, + }) + } + + return content + } catch (error) { + if (error && typeof error === 'object' && 'statusCode' in error) { + throw error + } + if ((error as Error)?.name === 'AbortError') { + throw createError({ + statusCode: 504, + message: 'Diff request timed out while fetching file', + }) + } + throw createError({ + statusCode: 502, + message: 'Failed to fetch file for diff', + }) + } finally { + clearTimeout(timeoutId) + } +} + +/** + * Get diff for a specific file between two versions. + * + * URL patterns: + * - /api/registry/compare-file/packageName/v/1.0.0...2.0.0/path/to/file.ts + * - /api/registry/compare-file/@scope/packageName/v/1.0.0...2.0.0/path/to/file.ts + */ +export default defineCachedEventHandler( + async event => { + const startTime = Date.now() + + // Parse package segments + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + const { rawPackageName, rawVersion: fullPathAfterV } = parsePackageParams(pkgParamSegments) + + // Split version range and file path + // fullPathAfterV => "1.0.0...2.0.0/dist/index.mjs" + const versionSegments = fullPathAfterV?.split('/') ?? [] + + if (versionSegments.length < 2) { + throw createError({ + statusCode: 400, + message: 'Version range and file path are required', + }) + } + + // First segment contains the version range + const rawVersionRange = versionSegments[0]! + const rawFilePath = versionSegments.slice(1).join('/') + + // Parse version range + const range = parseVersionRange(rawVersionRange) + if (!range) { + throw createError({ + statusCode: 400, + message: 'Invalid version range format. Use from...to (e.g., 1.0.0...2.0.0)', + }) + } + + try { + // Validate inputs + const { packageName, fromVersion, toVersion, filePath } = v.parse( + PackageFileDiffQuerySchema, + { + packageName: rawPackageName, + fromVersion: range.from, + toVersion: range.to, + filePath: rawFilePath, + }, + ) + + // Set up abort controller for timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), DIFF_TIMEOUT) + + try { + // Get diff options from query params + const query = getQuery(event) + const diffOptions = { + mergeModifiedLines: query.mergeModifiedLines !== 'false', + maxChangeRatio: parseFloat(query.maxChangeRatio as string) || 0.45, + maxDiffDistance: parseInt(query.maxDiffDistance as string, 10) || 30, + inlineMaxCharEdits: parseInt(query.inlineMaxCharEdits as string, 10) || 2, + } + + // Fetch file contents in parallel + const [fromContent, toContent] = await Promise.all([ + fetchFileContentForDiff(packageName, fromVersion, filePath, controller.signal), + fetchFileContentForDiff(packageName, toVersion, filePath, controller.signal), + ]) + + clearTimeout(timeoutId) + + // Determine file type + let type: 'add' | 'delete' | 'modify' + if (fromContent === null && toContent === null) { + throw createError({ + statusCode: 404, + message: 'File not found in either version', + }) + } else if (fromContent === null) { + type = 'add' + } else if (toContent === null) { + type = 'delete' + } else { + type = 'modify' + } + + // Create diff with options + const diff = createDiff(fromContent ?? '', toContent ?? '', filePath, diffOptions) + + if (!diff) { + // No changes (shouldn't happen but handle it) + return { + package: packageName, + from: fromVersion, + to: toVersion, + path: filePath, + type, + hunks: [], + stats: { additions: 0, deletions: 0 }, + meta: { computeTime: Date.now() - startTime }, + } satisfies FileDiffResponse + } + + // Insert skip blocks and count stats + const hunkOnly = diff.hunks.filter((h): h is DiffHunk => h.type === 'hunk') + const hunksWithSkips = insertSkipBlocks(hunkOnly) + const stats = countDiffStats(hunksWithSkips) + + return { + package: packageName, + from: fromVersion, + to: toVersion, + path: filePath, + type, + hunks: hunksWithSkips, + stats, + meta: { computeTime: Date.now() - startTime }, + } satisfies FileDiffResponse + } catch (error) { + clearTimeout(timeoutId) + + // Check if it was a timeout + if (error instanceof Error && error.name === 'AbortError') { + throw createError({ + statusCode: 504, + message: 'Diff computation timed out', + }) + } + + throw error + } + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to compute file diff', + }) + } + }, + { + // Diff between specific versions never changes - cache permanently + maxAge: CACHE_MAX_AGE_ONE_YEAR, + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + const query = getQuery(event) + const optionsKey = `${query.mergeModifiedLines}:${query.maxChangeRatio}:${query.maxDiffDistance}:${query.inlineMaxCharEdits}` + return `compare-file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}:${optionsKey}` + }, + }, +) diff --git a/server/api/registry/compare/[...pkg].get.ts b/server/api/registry/compare/[...pkg].get.ts new file mode 100644 index 00000000..71ba004e --- /dev/null +++ b/server/api/registry/compare/[...pkg].get.ts @@ -0,0 +1,143 @@ +import * as v from 'valibot' +import { PackageCompareQuerySchema } from '#shared/schemas/package' +import type { CompareResponse } from '#shared/types' +import { CACHE_MAX_AGE_ONE_YEAR } from '#shared/utils/constants' +import { buildCompareResponse } from '#server/utils/compare' + +const CACHE_VERSION = 1 +const COMPARE_TIMEOUT = 8000 // 8 seconds + +/** + * Fetch package.json from jsDelivr + */ +async function fetchPackageJson( + packageName: string, + version: string, + signal?: AbortSignal, +): Promise | null> { + try { + const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/package.json` + const response = await fetch(url, { signal }) + if (!response.ok) return null + return (await response.json()) as Record + } catch { + return null + } +} + +/** + * Parse the version range from the URL. + * Supports formats like: "1.0.0...2.0.0" or "1.0.0..2.0.0" + */ +function parseVersionRange(versionRange: string): { from: string; to: string } | null { + // Try triple dot first (GitHub style) + let parts = versionRange.split('...') + if (parts.length === 2) { + return { from: parts[0]!, to: parts[1]! } + } + + // Try double dot + parts = versionRange.split('..') + if (parts.length === 2) { + return { from: parts[0]!, to: parts[1]! } + } + + return null +} + +/** + * Compare two package versions and return differences. + * + * URL patterns: + * - /api/registry/compare/packageName/v/1.0.0...2.0.0 + * - /api/registry/compare/@scope/packageName/v/1.0.0...2.0.0 + */ +export default defineCachedEventHandler( + async event => { + const startTime = Date.now() + + // Parse package segments + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + const { rawPackageName, rawVersion: rawVersionRange } = parsePackageParams(pkgParamSegments) + + if (!rawVersionRange) { + throw createError({ + statusCode: 400, + message: 'Version range is required (e.g., 1.0.0...2.0.0)', + }) + } + + // Parse version range + const range = parseVersionRange(rawVersionRange) + if (!range) { + throw createError({ + statusCode: 400, + message: 'Invalid version range format. Use from...to (e.g., 1.0.0...2.0.0)', + }) + } + + try { + // Validate inputs + const { packageName, fromVersion, toVersion } = v.parse(PackageCompareQuerySchema, { + packageName: rawPackageName, + fromVersion: range.from, + toVersion: range.to, + }) + + // Set up abort controller for timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), COMPARE_TIMEOUT) + + try { + // Fetch file trees and package.json for both versions in parallel + const [fromTree, toTree, fromPkg, toPkg] = await Promise.all([ + getPackageFileTree(packageName, fromVersion), + getPackageFileTree(packageName, toVersion), + fetchPackageJson(packageName, fromVersion, controller.signal), + fetchPackageJson(packageName, toVersion, controller.signal), + ]) + + clearTimeout(timeoutId) + + const computeTime = Date.now() - startTime + + return buildCompareResponse( + packageName, + fromVersion, + toVersion, + fromTree.tree, + toTree.tree, + fromPkg, + toPkg, + computeTime, + ) satisfies CompareResponse + } catch (error) { + clearTimeout(timeoutId) + + // Check if it was a timeout + if (error instanceof Error && error.name === 'AbortError') { + throw createError({ + statusCode: 504, + message: 'Comparison timed out. Try comparing fewer files.', + }) + } + + throw error + } + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to compare package versions', + }) + } + }, + { + // Comparison between specific versions never changes hence cache permanently + maxAge: CACHE_MAX_AGE_ONE_YEAR, + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `compare:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` + }, + }, +) diff --git a/server/utils/compare.ts b/server/utils/compare.ts new file mode 100644 index 00000000..71bfec1e --- /dev/null +++ b/server/utils/compare.ts @@ -0,0 +1,260 @@ +import { diff as semverDiff } from 'semver' +import type { PackageFileTree, DependencyChange, FileChange, CompareResponse } from '#shared/types' + +/** Maximum number of files to include in comparison */ +const MAX_FILES_COMPARE = 1000 + +/** Flatten a file tree into a map of path -> node */ +export function flattenTree(tree: PackageFileTree[]): Map { + const result = new Map() + + function traverse(nodes: PackageFileTree[]) { + for (const node of nodes) { + result.set(node.path, node) + if (node.children) { + traverse(node.children) + } + } + } + + traverse(tree) + return result +} + +/** Compare two file trees and return changes */ +export function compareFileTrees( + fromTree: PackageFileTree[], + toTree: PackageFileTree[], +): { added: FileChange[]; removed: FileChange[]; modified: FileChange[]; truncated: boolean } { + const fromFiles = flattenTree(fromTree) + const toFiles = flattenTree(toTree) + + const hasChanged = (fromNode: PackageFileTree, toNode: PackageFileTree): boolean => { + // Prefer strong hash comparison when both hashes are available + if (fromNode.hash && toNode.hash) return fromNode.hash !== toNode.hash + // Fallback to size comparison if hashes are missing + if (typeof fromNode.size === 'number' && typeof toNode.size === 'number') { + return fromNode.size !== toNode.size + } + // If we lack comparable signals, assume unchanged + return false + } + + const added: FileChange[] = [] + const removed: FileChange[] = [] + const modified: FileChange[] = [] + let truncated = false + const overLimit = () => added.length + removed.length + modified.length >= MAX_FILES_COMPARE + + // Find added and modified files + for (const [path, toNode] of toFiles) { + if (overLimit()) { + truncated = true + break + } + + const fromNode = fromFiles.get(path) + + // Handle directory -> file / file -> directory transitions + if (toNode.type === 'directory') { + if (fromNode?.type === 'file') { + removed.push({ + path, + type: 'removed', + oldSize: fromNode.size, + }) + } + continue + } + + // toNode is file + if (!fromNode) { + // New file + added.push({ + path, + type: 'added', + newSize: toNode.size, + }) + } else if (fromNode.type === 'directory') { + // Path was a directory, now a file -> treat as added file + added.push({ + path, + type: 'added', + newSize: toNode.size, + }) + } else if (fromNode.type === 'file') { + if (hasChanged(fromNode, toNode)) { + modified.push({ + path, + type: 'modified', + oldSize: fromNode.size, + newSize: toNode.size, + }) + } + } + } + + // Find removed files + for (const [path, fromNode] of fromFiles) { + if (fromNode.type === 'directory') continue + + if (added.length + removed.length + modified.length >= MAX_FILES_COMPARE) { + truncated = true + break + } + + if (!toFiles.has(path)) { + removed.push({ + path, + type: 'removed', + oldSize: fromNode.size, + }) + } + } + + // Sort by path + added.sort((a, b) => a.path.localeCompare(b.path)) + removed.sort((a, b) => a.path.localeCompare(b.path)) + modified.sort((a, b) => a.path.localeCompare(b.path)) + + return { added, removed, modified, truncated } +} + +/** Compare dependencies between two package.json files */ +export function compareDependencies( + fromPkg: Record | null, + toPkg: Record | null, +): DependencyChange[] { + const changes: DependencyChange[] = [] + const sections = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ] as const + + for (const section of sections) { + const fromDeps = (fromPkg?.[section] as Record) ?? {} + const toDeps = (toPkg?.[section] as Record) ?? {} + + const allNames = new Set([...Object.keys(fromDeps), ...Object.keys(toDeps)]) + + for (const name of allNames) { + const fromVersion = fromDeps[name] ?? null + const toVersion = toDeps[name] ?? null + + if (fromVersion === toVersion) continue + + let type: 'added' | 'removed' | 'updated' + if (!fromVersion) type = 'added' + else if (!toVersion) type = 'removed' + else type = 'updated' + + let semverDiffType: DependencyChange['semverDiff'] = null + if (type === 'updated' && fromVersion && toVersion) { + // Try to compute semver diff + try { + // Strip ^ ~ >= etc for comparison + const cleanFrom = fromVersion.replace(/^[\^~>=<]+/, '') + const cleanTo = toVersion.replace(/^[\^~>=<]+/, '') + const diffResult = semverDiff(cleanFrom, cleanTo) + if (diffResult) { + if ( + diffResult === 'premajor' || + diffResult === 'preminor' || + diffResult === 'prepatch' + ) { + semverDiffType = 'prerelease' + } else if (['major', 'minor', 'patch', 'prerelease'].includes(diffResult)) { + semverDiffType = diffResult as 'major' | 'minor' | 'patch' | 'prerelease' + } + } + } catch { + // Invalid semver, ignore + } + } + + changes.push({ + name, + section, + from: fromVersion, + to: toVersion, + type, + semverDiff: semverDiffType, + }) + } + } + + // Sort: by section, then by name + changes.sort((a, b) => { + if (a.section !== b.section) { + return sections.indexOf(a.section) - sections.indexOf(b.section) + } + return a.name.localeCompare(b.name) + }) + + return changes +} + +/** Count total files in a tree */ +export function countFiles(tree: PackageFileTree[]): number { + let count = 0 + + function traverse(nodes: PackageFileTree[]) { + for (const node of nodes) { + if (node.type === 'file') count++ + if (node.children) traverse(node.children) + } + } + + traverse(tree) + return count +} + +/** Build the full compare response */ +export function buildCompareResponse( + packageName: string, + from: string, + to: string, + fromTree: PackageFileTree[], + toTree: PackageFileTree[], + fromPkg: Record | null, + toPkg: Record | null, + computeTime: number, +): CompareResponse { + const fileChanges = compareFileTrees(fromTree, toTree) + const dependencyChanges = compareDependencies(fromPkg, toPkg) + + const warnings: string[] = [] + if (fileChanges.truncated) { + warnings.push(`File list truncated to ${MAX_FILES_COMPARE} files`) + } + + return { + package: packageName, + from, + to, + packageJson: { + from: fromPkg, + to: toPkg, + }, + files: { + added: fileChanges.added, + removed: fileChanges.removed, + modified: fileChanges.modified, + }, + dependencyChanges, + stats: { + totalFilesFrom: countFiles(fromTree), + totalFilesTo: countFiles(toTree), + filesAdded: fileChanges.added.length, + filesRemoved: fileChanges.removed.length, + filesModified: fileChanges.modified.length, + }, + meta: { + truncated: fileChanges.truncated, + warnings: warnings.length > 0 ? warnings : undefined, + computeTime, + }, + } +} diff --git a/server/utils/diff.ts b/server/utils/diff.ts new file mode 100644 index 00000000..45e695a4 --- /dev/null +++ b/server/utils/diff.ts @@ -0,0 +1 @@ +export * from '../../shared/utils/diff' diff --git a/server/utils/file-tree.ts b/server/utils/file-tree.ts index 9d2556e0..2f2fe599 100644 --- a/server/utils/file-tree.ts +++ b/server/utils/file-tree.ts @@ -50,6 +50,7 @@ export function convertToFileTree( name: node.name, path, type: 'file', + hash: node.hash, size: node.size, }) } diff --git a/shared/schemas/package.ts b/shared/schemas/package.ts index 49543ece..bc4b10af 100644 --- a/shared/schemas/package.ts +++ b/shared/schemas/package.ts @@ -70,6 +70,25 @@ export const PackageFileQuerySchema = v.object({ filePath: FilePathSchema, }) +/** + * Schema for version comparison (from...to range) + */ +export const PackageCompareQuerySchema = v.object({ + packageName: PackageNameSchema, + fromVersion: VersionSchema, + toVersion: VersionSchema, +}) + +/** + * Schema for file diff between versions + */ +export const PackageFileDiffQuerySchema = v.object({ + packageName: PackageNameSchema, + fromVersion: VersionSchema, + toVersion: VersionSchema, + filePath: FilePathSchema, +}) + /** * Automatically infer types for routes * Usage - prefer this over manually defining interfaces @@ -80,3 +99,7 @@ export type PackageRouteParams = v.InferOutput export type PackageVersionQuery = v.InferOutput /** @public */ export type PackageFileQuery = v.InferOutput +/** @public */ +export type PackageCompareQuery = v.InferOutput +/** @public */ +export type PackageFileDiffQuery = v.InferOutput diff --git a/shared/types/compare.ts b/shared/types/compare.ts new file mode 100644 index 00000000..9d0d3ea5 --- /dev/null +++ b/shared/types/compare.ts @@ -0,0 +1,153 @@ +/** A change in a dependency between versions */ +export interface DependencyChange { + /** Package name */ + name: string + /** Which dependency section it belongs to */ + section: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' + /** Version in the "from" version (null if newly added) */ + from: string | null + /** Version in the "to" version (null if removed) */ + to: string | null + /** Type of change */ + type: 'added' | 'removed' | 'updated' + /** Best-effort semver diff type */ + semverDiff?: 'major' | 'minor' | 'patch' | 'prerelease' | null +} + +/** File change info in the comparison */ +export interface FileChange { + /** File path */ + path: string + /** Type of change */ + type: 'added' | 'removed' | 'modified' + /** Old file size (for removed/modified) */ + oldSize?: number + /** New file size (for added/modified) */ + newSize?: number +} + +/** Comparison summary response from the API */ +export interface CompareResponse { + /** Package name */ + package: string + /** Source version */ + from: string + /** Target version */ + to: string + /** package.json content for both versions */ + packageJson: { + from: Record | null + to: Record | null + } + /** File changes between versions */ + files: { + added: FileChange[] + removed: FileChange[] + modified: FileChange[] + } + /** Dependency changes */ + dependencyChanges: DependencyChange[] + /** Stats summary */ + stats: { + totalFilesFrom: number + totalFilesTo: number + filesAdded: number + filesRemoved: number + filesModified: number + } + /** Metadata about the comparison */ + meta: { + /** Whether file list was truncated due to size */ + truncated?: boolean + /** Any warnings during comparison */ + warnings?: string[] + /** Time taken to compute (ms) */ + computeTime?: number + } +} + +/** A line segment in a diff (for inline word-level diffs) */ +export interface DiffLineSegment { + value: string + type: 'insert' | 'delete' | 'normal' +} + +/** A single line in the diff */ +export interface DiffLine { + /** Line type */ + type: 'insert' | 'delete' | 'normal' + /** Old line number (for normal/delete) */ + oldLineNumber?: number + /** New line number (for normal/insert) */ + newLineNumber?: number + /** Line number (for insert/delete) */ + lineNumber?: number + /** Line content segments */ + content: DiffLineSegment[] +} + +/** A hunk in the diff */ +export interface DiffHunk { + type: 'hunk' + /** Original hunk header content */ + content: string + /** Old file start line */ + oldStart: number + /** Number of lines in old file */ + oldLines: number + /** New file start line */ + newStart: number + /** Number of lines in new file */ + newLines: number + /** Lines in this hunk */ + lines: DiffLine[] +} + +/** A skip block (collapsed unchanged lines) */ +export interface DiffSkipBlock { + type: 'skip' + /** Number of lines skipped */ + count: number + /** Context message */ + content: string +} + +/** Parsed file diff */ +export interface FileDiff { + /** Old file path */ + oldPath: string + /** New file path */ + newPath: string + /** File change type */ + type: 'add' | 'delete' | 'modify' + /** Hunks in the diff */ + hunks: (DiffHunk | DiffSkipBlock)[] +} + +/** Per-file diff response from the API */ +export interface FileDiffResponse { + /** Package name */ + package: string + /** Source version */ + from: string + /** Target version */ + to: string + /** File path */ + path: string + /** File change type */ + type: 'add' | 'delete' | 'modify' + /** Parsed diff hunks */ + hunks: (DiffHunk | DiffSkipBlock)[] + /** Diff stats */ + stats: { + additions: number + deletions: number + } + /** Metadata */ + meta: { + /** Whether diff was truncated */ + truncated?: boolean + /** Time taken to compute (ms) */ + computeTime?: number + } +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 1bb0a8a1..ab2d41d6 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -5,3 +5,4 @@ export * from './readme' export * from './docs' export * from './deno-doc' export * from './i18n-status' +export * from './compare' diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index a29019f3..a095fa62 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -300,6 +300,8 @@ export interface PackageFileTree { path: string /** Node type */ type: 'file' | 'directory' + /** File hash (only for files) */ + hash?: string /** File size in bytes (only for files) */ size?: number /** Child nodes (only for directories) */ diff --git a/shared/utils/diff.ts b/shared/utils/diff.ts new file mode 100644 index 00000000..e409f613 --- /dev/null +++ b/shared/utils/diff.ts @@ -0,0 +1,383 @@ +import { createTwoFilesPatch } from 'diff' +import type { + DiffLine, + DiffLineSegment, + DiffHunk, + DiffSkipBlock, + FileDiff, +} from '#shared/types/compare' + +/** Options for parsing diffs */ +export interface ParseOptions { + maxDiffDistance: number + maxChangeRatio: number + mergeModifiedLines: boolean + inlineMaxCharEdits: number +} + +const defaultOptions: ParseOptions = { + maxDiffDistance: 30, + maxChangeRatio: 0.45, + mergeModifiedLines: true, + inlineMaxCharEdits: 2, +} + +interface RawChange { + type: 'insert' | 'delete' | 'normal' + content: string + lineNumber?: number + oldLineNumber?: number + newLineNumber?: number + isNormal?: boolean +} + +function calculateChangeRatio(a: string, b: string): number { + const totalChars = a.length + b.length + if (totalChars === 0) return 1 + let changedChars = 0 + const maxLen = Math.max(a.length, b.length) + for (let i = 0; i < maxLen; i++) { + if (a[i] !== b[i]) changedChars++ + } + changedChars += Math.abs(a.length - b.length) + return changedChars / totalChars +} + +function isSimilarEnough(a: string, b: string, maxChangeRatio: number): boolean { + if (maxChangeRatio <= 0) return a === b + if (maxChangeRatio >= 1) return true + return calculateChangeRatio(a, b) <= maxChangeRatio +} + +function buildInlineDiffSegments( + oldContent: string, + newContent: string, + _options: ParseOptions, +): DiffLineSegment[] { + const oldWords = oldContent.split(/(\s+)/) + const newWords = newContent.split(/(\s+)/) + + const segments: DiffLineSegment[] = [] + let oi = 0 + let ni = 0 + + while (oi < oldWords.length || ni < newWords.length) { + if (oi >= oldWords.length) { + segments.push({ value: newWords.slice(ni).join(''), type: 'insert' }) + break + } + if (ni >= newWords.length) { + segments.push({ value: oldWords.slice(oi).join(''), type: 'delete' }) + break + } + + if (oldWords[oi] === newWords[ni]) { + const last = segments[segments.length - 1] + if (last?.type === 'normal') { + last.value += oldWords[oi]! + } else { + segments.push({ value: oldWords[oi]!, type: 'normal' }) + } + oi++ + ni++ + } else { + let foundSync = false + + for (let look = 1; look <= 3 && ni + look < newWords.length; look++) { + if (newWords[ni + look] === oldWords[oi]) { + segments.push({ value: newWords.slice(ni, ni + look).join(''), type: 'insert' }) + ni += look + foundSync = true + break + } + } + + if (!foundSync) { + for (let look = 1; look <= 3 && oi + look < oldWords.length; look++) { + if (oldWords[oi + look] === newWords[ni]) { + segments.push({ value: oldWords.slice(oi, oi + look).join(''), type: 'delete' }) + oi += look + foundSync = true + break + } + } + } + + if (!foundSync) { + segments.push({ value: oldWords[oi]!, type: 'delete' }) + segments.push({ value: newWords[ni]!, type: 'insert' }) + oi++ + ni++ + } + } + } + + const merged: DiffLineSegment[] = [] + for (const seg of segments) { + const last = merged[merged.length - 1] + if (last?.type === seg.type) { + last.value += seg.value + } else { + merged.push({ ...seg }) + } + } + + return merged +} + +function changeToLine(change: RawChange): DiffLine { + return { + type: change.type, + oldLineNumber: change.oldLineNumber, + newLineNumber: change.newLineNumber, + lineNumber: change.lineNumber, + content: [{ value: change.content, type: 'normal' }], + } +} + +function mergeAdjacentLines(changes: RawChange[], options: ParseOptions): DiffLine[] { + const out: DiffLine[] = [] + + for (let i = 0; i < changes.length; i++) { + const current = changes[i]! + const next = changes[i + 1] + + if ( + next && + current.type === 'delete' && + next.type === 'insert' && + isSimilarEnough(current.content, next.content, options.maxChangeRatio) + ) { + out.push({ + type: 'normal', + oldLineNumber: current.lineNumber, + newLineNumber: next.lineNumber, + content: buildInlineDiffSegments(current.content, next.content, options), + }) + i++ + } else { + out.push(changeToLine(current)) + } + } + + return out +} + +export function parseUnifiedDiff( + diffText: string, + options: Partial = {}, +): FileDiff[] { + const opts = { ...defaultOptions, ...options } + const files: FileDiff[] = [] + + const lines = diffText.split('\n') + let currentFile: FileDiff | null = null + let currentHunk: { + changes: RawChange[] + oldStart: number + oldLines: number + newStart: number + newLines: number + content: string + } | null = null + let oldLine = 0 + let newLine = 0 + + for (const line of lines) { + if (line.startsWith('---')) { + if (currentHunk && currentFile) { + const hunk = processHunk(currentHunk, opts) + currentFile.hunks.push(hunk) + } + currentHunk = null + continue + } + + if (line.startsWith('+++')) { + const match = line.match(/^\+\+\+ (?:b\/)?(.*)/) + const path = match?.[1] ?? '' + + if (currentFile && currentHunk) { + const hunk = processHunk(currentHunk, opts) + currentFile.hunks.push(hunk) + files.push(currentFile) + } else if (currentFile) { + files.push(currentFile) + } + + currentFile = { + oldPath: path, + newPath: path, + type: 'modify', + hunks: [], + } + currentHunk = null + continue + } + + const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/) + if (hunkMatch) { + if (currentHunk && currentFile) { + const hunk = processHunk(currentHunk, opts) + currentFile.hunks.push(hunk) + } + + oldLine = parseInt(hunkMatch[1]!, 10) + newLine = parseInt(hunkMatch[3]!, 10) + + currentHunk = { + changes: [], + oldStart: oldLine, + oldLines: parseInt(hunkMatch[2] ?? '1', 10), + newStart: newLine, + newLines: parseInt(hunkMatch[4] ?? '1', 10), + content: line, + } + continue + } + + if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('\\')) { + continue + } + + if (currentHunk) { + if (line.startsWith('+')) { + currentHunk.changes.push({ + type: 'insert', + content: line.slice(1), + lineNumber: newLine, + newLineNumber: newLine, + }) + newLine++ + } else if (line.startsWith('-')) { + currentHunk.changes.push({ + type: 'delete', + content: line.slice(1), + lineNumber: oldLine, + oldLineNumber: oldLine, + }) + oldLine++ + } else if (line.startsWith(' ') || line === '') { + currentHunk.changes.push({ + type: 'normal', + content: line.slice(1) || '', + oldLineNumber: oldLine, + newLineNumber: newLine, + isNormal: true, + }) + oldLine++ + newLine++ + } + } + } + + if (currentHunk && currentFile) { + const hunk = processHunk(currentHunk, opts) + currentFile.hunks.push(hunk) + } + if (currentFile) { + files.push(currentFile) + } + + for (const file of files) { + const hasAdds = file.hunks.some( + h => h.type === 'hunk' && h.lines.some(l => l.type === 'insert'), + ) + const hasDels = file.hunks.some( + h => h.type === 'hunk' && h.lines.some(l => l.type === 'delete'), + ) + + if (hasAdds && !hasDels) file.type = 'add' + else if (hasDels && !hasAdds) file.type = 'delete' + else file.type = 'modify' + } + + return files +} + +function processHunk( + raw: { + changes: RawChange[] + oldStart: number + oldLines: number + newStart: number + newLines: number + content: string + }, + options: ParseOptions, +): DiffHunk { + const lines = options.mergeModifiedLines + ? mergeAdjacentLines(raw.changes, options) + : raw.changes.map(changeToLine) + + return { + type: 'hunk', + content: raw.content, + oldStart: raw.oldStart, + oldLines: raw.oldLines, + newStart: raw.newStart, + newLines: raw.newLines, + lines, + } +} + +export function insertSkipBlocks(hunks: DiffHunk[]): (DiffHunk | DiffSkipBlock)[] { + const result: (DiffHunk | DiffSkipBlock)[] = [] + let lastHunkLine = 1 + + for (const hunk of hunks) { + const distanceToLastHunk = hunk.oldStart - lastHunkLine + + if (distanceToLastHunk > 0) { + result.push({ + type: 'skip', + count: distanceToLastHunk, + content: `${distanceToLastHunk} lines hidden`, + }) + } + + lastHunkLine = Math.max(hunk.oldStart + hunk.oldLines, lastHunkLine) + result.push(hunk) + } + + return result +} + +export function createDiff( + oldContent: string, + newContent: string, + filePath: string, + options: Partial = {}, +): FileDiff | null { + const diffText = createTwoFilesPatch( + `a/${filePath}`, + `b/${filePath}`, + oldContent, + newContent, + '', + '', + { context: 3 }, + ) + + const files = parseUnifiedDiff(diffText, options) + return files[0] ?? null +} + +export function countDiffStats(hunks: (DiffHunk | DiffSkipBlock)[]): { + additions: number + deletions: number +} { + let additions = 0 + let deletions = 0 + + for (const hunk of hunks) { + if (hunk.type === 'hunk') { + for (const line of hunk.lines) { + if (line.type === 'insert') additions++ + else if (line.type === 'delete') deletions++ + } + } + } + + return { additions, deletions } +}