Skip to content
Open
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
52 changes: 52 additions & 0 deletions app/components/LicenseChangeWarning.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useLicenseChanges } from '~/composables/useLicenseChanges'

const props = defineProps<{
license?: string
packageName?: string
resolvedVersion: string | null | undefined
}>()

const licenseChanges = useLicenseChanges(
() => props.packageName,
() => props.resolvedVersion,
)

const changes = computed(() => licenseChanges.data.value?.changes ?? [])

const licenseChangeText = computed(() =>
changes.value
.map(item =>
$t('package.versions.license_change_item', {
from: item.from,
to: item.to,
version: item.version,
}),
)
.join('; '),
)
</script>

<template>
<div
v-if="changes && changes.length > 0"
class="border border-amber-600/40 bg-amber-500/10 rounded-lg mt-1 gap-x-1 py-2 px-3"
:aria-label="$t('package.versions.license_change_help')"
>
<p class="text-md text-amber-800 dark:text-amber-400 flex items-center gap-2">
<span
class="i-lucide:alert-triangle w-4 h-4 flex-shrink-0"
role="img"
:aria-label="$t('package.versions.license_change_help')"
/>
{{ $t('package.versions.license_change_warning') }}
</p>
<p class="text-md text-amber-800 dark:text-amber-400 mt-1">
<i18n-t keypath="package.versions.changed_license" tag="span">
<template #license_change>{{ licenseChangeText }}</template>
</i18n-t>
</p>
</div>
</template>

<style scoped></style>
88 changes: 88 additions & 0 deletions app/composables/useLicenseChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { MaybeRefOrGetter } from 'vue'
import { toValue } from 'vue'

export interface LicenseChange {
from: string
to: string
version: string
}

export interface LicenseChangesResult {
changes: LicenseChange[]
}

// Type definitions for npm registry response
interface NpmRegistryVersion {
version: string
license?: string
}

// for registry responses of $fetch function, the type includes the key versions as well as many others too.
interface NpmRegistryResponse {
time: Record<string, string>
versions: Record<string, NpmRegistryVersion>
}

/**
* Composable to detect license changes across all versions of a package
*/
export function useLicenseChanges(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, another thing here - it may be worth doing this on the server so we don't have to fetch the packument on the client. I think I linked this before, but it's also had some improvements since then. Let me know if you want some help, or don't understand!

https://github.com/npmx-dev/npmx.dev/blob/064cf97ebc89136a267a0c44d757bfaf69212b04/app/composables/useInstallSizeDiff.ts

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ok, i am not familiar with nuxt (working with next.js, react and vue, mostly) but i will check this example in detail and run my composable on server. If i have some questions i will return you. Thanks for pointing this out to me.

packageName: MaybeRefOrGetter<string | null | undefined>,
resolvedVersion: MaybeRefOrGetter<string | null | undefined> = () => undefined,
) {
return useAsyncData<LicenseChangesResult>(
() => {
const name = toValue(packageName)
const version = toValue(resolvedVersion) ?? 'latest'
return `license-changes:${name}:${version}`
},
async () => {
const name = toValue(packageName)
const resolvedVer = toValue(resolvedVersion)
if (!name) return { changes: [] }

// Fetch full package metadata from npm registry
const url = `https://registry.npmjs.org/${name}`
const data = await $fetch<NpmRegistryResponse>(url)

const changes: LicenseChange[] = []

// `data.versions` is an object with version keys
const versions = Object.values(data.versions) as NpmRegistryVersion[]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should only compare to the last version, there is some logic here that could be reused (you can move it into its own utility file in shared too

function getComparisonVersion(pkg: SlimPackument, resolvedVersion: string): string | null {
const isCurrentPrerelease = prerelease(resolvedVersion) !== null
if (isCurrentPrerelease) {
const latest = pkg['dist-tags']?.latest
if (!latest || latest === resolvedVersion) return null
return latest
}
// Find the previous version in time that was stable
const stableVersions = Object.keys(pkg.time)
.filter(v => v !== 'modified' && v !== 'created' && valid(v) !== null && prerelease(v) === null)
.sort((a, b) => compare(a, b))
const currentIdx = stableVersions.indexOf(resolvedVersion)
// Don't compare the second version against the first as the first
// has no baseline so a large size difference is expected
if (currentIdx <= 1) return null
return stableVersions[currentIdx - 1]!
}


// Sort versions ascending to compare chronologically
versions.sort((a, b) => {
const dateA = new Date(data.time[a.version] as string).getTime()
const dateB = new Date(data.time[b.version] as string).getTime()

// Ascending order (oldest to newest)
return dateA - dateB
})

// When resolvedVer is not provided, check changes across all versions
const targetVersion = resolvedVer ?? versions[versions.length - 1]?.version

if (targetVersion) {
const resolvedIndex = versions.findIndex(v => v.version === targetVersion)

if (resolvedIndex > 0) {
const currentLicense = (versions[resolvedIndex]?.license as string) ?? 'UNKNOWN'
const previousLicense = (versions[resolvedIndex - 1]?.license as string) ?? 'UNKNOWN'

if (currentLicense !== previousLicense) {
changes.push({
from: previousLicense,
to: currentLicense,
version: (versions[resolvedIndex]?.version as string) || 'UNKNOWN',
})
}
}
}
return { changes }
},
{
default: () => ({ changes: [] }),
watch: [() => toValue(packageName), () => toValue(resolvedVersion)],
},
)
}
2 changes: 2 additions & 0 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,8 @@ const showSkeleton = shallowRef(false)
</section>

<div class="space-y-6" :class="$style.areaVulns">
<!-- license change warning -->
<LicenseChangeWarning :packageName="pkg.name" :resolvedVersion="resolvedVersion" />
<!-- Bad package warning -->
<PackageReplacement v-if="moduleReplacement" :replacement="moduleReplacement" />
<!-- Size / dependency increase notice -->
Expand Down
4 changes: 4 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,11 @@
"filter_placeholder": "Filter by semver (e.g. ^3.0.0)",
"filter_invalid": "Invalid semver range",
"filter_help": "Semver range filter help",
"license_change_help": "License Change Details",
"license_change_item": "from {from} to {to} at version {version}",
"filter_tooltip": "Filter versions using a {link}. For example, ^3.0.0 shows all 3.x versions.",
"changed_license": "The license was changed {license_change}",
Comment on lines +436 to +438
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Currently it's nesting the keys right? I think they should be one key, so it'd be like The license was changed {from} to {to}. I also don't think we need the {version} since it's on the version page, wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

agree on that. thanks, as we changed the logic from checking all versions to last version, there is really no meaning to show it anymore, even if it runs the logic on resolvedVersion, which user can change by selecting from version selector.
I will check the keys, so that it will be simpler.

"license_change_warning": "License change!",
"filter_tooltip_link": "semver range",
"no_matches": "No versions match this range",
"copy_alt": {
Expand Down
4 changes: 4 additions & 0 deletions i18n/locales/tr-TR.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,11 @@
"filter_placeholder": "Semver ile filtrele (örn. ^3.0.0)",
"filter_invalid": "Geçersiz semver aralığı",
"filter_help": "Semver aralığı filtresi yardımı",
"license_change_help": "Lisans değişikliği yardımı",
"license_change_item": "{version} sürümünde {from}'den {to}'ya",
"filter_tooltip": "Sürümleri {link} kullanarak filtreleyin. Örneğin, ^3.0.0 tüm 3.x sürümlerini gösterir.",
"changed_license": "Lisans değişikliği gerçekleşti: {license_change}",
"license_change_warning": "Lisans değişikliği!",
"filter_tooltip_link": "semver aralığı",
"no_matches": "Bu aralığa uygun sürüm yok",
"copy_alt": {
Expand Down
12 changes: 12 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1300,9 +1300,21 @@
"filter_help": {
"type": "string"
},
"license_change_help": {
"type": "string"
},
"license_change_item": {
"type": "string"
},
"filter_tooltip": {
"type": "string"
},
"changed_license": {
"type": "string"
},
"license_change_warning": {
"type": "string"
},
"filter_tooltip_link": {
"type": "string"
},
Expand Down
17 changes: 17 additions & 0 deletions test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ import {
PackageSelectionView,
PackageSelectionCheckbox,
PackageExternalLinks,
LicenseChangeWarning,
ChartSplitSparkline,
TabRoot,
TabList,
Expand Down Expand Up @@ -349,6 +350,22 @@ describe('component accessibility audits', () => {
})
})

describe('LicenseChangeWarning', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(LicenseChangeWarning, {
props: {
packageName: 'vue',
resolvedVersion: '3.4.0',
},
global: {
mocks: { $t: (key: string) => key },
stubs: { 'i18n-t': { template: '<span><slot name="license_change" /></span>' } },
},
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})
})
describe('AppLogo', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(AppLogo)
Expand Down
Loading