diff --git a/app/components/Button/Base.stories.ts b/app/components/Button/Base.stories.ts index 520d5825b..9de074437 100644 --- a/app/components/Button/Base.stories.ts +++ b/app/components/Button/Base.stories.ts @@ -39,7 +39,7 @@ export const Disabled: Story = { export const WithIcon: Story = { args: { default: 'Search', - classicon: 'i-carbon:search', + classicon: 'i-lucide:search', variant: 'secondary', }, } diff --git a/app/components/diff/FileTree.vue b/app/components/diff/FileTree.vue new file mode 100644 index 000000000..241200870 --- /dev/null +++ b/app/components/diff/FileTree.vue @@ -0,0 +1,181 @@ + + + diff --git a/app/components/diff/Hunk.vue b/app/components/diff/Hunk.vue new file mode 100644 index 000000000..741689f01 --- /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 000000000..032519731 --- /dev/null +++ b/app/components/diff/Line.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/app/components/diff/MobileSidebarDrawer.vue b/app/components/diff/MobileSidebarDrawer.vue new file mode 100644 index 000000000..9000399d8 --- /dev/null +++ b/app/components/diff/MobileSidebarDrawer.vue @@ -0,0 +1,89 @@ + + + diff --git a/app/components/diff/SidebarPanel.vue b/app/components/diff/SidebarPanel.vue new file mode 100644 index 000000000..806cae531 --- /dev/null +++ b/app/components/diff/SidebarPanel.vue @@ -0,0 +1,259 @@ + + + diff --git a/app/components/diff/SkipBlock.vue b/app/components/diff/SkipBlock.vue new file mode 100644 index 000000000..f5169dffc --- /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 000000000..2bbda05a4 --- /dev/null +++ b/app/components/diff/Table.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/components/diff/ViewerPanel.vue b/app/components/diff/ViewerPanel.vue new file mode 100644 index 000000000..9996f301e --- /dev/null +++ b/app/components/diff/ViewerPanel.vue @@ -0,0 +1,493 @@ + + + + + diff --git a/app/pages/diff/[[org]]/[packageName]/v/[versionRange].vue b/app/pages/diff/[[org]]/[packageName]/v/[versionRange].vue new file mode 100644 index 000000000..538dbeebd --- /dev/null +++ b/app/pages/diff/[[org]]/[packageName]/v/[versionRange].vue @@ -0,0 +1,260 @@ + + + diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 1553b0f04..d0e5474e0 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -875,6 +875,16 @@ const showSkeleton = shallowRef(false) > {{ $t('package.links.compare') }} + + {{ $t('compare.compare_versions') }} + + +
  • + + {{ $t('package.links.docs') }} + +
  • +
  • + + {{ $t('package.links.code') }} + +
  • +
  • + + {{ $t('package.links.compare') }} + +
  • +
  • + + +
  • diff --git a/app/types/icon.ts b/app/types/icon.ts index 8221b2d43..d319ec2a9 100644 --- a/app/types/icon.ts +++ b/app/types/icon.ts @@ -1,5 +1,4 @@ export type IconClass = - | `i-carbon:${string}` | `i-lucide:${string}` | `i-simple-icons:${string}` | `i-svg-spinners:${string}` diff --git a/app/utils/router.ts b/app/utils/router.ts index 3420180fa..1b8ce5d40 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -22,3 +22,20 @@ export function packageRoute(packageName: string, version?: string | null): Rout }, } } + +export function diffRoute( + packageName: string, + fromVersion: string, + toVersion: string, +): RouteLocationRaw { + const [org, name = ''] = packageName.startsWith('@') ? packageName.split('/') : ['', packageName] + + return { + name: 'diff', + params: { + org: org || undefined, + packageName: name, + versionRange: `${fromVersion}...${toVersion}`, + }, + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 2a53cf33f..4c652248a 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1057,7 +1057,37 @@ "trends": { "title": "Compare Trends" } - } + }, + "file_changes": "File Changes", + "files_count": "{count} files", + "lines_hidden": "{count} lines hidden", + "compare_versions": "diff", + "summary": "Summary", + "deps_count": "{count} deps", + "dependencies": "Dependencies", + "dev_dependencies": "Dev Dependencies", + "peer_dependencies": "Peer Dependencies", + "optional_dependencies": "Optional Dependencies", + "no_dependency_changes": "No dependency changes", + "file_filter_option": { + "all": "All ({count})", + "added": "Added ({count})", + "removed": "Removed ({count})", + "modified": "Modified ({count})" + }, + "search_files_placeholder": "Search files...", + "no_files_all": "No files", + "no_files_search": "No files matching \"{query}\"", + "no_files_filtered": "No {filter} files", + "filter": { + "added": "added", + "removed": "removed", + "modified": "modified" + }, + "files_button": "Files", + "select_file_prompt": "Select a file from the sidebar to view its diff", + "close_files_panel": "Close files panel", + "filter_files_label": "Filter files by change type" }, "privacy_policy": { "title": "privacy policy", diff --git a/i18n/schema.json b/i18n/schema.json index 911d445c5..5ce1bf37b 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -3177,6 +3177,96 @@ } }, "additionalProperties": false + }, + "file_changes": { + "type": "string" + }, + "files_count": { + "type": "string" + }, + "lines_hidden": { + "type": "string" + }, + "compare_versions": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "deps_count": { + "type": "string" + }, + "dependencies": { + "type": "string" + }, + "dev_dependencies": { + "type": "string" + }, + "peer_dependencies": { + "type": "string" + }, + "optional_dependencies": { + "type": "string" + }, + "no_dependency_changes": { + "type": "string" + }, + "file_filter_option": { + "type": "object", + "properties": { + "all": { + "type": "string" + }, + "added": { + "type": "string" + }, + "removed": { + "type": "string" + }, + "modified": { + "type": "string" + } + }, + "additionalProperties": false + }, + "search_files_placeholder": { + "type": "string" + }, + "no_files_all": { + "type": "string" + }, + "no_files_search": { + "type": "string" + }, + "no_files_filtered": { + "type": "string" + }, + "filter": { + "type": "object", + "properties": { + "added": { + "type": "string" + }, + "removed": { + "type": "string" + }, + "modified": { + "type": "string" + } + }, + "additionalProperties": false + }, + "files_button": { + "type": "string" + }, + "select_file_prompt": { + "type": "string" + }, + "close_files_panel": { + "type": "string" + }, + "filter_files_label": { + "type": "string" } }, "additionalProperties": false diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index bd944ac87..0b1ecf9c6 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -1056,7 +1056,37 @@ "trends": { "title": "Compare Trends" } - } + }, + "file_changes": "File Changes", + "files_count": "{count} files", + "lines_hidden": "{count} lines hidden", + "compare_versions": "diff", + "summary": "Summary", + "deps_count": "{count} deps", + "dependencies": "Dependencies", + "dev_dependencies": "Dev Dependencies", + "peer_dependencies": "Peer Dependencies", + "optional_dependencies": "Optional Dependencies", + "no_dependency_changes": "No dependency changes", + "file_filter_option": { + "all": "All ({count})", + "added": "Added ({count})", + "removed": "Removed ({count})", + "modified": "Modified ({count})" + }, + "search_files_placeholder": "Search files...", + "no_files_all": "No files", + "no_files_search": "No files matching \"{query}\"", + "no_files_filtered": "No {filter} files", + "filter": { + "added": "added", + "removed": "removed", + "modified": "modified" + }, + "files_button": "Files", + "select_file_prompt": "Select a file from the sidebar to view its diff", + "close_files_panel": "Close files panel", + "filter_files_label": "Filter files by change type" }, "privacy_policy": { "title": "privacy policy", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 13a074d6a..359cd5e2a 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -1056,7 +1056,37 @@ "trends": { "title": "Compare Trends" } - } + }, + "file_changes": "File Changes", + "files_count": "{count} files", + "lines_hidden": "{count} lines hidden", + "compare_versions": "diff", + "summary": "Summary", + "deps_count": "{count} deps", + "dependencies": "Dependencies", + "dev_dependencies": "Dev Dependencies", + "peer_dependencies": "Peer Dependencies", + "optional_dependencies": "Optional Dependencies", + "no_dependency_changes": "No dependency changes", + "file_filter_option": { + "all": "All ({count})", + "added": "Added ({count})", + "removed": "Removed ({count})", + "modified": "Modified ({count})" + }, + "search_files_placeholder": "Search files...", + "no_files_all": "No files", + "no_files_search": "No files matching \"{query}\"", + "no_files_filtered": "No {filter} files", + "filter": { + "added": "added", + "removed": "removed", + "modified": "modified" + }, + "files_button": "Files", + "select_file_prompt": "Select a file from the sidebar to view its diff", + "close_files_panel": "Close files panel", + "filter_files_label": "Filter files by change type" }, "privacy_policy": { "title": "privacy policy", diff --git a/package.json b/package.json index 02eca8ab1..133ba75a5 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@vueuse/shared": "14.2.1", "algoliasearch": "5.49.0", "defu": "6.1.4", + "diff": "^8.0.3", "fast-npm-meta": "1.2.1", "focus-trap": "^8.0.0", "gray-matter": "4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74ad50cd7..74c23b6e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: defu: specifier: 6.1.4 version: 6.1.4 + diff: + specifier: ^8.0.3 + version: 8.0.3 fast-npm-meta: specifier: 1.2.1 version: 1.2.1 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 000000000..71312422c --- /dev/null +++ b/server/api/registry/compare-file/[...pkg].get.ts @@ -0,0 +1,264 @@ +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' +import { parseVersionRange } from '#server/utils/compare' +import { getLanguageFromPath } from '#server/utils/code-highlight' + +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 + +/** + * 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) + + // Syntax-highlight diff segments using server-side Shiki + const language = getLanguageFromPath(filePath) + const shiki = await getShikiHighlighter() + const loadedLangs = shiki.getLoadedLanguages() + const canHighlight = loadedLangs.includes(language as never) + + if (canHighlight) { + for (const hunk of hunksWithSkips) { + if (hunk.type !== 'hunk') continue + for (const line of hunk.lines) { + line.content = line.content.map(seg => { + const code = seg.value.length ? seg.value : ' ' + try { + const raw = shiki.codeToHtml(code, { + lang: language, + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: 'dark', + }) + const html = raw.match(/]*>([\s\S]*?)<\/code>/)?.[1] + return html ? { ...seg, html } : seg + } catch { + return seg + } + }) + } + } + } + + 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) + // Normalize option values to prevent cache pollution from arbitrary floats. + // These match the parsing logic used in the handler body. + const merge = query.mergeModifiedLines !== 'false' + const ratio = Math.round((parseFloat(query.maxChangeRatio as string) || 0.45) * 100) + const distance = parseInt(query.maxDiffDistance as string, 10) || 30 + const charEdits = parseInt(query.inlineMaxCharEdits as string, 10) || 2 + const optionsKey = `${merge}:${ratio}:${distance}:${charEdits}` + 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 000000000..03a34bf23 --- /dev/null +++ b/server/api/registry/compare/[...pkg].get.ts @@ -0,0 +1,123 @@ +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, parseVersionRange } 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 + } +} + +/** + * 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, controller.signal), + getPackageFileTree(packageName, toVersion, controller.signal), + 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 000000000..68ae93b0d --- /dev/null +++ b/server/utils/compare.ts @@ -0,0 +1,273 @@ +import { diff as semverDiff } from 'semver' +import type { PackageFileTree, DependencyChange, FileChange, CompareResponse } from '#shared/types' + +/** + * Parse a version range from a URL segment. + * Supports formats like: "1.0.0...2.0.0" (triple dot, GitHub style) + */ +export function parseVersionRange(versionRange: string): { from: string; to: string } | null { + const parts = versionRange.split('...') + if (parts.length === 2) { + return { from: parts[0]!, to: parts[1]! } + } + + return null +} + +/** Maximum number of files to include in comparison */ +const MAX_FILES_COMPARE = 1000 + +function traverse(nodes: PackageFileTree[], result: Map) { + for (const node of nodes) { + result.set(node.path, node) + if (node.children) { + traverse(node.children, result) + } + } +} + +/** Flatten a file tree into a map of path -> node */ +export function flattenTree(tree: PackageFileTree[]): Map { + const result = new Map() + + traverse(tree, result) + return result +} + +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 +} + +/** 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 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 (overLimit()) { + 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 000000000..45e695a49 --- /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 9d8933f3b..c5a55159d 100644 --- a/server/utils/file-tree.ts +++ b/server/utils/file-tree.ts @@ -12,9 +12,10 @@ import type { export async function fetchFileTree( packageName: string, version: string, + signal?: AbortSignal, ): Promise { const url = `https://data.jsdelivr.com/v1/packages/npm/${packageName}@${version}` - const response = await fetch(url) + const response = await fetch(url, { signal }) if (!response.ok) { if (response.status === 404) { @@ -53,6 +54,7 @@ export function convertToFileTree( name: node.name, path, type: 'file', + hash: node.hash, size: node.size, }) } @@ -76,8 +78,9 @@ export function convertToFileTree( export async function getPackageFileTree( packageName: string, version: string, + signal?: AbortSignal, ): Promise { - const jsDelivrData = await fetchFileTree(packageName, version) + const jsDelivrData = await fetchFileTree(packageName, version, signal) const tree = convertToFileTree(jsDelivrData.files) return { diff --git a/server/utils/npm.ts b/server/utils/npm.ts index 7f61feafc..99d13810c 100644 --- a/server/utils/npm.ts +++ b/server/utils/npm.ts @@ -1,7 +1,7 @@ import type { Packument, NpmSearchResponse } from '#shared/types' import { encodePackageName, fetchLatestVersion } from '#shared/utils/npm' import { maxSatisfying, prerelease } from 'semver' -import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' +import { CACHE_MAX_AGE_FIVE_MINUTES, CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' const NPM_REGISTRY = 'https://registry.npmjs.org' diff --git a/shared/schemas/package.ts b/shared/schemas/package.ts index 7bfde5db3..0bd3e5d18 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 @@ -77,3 +96,7 @@ export const PackageFileQuerySchema = v.object({ export type PackageRouteParams = v.InferOutput export type PackageVersionQuery = v.InferOutput 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 000000000..bce433b87 --- /dev/null +++ b/shared/types/compare.ts @@ -0,0 +1,155 @@ +/** 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' + /** Pre-rendered syntax-highlighted HTML (set by ViewerPanel after diff computation) */ + html?: string +} + +/** 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 be4274d32..cbd472b9d 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -8,5 +8,6 @@ export * from './deno-doc' export * from './i18n-status' export * from './comparison' export * from './skills' +export * from './compare' export * from './version-downloads' export * from './install-size' diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index f7d7cdd12..250a9218e 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -355,6 +355,8 @@ export interface PackageFileTree { path: string /** Node type */ type: 'file' | 'directory' + /** File hash (only for files) */ + hash?: string /** Node size in bytes (file size or recursive directory total) */ size?: number /** Child nodes (only for directories) */ diff --git a/shared/utils/diff.ts b/shared/utils/diff.ts new file mode 100644 index 000000000..aa8541e12 --- /dev/null +++ b/shared/utils/diff.ts @@ -0,0 +1,393 @@ +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 + + // Track old path from --- line to detect /dev/null (new/deleted files) + let lastOldPath = '' + + for (const line of lines) { + if (line.startsWith('---')) { + if (currentHunk && currentFile) { + const hunk = processHunk(currentHunk, opts) + currentFile.hunks.push(hunk) + } + currentHunk = null + const oldMatch = line.match(/^--- (?:a\/)?(.*)/) + lastOldPath = oldMatch?.[1]?.trimEnd() ?? '' + continue + } + + if (line.startsWith('+++')) { + const match = line.match(/^\+\+\+ (?:b\/)?(.*)/) + const path = match?.[1]?.trimEnd() ?? '' + + if (currentFile && currentHunk) { + const hunk = processHunk(currentHunk, opts) + currentFile.hunks.push(hunk) + files.push(currentFile) + } else if (currentFile) { + files.push(currentFile) + } + + // Determine file type from --- / +++ paths: + // /dev/null in the old path means a new file; in the new path means deleted + let fileType: FileDiff['type'] = 'modify' + if (lastOldPath === '/dev/null') fileType = 'add' + else if (path === '/dev/null') fileType = 'delete' + + currentFile = { + oldPath: lastOldPath === '/dev/null' ? path : lastOldPath || path, + newPath: path, + type: fileType, + hunks: [], + } + currentHunk = null + lastOldPath = '' + 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) + } + + 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++ + else if (line.type === 'normal') { + // Merged modified lines have type 'normal' but contain inline + // insert/delete segments. Count them as both an addition and deletion. + const hasInlineChanges = line.content.some( + seg => seg.type === 'insert' || seg.type === 'delete', + ) + if (hasInlineChanges) { + additions++ + deletions++ + } + } + } + } + } + + return { additions, deletions } +} diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index b12fe604a..c797b2577 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -58,6 +58,11 @@ const allowedWarnings: RegExp[] = [ // vue-i18n logs this when is used outside a component-scoped i18n; // it falls back to the global scope and still renders correctly. /\[intlify\] Not found parent scope/, + // mountSuspended wraps each component instance and calls expose() after + // setup. For recursive components (e.g. DiffFileTree rendering child + // DiffFileTree instances), this triggers a duplicate expose() call on the + // inner wrapper. The warning does not affect test correctness. + /expose\(\) should be called only once/, ] beforeEach(() => { @@ -195,6 +200,14 @@ import { UserAvatar, VersionSelector, ViewModeToggle, + DiffFileTree, + DiffHunk, + DiffLine, + DiffMobileSidebarDrawer, + DiffSidebarPanel, + DiffSkipBlock, + DiffTable, + DiffViewerPanel, } from '#components' // Server variant components must be imported directly to test the server-side render @@ -2617,6 +2630,629 @@ describe('component accessibility audits', () => { }) }) + // Diff components + describe('DiffFileTree', () => { + const mockFiles = [ + { path: 'src/index.ts', type: 'modified' as const }, + { path: 'src/utils/helper.ts', type: 'added' as const }, + { path: 'README.md', type: 'modified' as const }, + { path: 'old-file.js', type: 'removed' as const }, + ] + + it('should have no accessibility violations with files', async () => { + const component = await mountSuspended(DiffFileTree, { + props: { + files: mockFiles, + selectedPath: null, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with selected file', async () => { + const component = await mountSuspended(DiffFileTree, { + props: { + files: mockFiles, + selectedPath: 'src/index.ts', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with empty files', async () => { + const component = await mountSuspended(DiffFileTree, { + props: { + files: [], + selectedPath: null, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DiffLine', () => { + it('should have no accessibility violations for normal line', async () => { + const component = await mountSuspended(DiffLine, { + props: { + line: { + type: 'normal', + oldLineNumber: 1, + newLineNumber: 1, + content: [{ value: 'const x = 1;', type: 'normal' }], + }, + }, + global: { + provide: { + diffContext: { + fileStatus: computed(() => 'modify'), + language: computed(() => 'typescript'), + enableShiki: computed(() => false), + wordWrap: computed(() => false), + }, + }, + }, + attachTo: (() => { + const table = document.createElement('table') + const tbody = document.createElement('tbody') + table.appendChild(tbody) + document.body.appendChild(table) + return tbody + })(), + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for insert line', async () => { + const component = await mountSuspended(DiffLine, { + props: { + line: { + type: 'insert', + newLineNumber: 5, + content: [{ value: 'const newVar = true;', type: 'insert' }], + }, + }, + global: { + provide: { + diffContext: { + fileStatus: computed(() => 'modify'), + language: computed(() => 'typescript'), + enableShiki: computed(() => false), + wordWrap: computed(() => false), + }, + }, + }, + attachTo: (() => { + const table = document.createElement('table') + const tbody = document.createElement('tbody') + table.appendChild(tbody) + document.body.appendChild(table) + return tbody + })(), + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for delete line', async () => { + const component = await mountSuspended(DiffLine, { + props: { + line: { + type: 'delete', + oldLineNumber: 3, + content: [{ value: 'const oldVar = false;', type: 'delete' }], + }, + }, + global: { + provide: { + diffContext: { + fileStatus: computed(() => 'modify'), + language: computed(() => 'typescript'), + enableShiki: computed(() => false), + wordWrap: computed(() => false), + }, + }, + }, + attachTo: (() => { + const table = document.createElement('table') + const tbody = document.createElement('tbody') + table.appendChild(tbody) + document.body.appendChild(table) + return tbody + })(), + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with word-level diff segments', async () => { + const component = await mountSuspended(DiffLine, { + props: { + line: { + type: 'insert', + newLineNumber: 10, + content: [ + { value: 'const ', type: 'normal' }, + { value: 'newName', type: 'insert' }, + { value: ' = 1;', type: 'normal' }, + ], + }, + }, + global: { + provide: { + diffContext: { + fileStatus: computed(() => 'modify'), + language: computed(() => 'typescript'), + enableShiki: computed(() => false), + wordWrap: computed(() => false), + }, + }, + }, + attachTo: (() => { + const table = document.createElement('table') + const tbody = document.createElement('tbody') + table.appendChild(tbody) + document.body.appendChild(table) + return tbody + })(), + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DiffHunk', () => { + const mockHunk = { + type: 'hunk' as const, + content: '@@ -1,5 +1,6 @@', + oldStart: 1, + oldLines: 5, + newStart: 1, + newLines: 6, + lines: [ + { + type: 'normal' as const, + oldLineNumber: 1, + newLineNumber: 1, + content: [{ value: 'const a = 1;', type: 'normal' as const }], + }, + { + type: 'delete' as const, + oldLineNumber: 2, + content: [{ value: 'const b = 2;', type: 'delete' as const }], + }, + { + type: 'insert' as const, + newLineNumber: 2, + content: [{ value: 'const b = 3;', type: 'insert' as const }], + }, + ], + } + + it('should have no accessibility violations', async () => { + const component = await mountSuspended(DiffHunk, { + props: { hunk: mockHunk }, + global: { + provide: { + diffContext: { + fileStatus: computed(() => 'modify'), + language: computed(() => 'typescript'), + enableShiki: computed(() => false), + wordWrap: computed(() => false), + }, + }, + }, + attachTo: (() => { + const table = document.createElement('table') + const tbody = document.createElement('tbody') + table.appendChild(tbody) + document.body.appendChild(table) + return tbody + })(), + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DiffSkipBlock', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(DiffSkipBlock, { + props: { + count: 25, + }, + attachTo: (() => { + const table = document.createElement('table') + const tbody = document.createElement('tbody') + table.appendChild(tbody) + document.body.appendChild(table) + return tbody + })(), + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with custom content', async () => { + const component = await mountSuspended(DiffSkipBlock, { + props: { + count: 50, + content: '50 unchanged lines', + }, + attachTo: (() => { + const table = document.createElement('table') + const tbody = document.createElement('tbody') + table.appendChild(tbody) + document.body.appendChild(table) + return tbody + })(), + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DiffTable', () => { + const mockHunks = [ + { + type: 'hunk' as const, + content: '@@ -1,3 +1,4 @@', + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 4, + lines: [ + { + type: 'normal' as const, + oldLineNumber: 1, + newLineNumber: 1, + content: [{ value: 'line 1', type: 'normal' as const }], + }, + { + type: 'insert' as const, + newLineNumber: 2, + content: [{ value: 'new line', type: 'insert' as const }], + }, + ], + }, + ] + + it('should have no accessibility violations for modify type', async () => { + const component = await mountSuspended(DiffTable, { + props: { + hunks: mockHunks, + type: 'modify', + fileName: 'test.ts', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for add type', async () => { + const component = await mountSuspended(DiffTable, { + props: { + hunks: mockHunks, + type: 'add', + fileName: 'new-file.ts', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for delete type', async () => { + const component = await mountSuspended(DiffTable, { + props: { + hunks: mockHunks, + type: 'delete', + fileName: 'removed.ts', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with skip blocks', async () => { + const hunksWithSkip = [ + ...mockHunks, + { type: 'skip' as const, count: 20, content: '20 lines hidden' }, + { + type: 'hunk' as const, + content: '@@ -25,3 +26,3 @@', + oldStart: 25, + oldLines: 3, + newStart: 26, + newLines: 3, + lines: [ + { + type: 'normal' as const, + oldLineNumber: 25, + newLineNumber: 26, + content: [{ value: 'line 25', type: 'normal' as const }], + }, + ], + }, + ] + const component = await mountSuspended(DiffTable, { + props: { + hunks: hunksWithSkip, + type: 'modify', + fileName: 'large-file.ts', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with word wrap enabled', async () => { + const component = await mountSuspended(DiffTable, { + props: { + hunks: mockHunks, + type: 'modify', + fileName: 'test.ts', + wordWrap: true, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with empty hunks', async () => { + const component = await mountSuspended(DiffTable, { + props: { + hunks: [], + type: 'modify', + fileName: 'empty.ts', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DiffSidebarPanel', () => { + const mockCompare = { + package: 'test-package', + from: '1.0.0', + to: '2.0.0', + packageJson: { from: {}, to: {} }, + files: { + added: [{ path: 'new.ts', type: 'added' as const, newSize: 100 }], + removed: [{ path: 'old.ts', type: 'removed' as const, oldSize: 50 }], + modified: [{ path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }], + }, + dependencyChanges: [ + { + name: 'lodash', + section: 'dependencies' as const, + from: '^4.0.0', + to: '^4.1.0', + type: 'updated' as const, + semverDiff: 'minor' as const, + }, + ], + stats: { + totalFilesFrom: 10, + totalFilesTo: 11, + filesAdded: 1, + filesRemoved: 1, + filesModified: 1, + }, + meta: {}, + } + + const mockAllChanges = [ + { path: 'new.ts', type: 'added' as const, newSize: 100 }, + { path: 'old.ts', type: 'removed' as const, oldSize: 50 }, + { path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }, + ] + + const mockGroupedDeps = new Map([ + [ + 'dependencies', + [ + { + name: 'lodash', + section: 'dependencies' as const, + from: '^4.0.0', + to: '^4.1.0', + type: 'updated' as const, + semverDiff: 'minor' as const, + }, + ], + ], + ]) + + it('should have no accessibility violations', async () => { + const component = await mountSuspended(DiffSidebarPanel, { + props: { + compare: mockCompare, + groupedDeps: mockGroupedDeps, + allChanges: mockAllChanges, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with selected file', async () => { + const component = await mountSuspended(DiffSidebarPanel, { + props: { + compare: mockCompare, + groupedDeps: mockGroupedDeps, + allChanges: mockAllChanges, + selectedFile: mockAllChanges[0], + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with file filter', async () => { + const component = await mountSuspended(DiffSidebarPanel, { + props: { + compare: mockCompare, + groupedDeps: mockGroupedDeps, + allChanges: mockAllChanges, + fileFilter: 'added', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with warnings', async () => { + const compareWithWarnings = { + ...mockCompare, + meta: { warnings: ['Some files were truncated'] }, + } + const component = await mountSuspended(DiffSidebarPanel, { + props: { + compare: compareWithWarnings, + groupedDeps: mockGroupedDeps, + allChanges: mockAllChanges, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with no dependency changes', async () => { + const compareNoDeps = { + ...mockCompare, + dependencyChanges: [], + } + const component = await mountSuspended(DiffSidebarPanel, { + props: { + compare: compareNoDeps, + groupedDeps: new Map(), + allChanges: mockAllChanges, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DiffMobileSidebarDrawer', () => { + const mockCompare = { + package: 'test-package', + from: '1.0.0', + to: '2.0.0', + packageJson: { from: {}, to: {} }, + files: { + added: [{ path: 'new.ts', type: 'added' as const, newSize: 100 }], + removed: [], + modified: [{ path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }], + }, + dependencyChanges: [], + stats: { + totalFilesFrom: 5, + totalFilesTo: 6, + filesAdded: 1, + filesRemoved: 0, + filesModified: 1, + }, + meta: {}, + } + + const mockAllChanges = [ + { path: 'new.ts', type: 'added' as const, newSize: 100 }, + { path: 'changed.ts', type: 'modified' as const, oldSize: 200, newSize: 250 }, + ] + + it('should have no accessibility violations when closed', async () => { + const component = await mountSuspended(DiffMobileSidebarDrawer, { + props: { + compare: mockCompare, + groupedDeps: new Map(), + allChanges: mockAllChanges, + open: false, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations when open', async () => { + const component = await mountSuspended(DiffMobileSidebarDrawer, { + props: { + compare: mockCompare, + groupedDeps: new Map(), + allChanges: mockAllChanges, + open: true, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DiffViewerPanel', () => { + const mockFile = { + path: 'src/index.ts', + type: 'modified' as const, + oldSize: 500, + newSize: 600, + } + + // Note: DiffViewerPanel fetches content from CDN, so we test the initial/loading states + // Full diff rendering tests would require mocking fetch + + it('should have no accessibility violations in loading state', async () => { + const component = await mountSuspended(DiffViewerPanel, { + props: { + packageName: 'test-package', + fromVersion: '1.0.0', + toVersion: '2.0.0', + file: mockFile, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for added file', async () => { + const addedFile = { + path: 'src/new-feature.ts', + type: 'added' as const, + newSize: 200, + } + const component = await mountSuspended(DiffViewerPanel, { + props: { + packageName: 'test-package', + fromVersion: '1.0.0', + toVersion: '2.0.0', + file: addedFile, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for removed file', async () => { + const removedFile = { + path: 'src/deprecated.ts', + type: 'removed' as const, + oldSize: 300, + } + const component = await mountSuspended(DiffViewerPanel, { + props: { + packageName: 'test-package', + fromVersion: '1.0.0', + toVersion: '2.0.0', + file: removedFile, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('SizeIncrease', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(SizeIncrease, {