From 8caba705c2a0712dbb039298952984378a68f288 Mon Sep 17 00:00:00 2001 From: Mopsgamer <79159094+Mopsgamer@users.noreply.github.com> Date: Tue, 26 May 2026 22:46:52 +0200 Subject: [PATCH 01/54] whatever --- app/components/Dependencies/Card.vue | 62 ++++ .../Dependencies/InsightsSummary.vue | 285 +++++++++++++++ app/components/Dependencies/List.vue | 105 ++++++ .../Dependencies/StatusIndicators.vue | 72 ++++ app/components/Dependencies/Table.vue | 83 +++++ app/components/Dependencies/TableRow.vue | 69 ++++ app/components/Dependencies/Toolbar.vue | 141 ++++++++ app/components/Package/Card.vue | 86 ++++- app/components/Package/Dependencies.vue | 334 ------------------ app/components/Package/Header.vue | 19 +- app/components/Package/TableRow.vue | 5 +- .../npm/useOutdatedDependencies.ts | 122 ++++--- .../npm/useReplacementDependencies.ts | 107 ++++-- .../packageDependencyInsightsKey.ts | 6 + .../useCommandPalettePackageCommands.ts | 14 + app/composables/useDependencyAnalysis.ts | 6 + .../usePackageDependencyInsights.ts | 75 ++++ .../v/[version]/[...filePath].vue | 2 +- .../[[org]]/[packageName]/v/[version].vue | 308 ++++++++++++++++ .../package-stats/[[org]]/[packageName].vue | 41 ++- app/pages/package/[[org]]/[name].vue | 35 +- app/utils/npm/outdated-dependencies.ts | 10 +- app/utils/npm/package-dependency-sections.ts | 121 +++++++ app/utils/router.ts | 19 + i18n/locales/en.json | 19 +- i18n/schema.json | 51 +++ server/api/replacements/[...pkg].get.ts | 54 ++- shared/types/package-dependencies.ts | 26 ++ test/nuxt/a11y.spec.ts | 141 ++++++-- .../utils/package-dependency-sections.spec.ts | 40 +++ 30 files changed, 1976 insertions(+), 482 deletions(-) create mode 100644 app/components/Dependencies/Card.vue create mode 100644 app/components/Dependencies/InsightsSummary.vue create mode 100644 app/components/Dependencies/List.vue create mode 100644 app/components/Dependencies/StatusIndicators.vue create mode 100644 app/components/Dependencies/Table.vue create mode 100644 app/components/Dependencies/TableRow.vue create mode 100644 app/components/Dependencies/Toolbar.vue delete mode 100644 app/components/Package/Dependencies.vue create mode 100644 app/composables/packageDependencyInsightsKey.ts create mode 100644 app/composables/usePackageDependencyInsights.ts create mode 100644 app/pages/package-deps/[[org]]/[packageName]/v/[version].vue create mode 100644 app/utils/npm/package-dependency-sections.ts create mode 100644 shared/types/package-dependencies.ts create mode 100644 test/nuxt/utils/package-dependency-sections.spec.ts diff --git a/app/components/Dependencies/Card.vue b/app/components/Dependencies/Card.vue new file mode 100644 index 0000000000..798bab9c5c --- /dev/null +++ b/app/components/Dependencies/Card.vue @@ -0,0 +1,62 @@ + + + diff --git a/app/components/Dependencies/InsightsSummary.vue b/app/components/Dependencies/InsightsSummary.vue new file mode 100644 index 0000000000..ab6c70ff7c --- /dev/null +++ b/app/components/Dependencies/InsightsSummary.vue @@ -0,0 +1,285 @@ + + + diff --git a/app/components/Dependencies/List.vue b/app/components/Dependencies/List.vue new file mode 100644 index 0000000000..c602bcccea --- /dev/null +++ b/app/components/Dependencies/List.vue @@ -0,0 +1,105 @@ + + + diff --git a/app/components/Dependencies/StatusIndicators.vue b/app/components/Dependencies/StatusIndicators.vue new file mode 100644 index 0000000000..fa7160f922 --- /dev/null +++ b/app/components/Dependencies/StatusIndicators.vue @@ -0,0 +1,72 @@ + + + diff --git a/app/components/Dependencies/Table.vue b/app/components/Dependencies/Table.vue new file mode 100644 index 0000000000..07e2565875 --- /dev/null +++ b/app/components/Dependencies/Table.vue @@ -0,0 +1,83 @@ + + + diff --git a/app/components/Dependencies/TableRow.vue b/app/components/Dependencies/TableRow.vue new file mode 100644 index 0000000000..493c0e24bf --- /dev/null +++ b/app/components/Dependencies/TableRow.vue @@ -0,0 +1,69 @@ + + + diff --git a/app/components/Dependencies/Toolbar.vue b/app/components/Dependencies/Toolbar.vue new file mode 100644 index 0000000000..abda80ad6c --- /dev/null +++ b/app/components/Dependencies/Toolbar.vue @@ -0,0 +1,141 @@ + + + diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue index 6550a4ceef..e91b2492eb 100644 --- a/app/components/Package/Card.vue +++ b/app/components/Package/Card.vue @@ -1,5 +1,6 @@ @@ -48,7 +72,7 @@ const numberFormatter = useNumberFormatter()
{{ result.package.name }} + + + + +
+
+ + + + + + + + +
+
+ + diff --git a/app/components/Package/Dependencies.vue b/app/components/Package/Dependencies.vue deleted file mode 100644 index 649bfb34a5..0000000000 --- a/app/components/Package/Dependencies.vue +++ /dev/null @@ -1,334 +0,0 @@ - - - diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index e01e0dc03b..04707471a3 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -10,7 +10,7 @@ const props = defineProps<{ latestVersion?: SlimVersion | null provenanceData?: ProvenanceDetails | null provenanceStatus?: string | null - page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' | 'timeline' | 'stats' + page: 'main' | 'docs' | 'code' | 'dependencies' | 'diff' | 'changelog' | 'timeline' | 'stats' versionUrlPattern: string }>() @@ -154,6 +154,13 @@ const codeLink = computed((): RouteLocationRaw | null => { } }) +const dependenciesLink = computed((): RouteLocationRaw | null => { + if (props.pkg == null || props.resolvedVersion == null) { + return null + } + return dependenciesRoute(props.pkg.name, props.resolvedVersion) +}) + const mainLink = computed((): RouteLocationRaw | null => { if (props.pkg == null || props.resolvedVersion == null) { return null @@ -209,6 +216,7 @@ useShortcuts({ '-': () => changelogLink.value, 't': () => timelineLink.value, 's': () => statsLink.value, + 'y': () => dependenciesLink.value, }) @@ -372,6 +380,15 @@ useShortcuts({ > {{ $t('package.links.code') }} + + {{ $t('package.links.dependencies') }} + - {{ pkg.name }} + diff --git a/app/composables/npm/useOutdatedDependencies.ts b/app/composables/npm/useOutdatedDependencies.ts index eafdf3feb2..5c0876f8d9 100644 --- a/app/composables/npm/useOutdatedDependencies.ts +++ b/app/composables/npm/useOutdatedDependencies.ts @@ -60,66 +60,106 @@ export function useOutdatedDependencies( dependencies: MaybeRefOrGetter | undefined>, ) { const outdated = shallowRef>({}) + const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle') + const error = ref(null) async function fetchOutdatedInfo(deps: Record | undefined) { - if (!deps || Object.keys(deps).length === 0) { - outdated.value = {} - return - } + status.value = 'pending' + error.value = null + + try { + if (!deps || Object.keys(deps).length === 0) { + outdated.value = {} + status.value = 'success' + return + } - const semverEntries = Object.entries(deps).filter( - ([, constraint]) => !isNonSemverConstraint(constraint), - ) + const semverEntries = Object.entries(deps).filter( + ([, constraint]) => !isNonSemverConstraint(constraint), + ) - if (semverEntries.length === 0) { - outdated.value = {} - return - } + if (semverEntries.length === 0) { + outdated.value = {} + status.value = 'success' + return + } - const packageNames = semverEntries.map(([name]) => name) + const packageNames = semverEntries.map(([name]) => name) - const chunks: string[][] = [] - for (let i = 0; i < packageNames.length; i += BATCH_SIZE) { - chunks.push(packageNames.slice(i, i + BATCH_SIZE)) - } - const batchResults = await Promise.all( - chunks.map(chunk => getVersionsBatch(chunk, { throw: false })), - ) - const allVersionData = batchResults.flat() - - // Build a lookup map from package name to version data - const versionMap = new Map() - for (const data of allVersionData) { - if ('error' in data) continue - versionMap.set(data.name, data) - } + const chunks: string[][] = [] + for (let i = 0; i < packageNames.length; i += BATCH_SIZE) { + chunks.push(packageNames.slice(i, i + BATCH_SIZE)) + } - const results: Record = {} - for (const [name, constraint] of semverEntries) { - const data = versionMap.get(name) - if (!data) continue + // Use allSettled so a failing chunk doesn't discard results from successful ones + const batchResults = await Promise.allSettled( + chunks.map(chunk => getVersionsBatch(chunk, { throw: false })), + ) - const latestTag = data.distTags.latest - if (!latestTag) continue + let anyChunkFailed = false + const allVersionData = batchResults.flatMap(result => { + if (result.status === 'fulfilled') return result.value + anyChunkFailed = true + return [] + }) - const info = resolveOutdated(data.versions, latestTag, constraint) - if (info) { - results[name] = info + // Build a lookup map from package name to version data + const versionMap = new Map() + for (const data of allVersionData) { + if ('error' in data) continue + versionMap.set(data.name, data) } - } - outdated.value = results + const results: Record = {} + for (const [name, constraint] of semverEntries) { + const data = versionMap.get(name) + if (!data) continue + + const latestTag = data.distTags.latest + if (!latestTag) continue + + const info = resolveOutdated(data.versions, latestTag, constraint) + if (info) { + results[name] = info + } + } + + outdated.value = results + // Keep partial results from successful chunks; only surface error if ALL chunks failed + if (anyChunkFailed && Object.keys(results).length === 0) { + status.value = 'error' + error.value = new Error('Failed to fetch version data for all dependency chunks') + } else { + status.value = 'success' + } + } catch (err) { + error.value = err instanceof Error ? err : new Error(String(err)) + status.value = 'error' + } } watch( () => toValue(dependencies), deps => { - fetchOutdatedInfo(deps).catch(() => { - // Network failure or fast-npm-meta outage — leave stale results in place + // If the dependencies become undefined/empty and we aren't tracking anything, + // it is cleaner to set it to idle until legitimate keys show up. + if (!deps || Object.keys(deps).length === 0) { + outdated.value = {} + status.value = 'idle' + return + } + + fetchOutdatedInfo(deps).catch(err => { + error.value = err instanceof Error ? err : new Error(String(err)) + status.value = 'error' }) }, { immediate: true }, ) - return outdated + return { + data: outdated, + status, + error, + } } diff --git a/app/composables/npm/useReplacementDependencies.ts b/app/composables/npm/useReplacementDependencies.ts index 7888277b77..c90057bbb7 100644 --- a/app/composables/npm/useReplacementDependencies.ts +++ b/app/composables/npm/useReplacementDependencies.ts @@ -1,32 +1,43 @@ import type { ModuleReplacement, ModuleReplacementMapping } from 'module-replacements' +import { ref, shallowRef, watch, toValue } from 'vue' async function fetchReplacements( deps: Record, ): Promise> { const names = Object.keys(deps) + if (names.length === 0) return {} - const results = await Promise.all( - names.map(async name => { - try { - const response = await $fetch<{ - mapping: ModuleReplacementMapping - replacement: ModuleReplacement - } | null>(`/api/replacements/${name}`) - const replacement = response?.replacement ?? null - return { name, replacement } - } catch { - return { name, replacement: null } - } - }), - ) + // Single item: use the single-lookup endpoint (same response shape) + if (names.length === 1) { + const name = names[0]! + try { + const response = await $fetch<{ + mapping: ModuleReplacementMapping + replacement: ModuleReplacement + } | null>(`/api/replacements/${encodeURIComponent(name)}`) + return response?.replacement ? { [name]: response.replacement } : {} + } catch { + return {} + } + } - const map: Record = {} - for (const { name, replacement } of results) { - if (replacement) { - map[name] = replacement + // Multiple items: batch query (comma-separated) — single round-trip + try { + const query = names.map(encodeURIComponent).join(',') + const response = await $fetch< + Record + >(`/api/replacements/${query}`) + + const map: Record = {} + for (const name of names) { + if (response?.[name]?.replacement) { + map[name] = response[name].replacement + } } + return map + } catch { + return {} } - return map } /** @@ -37,31 +48,53 @@ export function useReplacementDependencies( dependencies: MaybeRefOrGetter | undefined>, ) { const replacements = shallowRef>({}) + const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle') + const error = ref(null) let generation = 0 - if (import.meta.client) { - watch( - () => toValue(dependencies), - async deps => { - const currentGeneration = ++generation + // On the server, replacements are a client-only enhancement — mark as success immediately + // so isLoading doesn't get stuck waiting for a watcher that won't run on SSR. + if (!import.meta.client) { + status.value = 'success' + return { data: replacements, status, error } + } + watch( + () => toValue(dependencies), + async deps => { + const currentGeneration = ++generation + status.value = 'pending' + error.value = null + + try { if (!deps || Object.keys(deps).length === 0) { - replacements.value = {} + if (currentGeneration === generation) { + replacements.value = {} + // 'success' (not 'idle') — no deps means nothing to fetch, not "hasn't started yet" + status.value = 'success' + } return } - try { - const result = await fetchReplacements(deps) - if (currentGeneration === generation) { - replacements.value = result - } - } catch { - // catastrophic failure, just keep whatever we have + const result = await fetchReplacements(deps) + + if (currentGeneration === generation) { + replacements.value = result + status.value = 'success' } - }, - { immediate: true }, - ) - } + } catch (err) { + if (currentGeneration === generation) { + error.value = err instanceof Error ? err : new Error(String(err)) + status.value = 'error' + } + } + }, + { immediate: true }, + ) - return replacements + return { + data: replacements, + status, + error, + } } diff --git a/app/composables/packageDependencyInsightsKey.ts b/app/composables/packageDependencyInsightsKey.ts new file mode 100644 index 0000000000..3ebc04a456 --- /dev/null +++ b/app/composables/packageDependencyInsightsKey.ts @@ -0,0 +1,6 @@ +import type { InjectionKey } from 'vue' +import type { usePackageDependencyInsights } from '~/composables/usePackageDependencyInsights' + +export const packageDependencyInsightsKey: InjectionKey< + ReturnType +> = Symbol('packageDependencyInsights') diff --git a/app/composables/useCommandPalettePackageCommands.ts b/app/composables/useCommandPalettePackageCommands.ts index 78ae3a62e0..0306bde935 100644 --- a/app/composables/useCommandPalettePackageCommands.ts +++ b/app/composables/useCommandPalettePackageCommands.ts @@ -47,6 +47,10 @@ export function useCommandPalettePackageCommands( filePath: '', }, } + const dependenciesLink = dependenciesRoute( + resolvedContext.packageName, + resolvedContext.resolvedVersion, + ) const commands: CommandPaletteContextCommandInput[] = [ { @@ -82,6 +86,16 @@ export function useCommandPalettePackageCommands( activeLabel: activeLabel(route.name === 'code', t('command_palette.here')), to: codeLink, }, + { + id: 'package-dependencies', + group: 'package', + label: t('package.links.dependencies'), + keywords: [resolvedContext.packageName, t('package.links.dependencies')], + iconClass: 'i-lucide:boxes', + active: route.name === 'dependencies', + activeLabel: activeLabel(route.name === 'dependencies', t('command_palette.here')), + to: dependenciesLink, + }, { id: 'package-timeline', group: 'package', diff --git a/app/composables/useDependencyAnalysis.ts b/app/composables/useDependencyAnalysis.ts index d8eee606a2..ca1a60453e 100644 --- a/app/composables/useDependencyAnalysis.ts +++ b/app/composables/useDependencyAnalysis.ts @@ -10,5 +10,11 @@ export function useDependencyAnalysis( return useFetch( () => `/api/registry/vulnerabilities/${encodePackageName(toValue(packageName))}/v/${toValue(version)}`, + { + key: `vuln:${toValue(packageName)}:${toValue(version)}`, + watch: [() => toValue(packageName), () => toValue(version)], + server: false, + lazy: true, + }, ) } diff --git a/app/composables/usePackageDependencyInsights.ts b/app/composables/usePackageDependencyInsights.ts new file mode 100644 index 0000000000..16c47ea395 --- /dev/null +++ b/app/composables/usePackageDependencyInsights.ts @@ -0,0 +1,75 @@ +import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies' +import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity' + +export function usePackageDependencyInsights( + packageName: MaybeRefOrGetter, + version: MaybeRefOrGetter, + dependencies: MaybeRefOrGetter | undefined>, +) { + const { t } = useI18n() + + const { + data: outdatedDeps, + status: outdatedStatus, + error: outdatedError, + } = useOutdatedDependencies(dependencies) + + const { + data: replacementDeps, + status: replacementStatus, + error: replacementError, + } = useReplacementDependencies(dependencies) + + const { + data: vulnTree, + status: vulnStatus, + error: vulnError, + } = useDependencyAnalysis(packageName, version) + + const hasError = computed(() => { + return !!(vulnError.value || outdatedError.value || replacementError.value) + }) + + function getVulnerableDepInfo(depName: string) { + if (!vulnTree.value?.vulnerablePackages) return null + return vulnTree.value.vulnerablePackages.find(p => p.name === depName && p.depth === 'direct') + } + + function getDeprecatedDepInfo(depName: string) { + if (!vulnTree.value?.deprecatedPackages) return null + return vulnTree.value.deprecatedPackages.find(p => p.name === depName && p.depth === 'direct') + } + + function getDepVersionTooltip(dep: string, depVersion: string) { + const outdated = outdatedDeps.value?.[dep] + if (outdated) return getOutdatedTooltip(outdated, t) + return depVersion + } + + function getDepVersionClass(dep: string) { + const outdated = outdatedDeps.value?.[dep] + if (outdated) return getVersionClass(outdated) + if (replacementDeps.value?.[dep]) return 'text-amber-700 dark:text-amber-500' + if (getVulnerableDepInfo(dep) || getDeprecatedDepInfo(dep)) return getVersionClass(undefined) + return getVersionClass(undefined) + } + + return { + outdatedDeps, + outdatedStatus, + replacementDeps, + replacementStatus, + vulnTree, + vulnStatus, + hasError, + errors: { vulnError, outdatedError, replacementError }, + getVulnerableDepInfo, + getDeprecatedDepInfo, + getDepVersionTooltip, + getDepVersionClass, + } +} + +export type PackageDependencyInsights = ReturnType + +export { SEVERITY_TEXT_COLORS, getHighestSeverity } diff --git a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue index c7d74034aa..63773598bb 100644 --- a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue +++ b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue @@ -66,7 +66,7 @@ function getCodeUrl(args: { } // Fetch package data for version list -const { data: pkg } = usePackage(packageName) +const { data: pkg } = usePackage(packageName, version) const { versions: commandPaletteVersions, ensureLoaded: ensureCommandPaletteVersionsLoaded } = useCommandPalettePackageVersions(packageName) diff --git a/app/pages/package-deps/[[org]]/[packageName]/v/[version].vue b/app/pages/package-deps/[[org]]/[packageName]/v/[version].vue new file mode 100644 index 0000000000..b31d1b6425 --- /dev/null +++ b/app/pages/package-deps/[[org]]/[packageName]/v/[version].vue @@ -0,0 +1,308 @@ + + + + + diff --git a/app/pages/package-stats/[[org]]/[packageName].vue b/app/pages/package-stats/[[org]]/[packageName].vue index d9fa54ef1f..5a0c48dd90 100644 --- a/app/pages/package-stats/[[org]]/[packageName].vue +++ b/app/pages/package-stats/[[org]]/[packageName].vue @@ -1,4 +1,6 @@ diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue index e91b2492eb..2c4f649486 100644 --- a/app/components/Package/Card.vue +++ b/app/components/Package/Card.vue @@ -1,6 +1,7 @@