Skip to content
Closed
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
12 changes: 12 additions & 0 deletions app/components/Package/Dependencies.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ const numberFormatter = useNumberFormatter()

<template>
<div class="space-y-8">
<!-- Empty state when no dependencies at all -->
<p
v-if="
sortedDependencies.length === 0 &&
sortedPeerDependencies.length === 0 &&
sortedOptionalDependencies.length === 0
"
class="text-sm text-fg-subtle"
>
{{ $t('package.dependencies.none') }}
</p>

<!-- Dependencies -->
<CollapsibleSection
v-if="sortedDependencies.length > 0"
Expand Down
66 changes: 48 additions & 18 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,27 @@
}

async function copyReadmeHandler() {
await fetchReadmeMarkdown()
// Safari requires navigator.clipboard.write() to be called synchronously within
// the user gesture, but accepts a Promise inside ClipboardItem.
// This pattern works in Safari 13.1+, Chrome, and Firefox.
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
const blobPromise = (async () => {
await fetchReadmeMarkdown()
const markdown = readmeMarkdownData.value?.markdown ?? ''
return new Blob([markdown], { type: 'text/plain' })
})()
try {
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blobPromise })])
return
} catch {
// Fall through to legacy approach
}
}

// Legacy fallback (non-Safari, or older browsers)
await fetchReadmeMarkdown()
const markdown = readmeMarkdownData.value?.markdown
if (!markdown) return

await copyReadme(markdown)
}
Comment on lines 110 to 133
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 | 🟑 Minor

Missing UI feedback for the modern clipboard path.

When navigator.clipboard.write() succeeds (Safari path), the function returns without updating the copiedReadme state. The user won't see the "Copied" feedback that the legacy path provides via copyReadme().

πŸ› Proposed fix
 async function copyReadmeHandler() {
   // Safari requires navigator.clipboard.write() to be called synchronously within
   // the user gesture, but accepts a Promise inside ClipboardItem.
   // This pattern works in Safari 13.1+, Chrome, and Firefox.
   if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
     const blobPromise = (async () => {
       await fetchReadmeMarkdown()
       const markdown = readmeMarkdownData.value?.markdown ?? ''
       return new Blob([markdown], { type: 'text/plain' })
     })()
     try {
       await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blobPromise })])
+      // Manually trigger copied state for UI feedback
+      await copyReadme(readmeMarkdownData.value?.markdown ?? '')
       return
     } catch {
       // Fall through to legacy approach
     }
   }
 
   // Legacy fallback (non-Safari, or older browsers)
   await fetchReadmeMarkdown()
   const markdown = readmeMarkdownData.value?.markdown
   if (!markdown) return
   await copyReadme(markdown)
 }

Alternatively, if calling copyReadme again feels redundant, you could expose the copied ref setter from useClipboard or use a separate shallowRef to track copied state.


Expand Down Expand Up @@ -305,6 +321,16 @@
return pkg.value.versions[latestTag] ?? null
})

// SlimVersion does not carry license, so we compare against the package-level license
const licenseChanged = computed(() => {
const currentLicense = displayVersion.value?.license
const latestLicense = pkg.value?.license
if (!currentLicense || !latestLicense) return false
const normalize = (l: unknown): string =>

Check warning on line 329 in app/pages/package/[[org]]/[name].vue

View workflow job for this annotation

GitHub Actions / πŸ€– Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `normalize` does not capture any variables from its parent scope
typeof l === 'string' ? l : ((l as { type?: string })?.type ?? '')
return normalize(currentLicense) !== normalize(latestLicense)
})

const deprecationNotice = computed(() => {
if (!displayVersion.value?.deprecated) return null

Expand Down Expand Up @@ -367,18 +393,6 @@
return chunks.filter(Boolean).join('\n')
})

const hasDependencies = computed(() => {
if (!displayVersion.value) return false
const deps = displayVersion.value.dependencies
const peerDeps = displayVersion.value.peerDependencies
const optionalDeps = displayVersion.value.optionalDependencies
return (
(deps && Object.keys(deps).length > 0) ||
(peerDeps && Object.keys(peerDeps).length > 0) ||
(optionalDeps && Object.keys(optionalDeps).length > 0)
)
})

// Vulnerability count for the stats banner
const vulnCount = computed(() => vulnTree.value?.totalCounts.total ?? 0)
const hasVulnerabilities = computed(() => vulnCount.value > 0)
Expand Down Expand Up @@ -582,9 +596,24 @@
<dt class="text-xs text-fg-subtle uppercase tracking-wider">
{{ $t('package.stats.license') }}
</dt>
<dd class="font-mono text-sm text-fg">
<LicenseDisplay v-if="pkg.license" :license="pkg.license" />
<dd class="font-mono text-sm text-fg flex items-center gap-2 flex-wrap">
<LicenseDisplay
v-if="displayVersion?.license ?? pkg.license"
:license="(displayVersion?.license ?? pkg.license) as string"
/>
<span v-else>{{ $t('package.license.none') }}</span>
<TooltipApp
v-if="licenseChanged"
:text="$t('package.license.changed', { latest: pkg.license })"
position="bottom"
>
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-2xs font-sans rounded bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30 cursor-help"
>
<span class="i-lucide:triangle-alert w-3 h-3" aria-hidden="true" />
{{ $t('package.license.changed_badge') }}
</span>
</TooltipApp>
</dd>
</div>

Expand Down Expand Up @@ -959,7 +988,7 @@

<!-- Dependencies -->
<PackageDependencies
v-if="hasDependencies && resolvedVersion && displayVersion"
v-if="resolvedVersion && displayVersion"
:package-name="pkg.name"
:version="resolvedVersion"
:dependencies="displayVersion.dependencies"
Expand Down Expand Up @@ -1107,7 +1136,7 @@
'install sidebar'
'vulns sidebar'
'readme sidebar';
grid-template-rows: auto auto auto auto 1fr;
grid-template-rows: auto auto auto auto;
}
}

Expand Down Expand Up @@ -1159,6 +1188,7 @@

.areaSidebar {
grid-area: sidebar;
align-self: start;
}

@media (max-width: 639.9px) {
Expand Down
7 changes: 5 additions & 2 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,8 @@
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
"outdated_patch": "Patch update available (latest: {latest})",
"has_replacement": "This dependency has suggested replacements",
"vulnerabilities_count": "{count} vulnerability | {count} vulnerabilities"
"vulnerabilities_count": "{count} vulnerability | {count} vulnerabilities",
"none": "No dependencies"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
Expand Down Expand Up @@ -564,7 +565,9 @@
},
"license": {
"view_spdx": "View license text on SPDX",
"none": "None"
"none": "None",
"changed_badge": "changed",
"changed": "License changed from {latest} in the latest version"
},
"vulnerabilities": {
"tree_found": "{vulns} vulnerability in {packages}/{total} packages | {vulns} vulnerabilities in {packages}/{total} packages",
Expand Down
9 changes: 9 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,9 @@
},
"vulnerabilities_count": {
"type": "string"
},
"none": {
"type": "string"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -1698,6 +1701,12 @@
},
"none": {
"type": "string"
},
"changed_badge": {
"type": "string"
},
"changed": {
"type": "string"
}
},
"additionalProperties": false
Expand Down
61 changes: 61 additions & 0 deletions test/unit/app/utils/license-change.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'

/**
* Tests for the license change detection logic used in app/pages/package/[[org]]/[name].vue.
*
* The `licenseChanged` computed detects when the license of the currently
* viewed version differs from the package-level (latest) license, so an
* amber "changed" badge can be shown to alert users.
*/

type LicenseValue = string | { type?: string } | undefined | null

function normalize(l: LicenseValue): string {
if (!l) return ''
return typeof l === 'string' ? l : ((l as { type?: string })?.type ?? '')
}

function licenseChanged(currentLicense: LicenseValue, packageLicense: LicenseValue): boolean {
if (!currentLicense || !packageLicense) return false
return normalize(currentLicense) !== normalize(packageLicense)
}

describe('licenseChanged detection', () => {
it('returns false when both licenses are the same string', () => {
expect(licenseChanged('MIT', 'MIT')).toBe(false)
})

it('returns true when a non-latest version has a different license', () => {
// e.g. old version had GPL, latest has MIT
expect(licenseChanged('GPL-2.0-only', 'MIT')).toBe(true)
})

it('returns false when current license is missing', () => {
expect(licenseChanged(null, 'MIT')).toBe(false)
expect(licenseChanged(undefined, 'MIT')).toBe(false)
})

it('returns false when package license is missing', () => {
expect(licenseChanged('MIT', null)).toBe(false)
expect(licenseChanged('MIT', undefined)).toBe(false)
})

it('returns false when both licenses are missing', () => {
expect(licenseChanged(null, null)).toBe(false)
})

it('normalizes object-shaped licenses for comparison', () => {
// Some old package.json use { type: "MIT" } instead of "MIT"
expect(licenseChanged({ type: 'MIT' }, 'MIT')).toBe(false)
expect(licenseChanged('MIT', { type: 'MIT' })).toBe(false)
expect(licenseChanged({ type: 'GPL-2.0' }, { type: 'MIT' })).toBe(true)
})

it('returns true for Apache-2.0 changed to MIT (real-world case)', () => {
expect(licenseChanged('Apache-2.0', 'MIT')).toBe(true)
})

it('returns false for complex SPDX expression that matches', () => {
expect(licenseChanged('MIT OR Apache-2.0', 'MIT OR Apache-2.0')).toBe(false)
})
})
Loading