Skip to content
48 changes: 31 additions & 17 deletions app/components/Package/Dependencies.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'
import { buildVersionLink } from '~/utils/versions'

const { t } = useI18n()

Expand Down Expand Up @@ -45,7 +46,12 @@ const optionalDepsExpanded = shallowRef(false)
// Sort dependencies alphabetically
const sortedDependencies = computed(() => {
if (!props.dependencies) return []
return Object.entries(props.dependencies).sort(([a], [b]) => a.localeCompare(b))
return Object.entries(props.dependencies)
.map(([name, version]) => ({
name,
version: buildVersionLink(version),
}))
.sort((a, b) => a.name.localeCompare(b.name))
})

// Sort peer dependencies alphabetically, with required first then optional
Expand All @@ -55,7 +61,7 @@ const sortedPeerDependencies = computed(() => {
return Object.entries(props.peerDependencies)
.map(([name, version]) => ({
name,
version,
version: buildVersionLink(version),
optional: props.peerDependenciesMeta?.[name]?.optional ?? false,
}))
.sort((a, b) => {
Expand All @@ -68,7 +74,12 @@ const sortedPeerDependencies = computed(() => {
// Sort optional dependencies alphabetically
const sortedOptionalDependencies = computed(() => {
if (!props.optionalDependencies) return []
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
return Object.entries(props.optionalDependencies)
.map(([name, version]) => ({
name,
version: buildVersionLink(version),
}))
.sort((a, b) => a.name.localeCompare(b.name))
})

// Get version tooltip
Expand Down Expand Up @@ -110,7 +121,10 @@ const numberFormatter = useNumberFormatter()
>
<ul class="px-1 space-y-1 list-none m-0" :aria-label="$t('package.dependencies.list_label')">
<li
v-for="[dep, version] in sortedDependencies.slice(0, depsExpanded ? undefined : 10)"
v-for="{ name: dep, version } in sortedDependencies.slice(
0,
depsExpanded ? undefined : 10,
)"
:key="dep"
class="flex items-center justify-between py-1 text-sm gap-2"
>
Expand Down Expand Up @@ -165,12 +179,12 @@ const numberFormatter = useNumberFormatter()
<span class="sr-only">{{ $t('package.deprecated.label') }}</span>
</LinkBase>
<LinkBase
:to="packageRoute(dep, version)"
:to="packageRoute(dep, version.href)"
class="block truncate"
:class="getDepVersionClass(dep)"
:title="getDepVersionTooltip(dep, version)"
:title="getDepVersionTooltip(dep, version.title)"
>
{{ version }}
{{ version.title }}
</LinkBase>
<span v-if="outdatedDeps[dep]" class="sr-only">
({{ getOutdatedTooltip(outdatedDeps[dep], $t) }})
Expand Down Expand Up @@ -227,12 +241,12 @@ const numberFormatter = useNumberFormatter()
</TagStatic>
</div>
<LinkBase
:to="packageRoute(peer.name, peer.version)"
:to="packageRoute(peer.name, peer.version.href)"
class="block truncate max-w-[40%]"
:title="peer.version"
:title="peer.version.title"
dir="ltr"
>
{{ peer.version }}
{{ peer.version.title }}
</LinkBase>
</li>
</ul>
Expand Down Expand Up @@ -273,23 +287,23 @@ const numberFormatter = useNumberFormatter()
:aria-label="$t('package.optional_dependencies.list_label')"
>
<li
v-for="[dep, version] in sortedOptionalDependencies.slice(
v-for="{ name, version } in sortedOptionalDependencies.slice(
0,
optionalDepsExpanded ? undefined : 10,
)"
:key="dep"
:key="name"
class="flex items-center justify-between py-1 text-sm gap-2"
>
<LinkBase :to="packageRoute(dep)" class="block truncate" dir="ltr">
{{ dep }}
<LinkBase :to="packageRoute(name)" class="block truncate" dir="ltr">
{{ name }}
</LinkBase>
<LinkBase
:to="packageRoute(dep, version)"
:to="packageRoute(name, version.href)"
class="block truncate"
:title="version"
:title="version.title"
dir="ltr"
>
{{ version }}
{{ version.title }}
</LinkBase>
</li>
</ul>
Expand Down
38 changes: 38 additions & 0 deletions app/utils/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export interface ParsedVersion {
prerelease: string
}

export interface VersionLink {
href: string
title: string
}

const LAST_VERSION_IN_RANGE_REGEXP =
/([~^<>=]*\s*\d+\.\d+\.\d+(?:-[0-9A-Z-]+(?:\.[0-9A-Z-]+)*)?(?:\+[0-9A-Z-]+(?:\.[0-9A-Z-]+)*)?)\s*$/i

/**
* Parse a semver version string into its components
* @param version - The version string (e.g., "1.2.3" or "1.0.0-beta.1")
Expand Down Expand Up @@ -207,3 +215,33 @@ export function filterVersions(versions: string[], range: string): Set<string> {
}
return matched
}

/**
* Convert a semver complex version (Comparator Set, Range, Union) to
* a version object with href and title. It's needed to get a single version
* for valid URL if there is a range version, union version or combination of both
* e.g. (href), "^1.0.0" -> "^1.0.0",
* ">=1.0.0 <= 2.0.0" -> "<=2.0.0"
* "1.0.0 || 2.0.0" -> "2.0.0"
*
* @param version - A semver version, might be a range, union, etc
* @returns Version object with href and title, where href is a valid single version for URL usage
*/
export function buildVersionLink(version: string): VersionLink {
let href = version
// Check for union version, which means only logical OR ("||") present and no range ("-")
if (version.includes('||') && !version.includes(' - ')) {
const versions: string[] = version.split('||').map(item => item.trim())

href = versions.at(-1) || version
// Check for range version and complex conditions: could be ">=" + "<=", "||", "&&", "-"
} else if (/>=|<=|[<>]|\s-\s|&&/.test(version)) {
// Remove whitespaces to convert to valid href: "< 1.0.0" -> "<1.0.0"
href = version.match(LAST_VERSION_IN_RANGE_REGEXP)?.[1]?.replace(/\s+/g, '') || version
}

return {
href,
title: version,
}
}
Loading