Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions app/components/Package/ExternalLinks.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { IconClass } from '~/types'

const props = defineProps<{
pkg: SlimPackument
jsrInfo?: JsrPackageInfo
}>()

const displayVersion = computed(() => props.pkg?.requestedVersion ?? null)
const { repositoryUrl } = useRepositoryUrl(displayVersion)
const { meta: repoMeta, repoRef, stars, starsLink, forks, forksLink } = useRepoMeta(repositoryUrl)
const compactNumberFormatter = useCompactNumberFormatter()

const homepageUrl = computed(() => {
const homepage = displayVersion.value?.homepage
if (!homepage) return null

// Don't show homepage if it's the same as the repository URL
if (repositoryUrl.value && areUrlsEquivalent(homepage, repositoryUrl.value)) {
return null
}

return homepage
})

const fundingUrl = computed(() => {
let funding = displayVersion.value?.funding
if (Array.isArray(funding)) funding = funding[0]

if (!funding) return null

return typeof funding === 'string' ? funding : funding.url
})

const PROVIDER_ICONS: Record<string, IconClass> = {
github: 'i-simple-icons:github',
gitlab: 'i-simple-icons:gitlab',
bitbucket: 'i-simple-icons:bitbucket',
codeberg: 'i-simple-icons:codeberg',
gitea: 'i-simple-icons:gitea',
forgejo: 'i-simple-icons:forgejo',
gitee: 'i-simple-icons:gitee',
sourcehut: 'i-simple-icons:sourcehut',
tangled: 'i-custom:tangled',
radicle: 'i-lucide:network', // Radicle is a P2P network, using network icon
}

const repoProviderIcon = computed((): IconClass => {
const provider = repoRef.value?.provider
if (!provider) return 'i-simple-icons:github'
return PROVIDER_ICONS[provider] ?? 'i-lucide:code'
})
</script>

<template>
<ul class="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:gap-4 list-none m-0 p-0 mt-3 text-sm">
<li v-if="repositoryUrl">
<LinkBase :to="repositoryUrl" :classicon="repoProviderIcon">
<span v-if="repoRef">
{{ repoRef.owner }}<span class="opacity-50">/</span>{{ repoRef.repo }}
</span>
<span v-else>{{ $t('package.links.repo') }}</span>
</LinkBase>
</li>
<li v-if="repositoryUrl && repoMeta && starsLink">
<LinkBase :to="starsLink" classicon="i-lucide:star">
{{ compactNumberFormatter.format(stars) }}
</LinkBase>
</li>
<li v-if="forks && forksLink">
<LinkBase :to="forksLink" classicon="i-lucide:git-fork">
{{ compactNumberFormatter.format(forks) }}
</LinkBase>
</li>
<li class="basis-full sm:hidden" />
<li v-if="homepageUrl">
<LinkBase :to="homepageUrl" classicon="i-lucide:link">
{{ $t('package.links.homepage') }}
</LinkBase>
</li>
<li v-if="displayVersion?.bugs?.url">
<LinkBase :to="displayVersion.bugs.url" classicon="i-lucide:circle-alert">
{{ $t('package.links.issues') }}
</LinkBase>
</li>
<li>
<LinkBase
:to="`https://www.npmjs.com/package/${pkg.name}`"
:title="$t('common.view_on.npm')"
classicon="i-simple-icons:npm"
Comment on lines +87 to +90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="app/components/Link/Base.vue"

echo "== $file =="
sed -n '1,220p' "$file"

echo
echo "== title-related lines =="
rg -n -C2 '\btitle\b|v-bind="props"|<NuxtLink|<span' "$file"

Repository: npmx-dev/npmx.dev

Length of output: 5693


🏁 Script executed:

# Verify ExternalLinks.vue uses title attribute in the mentioned lines
sed -n '85,100p' app/components/Package/ExternalLinks.vue | cat -n

Repository: npmx-dev/npmx.dev

Length of output: 632


Add title prop to LinkBase and forward it to both template branches.

The title attribute passed on lines 89 and 96 is not declared as a prop in app/components/Link/Base.vue and will be silently dropped at runtime. The component uses a v-if/v-else fragment with a disabled <span> and <NuxtLink> branch; Vue 3 does not automatically apply fallthrough attributes in this pattern, and v-bind="props" only forwards declared props. Add title to defineProps and bind :title="title" on both the <span> and <NuxtLink> branches to ensure the hover text appears.

>
npm
</LinkBase>
</li>
<li v-if="jsrInfo?.exists && jsrInfo.url">
<LinkBase :to="jsrInfo.url" :title="$t('badges.jsr.title')" classicon="i-simple-icons:jsr">
{{ $t('package.links.jsr') }}
</LinkBase>
</li>
<li v-if="fundingUrl">
<LinkBase :to="fundingUrl" classicon="i-lucide:heart">
{{ $t('package.links.fund') }}
</LinkBase>
</li>
</ul>
</template>
107 changes: 2 additions & 105 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
<script setup lang="ts">
import type { JsrPackageInfo } from '#shared/types/jsr'
import type { IconClass } from '~/types'
import { assertValidPackageName } from '#shared/utils/npm'
import { areUrlsEquivalent } from '#shared/utils/url'
import { getDependencyCount } from '~/utils/npm/dependency-count'
import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security'
import { useInstallSizeDiff } from '~/composables/useInstallSizeDiff'
import { useViewOnGitProvider } from '~/composables/useViewOnGitProvider'

defineOgImageComponent('Package', {
name: () => packageName.value,
Expand Down Expand Up @@ -390,50 +384,10 @@ const totalDepsCount = computed(() => {

const { repositoryUrl } = useRepositoryUrl(displayVersion)

const { meta: repoMeta, repoRef, stars, starsLink, forks, forksLink } = useRepoMeta(repositoryUrl)

const PROVIDER_ICONS: Record<string, IconClass> = {
github: 'i-simple-icons:github',
gitlab: 'i-simple-icons:gitlab',
bitbucket: 'i-simple-icons:bitbucket',
codeberg: 'i-simple-icons:codeberg',
gitea: 'i-simple-icons:gitea',
forgejo: 'i-simple-icons:forgejo',
gitee: 'i-simple-icons:gitee',
sourcehut: 'i-simple-icons:sourcehut',
tangled: 'i-custom:tangled',
radicle: 'i-lucide:network', // Radicle is a P2P network, using network icon
}

const repoProviderIcon = computed((): IconClass => {
const provider = repoRef.value?.provider
if (!provider) return 'i-simple-icons:github'
return PROVIDER_ICONS[provider] ?? 'i-lucide:code'
})
const { repoRef } = useRepoMeta(repositoryUrl)

const viewOnGitProvider = useViewOnGitProvider(() => repoRef.value?.provider)

const homepageUrl = computed(() => {
const homepage = displayVersion.value?.homepage
if (!homepage) return null

// Don't show homepage if it's the same as the repository URL
if (repositoryUrl.value && areUrlsEquivalent(homepage, repositoryUrl.value)) {
return null
}

return homepage
})

const fundingUrl = computed(() => {
let funding = displayVersion.value?.funding
if (Array.isArray(funding)) funding = funding[0]

if (!funding) return null

return typeof funding === 'string' ? funding : funding.url
})

// Check if a version has provenance/attestations
// The dist object may have attestations that aren't in the base type
function hasProvenance(version: PackumentVersion | null): boolean {
Expand Down Expand Up @@ -496,7 +450,6 @@ const versionUrlPattern = computed(
const dependencyCount = computed(() => getDependencyCount(displayVersion.value))

const numberFormatter = useNumberFormatter()
const compactNumberFormatter = useCompactNumberFormatter()
const bytesFormatter = useBytesFormatter()

useHead({
Expand Down Expand Up @@ -578,63 +531,7 @@ const showSkeleton = shallowRef(false)
</p>
</div>

<!-- External links -->
<ul
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:gap-4 list-none m-0 p-0 mt-3 text-sm"
>
<li v-if="repositoryUrl">
<LinkBase :to="repositoryUrl" :classicon="repoProviderIcon">
<span v-if="repoRef">
{{ repoRef.owner }}<span class="opacity-50">/</span>{{ repoRef.repo }}
</span>
<span v-else>{{ $t('package.links.repo') }}</span>
</LinkBase>
</li>
<li v-if="repositoryUrl && repoMeta && starsLink">
<LinkBase :to="starsLink" classicon="i-lucide:star">
{{ compactNumberFormatter.format(stars) }}
</LinkBase>
</li>
<li v-if="forks && forksLink">
<LinkBase :to="forksLink" classicon="i-lucide:git-fork">
{{ compactNumberFormatter.format(forks) }}
</LinkBase>
</li>
<li class="basis-full sm:hidden" />
<li v-if="homepageUrl">
<LinkBase :to="homepageUrl" classicon="i-lucide:link">
{{ $t('package.links.homepage') }}
</LinkBase>
</li>
<li v-if="displayVersion?.bugs?.url">
<LinkBase :to="displayVersion.bugs.url" classicon="i-lucide:circle-alert">
{{ $t('package.links.issues') }}
</LinkBase>
</li>
<li>
<LinkBase
:to="`https://www.npmjs.com/package/${pkg.name}`"
:title="$t('common.view_on.npm')"
classicon="i-simple-icons:npm"
>
npm
</LinkBase>
</li>
<li v-if="jsrInfo?.exists && jsrInfo.url">
<LinkBase
:to="jsrInfo.url"
:title="$t('badges.jsr.title')"
classicon="i-simple-icons:jsr"
>
{{ $t('package.links.jsr') }}
</LinkBase>
</li>
<li v-if="fundingUrl">
<LinkBase :to="fundingUrl" classicon="i-lucide:heart">
{{ $t('package.links.fund') }}
</LinkBase>
</li>
</ul>
<PackageExternalLinks :pkg :jsrInfo />
<PackageMetricsBadges
v-if="resolvedVersion"
:package-name="packageName"
Expand Down
57 changes: 57 additions & 0 deletions test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ import {
DiffSkipBlock,
DiffTable,
DiffViewerPanel,
PackageExternalLinks,
} from '#components'

// Server variant components must be imported directly to test the server-side render
Expand Down Expand Up @@ -672,6 +673,62 @@ describe('component accessibility audits', () => {
})
})

describe('PackageExternalLinks', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(PackageExternalLinks, {
props: {
pkg: {
'_id': 'react',
'name': 'react',
'dist-tags': { latest: '18.2.0' },
'time': {
'created': '2013-01-31T01:07:45.050Z',
'modified': '2024-03-14T00:00:00.000Z',
'18.2.0': '2024-03-14T00:00:00.000Z',
},
'requestedVersion': {
version: '18.2.0',
_npmVersion: '18.2.0',
homepage: 'https://react.dev',
repository: {
type: 'git',
url: 'https://github.com/facebook/react.git',
},
bugs: {
url: 'https://github.com/facebook/react/issues',
},
funding: 'https://github.com/sponsors/facebook',
dist: {
shasum: 'abc123def456',
tarball: 'https://registry.npmjs.org/react/-/react-18.2.0.tgz',
signatures: [],
},
deprecated: undefined,
keywords: [],
license: 'MIT',
name: 'react',
time: '2024-03-14T00:00:00.000Z',
_id: 'react@18.2.0',
},
'versions': {
'18.2.0': {
version: '18.2.0',
hasProvenance: false,
tags: [],
},
},
},
jsrInfo: {
exists: true,
url: 'https://jsr.io/@react/react',
},
},
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})
})

describe('PackageCard', () => {
const mockResult = {
package: {
Expand Down
Loading