Skip to content

Commit 0eb276c

Browse files
committed
feat: add clickable licences
1 parent 1339305 commit 0eb276c

8 files changed

Lines changed: 492 additions & 1 deletion

File tree

app/components/LicenseDisplay.vue

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script setup lang="ts">
2+
import { parseLicenseExpression } from '#shared/utils/spdx'
3+
4+
const props = defineProps<{
5+
license: string
6+
}>()
7+
8+
const tokens = computed(() => parseLicenseExpression(props.license))
9+
10+
const hasAnyValidLicense = computed(() => tokens.value.some(t => t.type === 'license' && t.url))
11+
</script>
12+
13+
<template>
14+
<span class="inline-flex items-baseline gap-x-1.5 flex-wrap gap-y-0.5">
15+
<template v-for="(token, i) in tokens" :key="i">
16+
<a
17+
v-if="token.type === 'license' && token.url"
18+
:href="token.url"
19+
target="_blank"
20+
rel="noopener noreferrer"
21+
class="link-subtle"
22+
title="View license text on SPDX"
23+
>
24+
{{ token.value }}
25+
</a>
26+
<span v-else-if="token.type === 'license'">{{ token.value }}</span>
27+
<span v-else-if="token.type === 'operator'" class="text-[0.65em]">{{ token.value }}</span>
28+
</template>
29+
<span
30+
v-if="hasAnyValidLicense"
31+
class="i-carbon-scales w-3.5 h-3.5 text-fg-subtle flex-shrink-0"
32+
aria-hidden="true"
33+
/>
34+
</span>
35+
</template>

app/pages/[...package].vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ defineOgImageComponent('Package', {
483483
<div v-if="pkg.license" class="space-y-1">
484484
<dt class="text-xs text-fg-subtle uppercase tracking-wider">License</dt>
485485
<dd class="font-mono text-sm text-fg">
486-
{{ pkg.license }}
486+
<LicenseDisplay :license="pkg.license" />
487487
</dd>
488488
</div>
489489

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"oxlint": "^1.41.0",
7575
"playwright-core": "1.57.0",
7676
"simple-git-hooks": "2.13.1",
77+
"spdx-license-list": "^6.11.0",
7778
"std-env": "3.10.0",
7879
"typescript": "5.9.3",
7980
"unocss": "66.6.0",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shared/utils/spdx.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import spdxLicenseIds from 'spdx-license-list/spdx-simple.json'
2+
3+
/**
4+
* Set of all valid SPDX license identifiers.
5+
* Sourced from spdx-license-list package which stays up-to-date with
6+
* the official SPDX license list.
7+
*
8+
* @see https://spdx.org/licenses/
9+
*/
10+
export const SPDX_LICENSE_IDS: Set<string> = new Set(spdxLicenseIds)
11+
12+
/**
13+
* Check if a license identifier is a valid SPDX license.
14+
* @see https://spdx.org/licenses/
15+
*/
16+
export function isValidSpdxLicense(license: string): boolean {
17+
return SPDX_LICENSE_IDS.has(license)
18+
}
19+
20+
/**
21+
* Generate an SPDX license URL for the given license identifier.
22+
* Returns null if the license is not a valid SPDX identifier.
23+
* @see https://spdx.org/licenses/
24+
*/
25+
export function getSpdxLicenseUrl(license: string | undefined): string | null {
26+
if (!license) return null
27+
const trimmed = license.trim()
28+
if (!SPDX_LICENSE_IDS.has(trimmed)) return null
29+
return `https://spdx.org/licenses/${trimmed}.html`
30+
}
31+
32+
/**
33+
* Token types for parsed license expressions
34+
*/
35+
export interface LicenseToken {
36+
type: 'license' | 'operator'
37+
value: string
38+
url?: string
39+
}
40+
41+
/**
42+
* Parse an SPDX license expression into tokens.
43+
* Handles compound expressions like "MIT OR Apache-2.0", "(MIT AND Zlib)".
44+
* Strips parentheses for cleaner display.
45+
*
46+
* @example
47+
* parseLicenseExpression('MIT') // [{ type: 'license', value: 'MIT', url: '...' }]
48+
* parseLicenseExpression('MIT OR Apache-2.0')
49+
* // [{ type: 'license', value: 'MIT', url: '...' }, { type: 'operator', value: 'OR' }, { type: 'license', value: 'Apache-2.0', url: '...' }]
50+
*/
51+
export function parseLicenseExpression(expression: string): LicenseToken[] {
52+
const result: LicenseToken[] = []
53+
// Match operators first (OR, AND, WITH), then license IDs - ignore parentheses
54+
// Operators must be checked first to avoid being captured as license identifiers
55+
const pattern = /\b(OR|AND|WITH)\b|([A-Za-z0-9.\-+]+)/g
56+
let match
57+
58+
while ((match = pattern.exec(expression)) !== null) {
59+
if (match[1]) {
60+
// Operator (OR, AND, WITH)
61+
result.push({ type: 'operator', value: match[1] })
62+
} else if (match[2]) {
63+
// License identifier
64+
const id = match[2]
65+
const isValid = isValidSpdxLicense(id)
66+
result.push({
67+
type: 'license',
68+
value: id,
69+
url: isValid ? `https://spdx.org/licenses/${id}.html` : undefined,
70+
})
71+
}
72+
}
73+
74+
return result
75+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { mountSuspended } from '@nuxt/test-utils/runtime'
3+
import LicenseDisplay from '~/components/LicenseDisplay.vue'
4+
5+
describe('LicenseDisplay', () => {
6+
describe('single license', () => {
7+
it('renders a valid SPDX license as a link', async () => {
8+
const component = await mountSuspended(LicenseDisplay, {
9+
props: { license: 'MIT' },
10+
})
11+
const link = component.find('a')
12+
expect(link.exists()).toBe(true)
13+
expect(link.attributes('href')).toBe('https://spdx.org/licenses/MIT.html')
14+
expect(link.text()).toBe('MIT')
15+
})
16+
17+
it('renders an invalid license as plain text', async () => {
18+
const component = await mountSuspended(LicenseDisplay, {
19+
props: { license: 'CustomLicense' },
20+
})
21+
const link = component.find('a')
22+
expect(link.exists()).toBe(false)
23+
expect(component.text()).toContain('CustomLicense')
24+
})
25+
26+
it('shows scales icon for valid license', async () => {
27+
const component = await mountSuspended(LicenseDisplay, {
28+
props: { license: 'MIT' },
29+
})
30+
const icon = component.find('.i-carbon-scales')
31+
expect(icon.exists()).toBe(true)
32+
})
33+
34+
it('does not show scales icon for invalid license', async () => {
35+
const component = await mountSuspended(LicenseDisplay, {
36+
props: { license: 'CustomLicense' },
37+
})
38+
const icon = component.find('.i-carbon-scales')
39+
expect(icon.exists()).toBe(false)
40+
})
41+
})
42+
43+
describe('compound expressions with OR', () => {
44+
it('renders "MIT OR Apache-2.0" with both licenses linked', async () => {
45+
const component = await mountSuspended(LicenseDisplay, {
46+
props: { license: 'MIT OR Apache-2.0' },
47+
})
48+
const links = component.findAll('a')
49+
expect(links).toHaveLength(2)
50+
expect(links[0]?.attributes('href')).toBe('https://spdx.org/licenses/MIT.html')
51+
expect(links[1]?.attributes('href')).toBe('https://spdx.org/licenses/Apache-2.0.html')
52+
// Operator is rendered lowercase
53+
expect(component.text().toLowerCase()).toContain('or')
54+
})
55+
56+
it('renders triple license choice correctly', async () => {
57+
const component = await mountSuspended(LicenseDisplay, {
58+
props: { license: '(BSD-2-Clause OR MIT OR Apache-2.0)' },
59+
})
60+
const links = component.findAll('a')
61+
expect(links).toHaveLength(3)
62+
})
63+
})
64+
65+
describe('compound expressions with AND', () => {
66+
it('renders "MIT AND Zlib" with both licenses linked', async () => {
67+
const component = await mountSuspended(LicenseDisplay, {
68+
props: { license: 'MIT AND Zlib' },
69+
})
70+
const links = component.findAll('a')
71+
expect(links).toHaveLength(2)
72+
expect(links[0]?.attributes('href')).toBe('https://spdx.org/licenses/MIT.html')
73+
expect(links[1]?.attributes('href')).toBe('https://spdx.org/licenses/Zlib.html')
74+
// Operator is rendered lowercase
75+
expect(component.text().toLowerCase()).toContain('and')
76+
})
77+
})
78+
79+
describe('compound expressions with WITH', () => {
80+
it('renders license with exception', async () => {
81+
const component = await mountSuspended(LicenseDisplay, {
82+
props: { license: 'GPL-2.0-only WITH Classpath-exception-2.0' },
83+
})
84+
// GPL-2.0-only is a valid license, Classpath-exception-2.0 is an exception (not a license)
85+
const links = component.findAll('a')
86+
expect(links.length).toBeGreaterThanOrEqual(1)
87+
expect(links[0]?.attributes('href')).toBe('https://spdx.org/licenses/GPL-2.0-only.html')
88+
// Operator is rendered lowercase
89+
expect(component.text().toLowerCase()).toContain('with')
90+
})
91+
})
92+
93+
describe('mixed valid and invalid', () => {
94+
it('renders valid licenses as links and invalid as text', async () => {
95+
const component = await mountSuspended(LicenseDisplay, {
96+
props: { license: 'MIT OR CustomLicense' },
97+
})
98+
const links = component.findAll('a')
99+
expect(links).toHaveLength(1)
100+
expect(links[0]?.text()).toBe('MIT')
101+
expect(component.text()).toContain('CustomLicense')
102+
})
103+
104+
it('shows scales icon when at least one license is valid', async () => {
105+
const component = await mountSuspended(LicenseDisplay, {
106+
props: { license: 'CustomLicense OR MIT' },
107+
})
108+
const icon = component.find('.i-carbon-scales')
109+
expect(icon.exists()).toBe(true)
110+
})
111+
})
112+
113+
describe('parentheses', () => {
114+
it('strips parentheses from expressions for cleaner display', async () => {
115+
const component = await mountSuspended(LicenseDisplay, {
116+
props: { license: '(MIT OR Apache-2.0)' },
117+
})
118+
// Parentheses are stripped for cleaner display
119+
expect(component.text()).not.toContain('(')
120+
expect(component.text()).not.toContain(')')
121+
const links = component.findAll('a')
122+
expect(links).toHaveLength(2)
123+
})
124+
})
125+
126+
describe('real-world examples', () => {
127+
it('handles rc package license: (BSD-2-Clause OR MIT OR Apache-2.0)', async () => {
128+
const component = await mountSuspended(LicenseDisplay, {
129+
props: { license: '(BSD-2-Clause OR MIT OR Apache-2.0)' },
130+
})
131+
const links = component.findAll('a')
132+
expect(links).toHaveLength(3)
133+
expect(links.map(l => l.text())).toEqual(['BSD-2-Clause', 'MIT', 'Apache-2.0'])
134+
})
135+
136+
it('handles jszip package license: (MIT OR GPL-3.0-or-later)', async () => {
137+
const component = await mountSuspended(LicenseDisplay, {
138+
props: { license: '(MIT OR GPL-3.0-or-later)' },
139+
})
140+
const links = component.findAll('a')
141+
expect(links).toHaveLength(2)
142+
})
143+
144+
it('handles pako package license: (MIT AND Zlib)', async () => {
145+
const component = await mountSuspended(LicenseDisplay, {
146+
props: { license: '(MIT AND Zlib)' },
147+
})
148+
const links = component.findAll('a')
149+
expect(links).toHaveLength(2)
150+
})
151+
})
152+
})

test/unit/npm-utils.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@ import { describe, expect, it } from 'vitest'
22

33
import { buildScopeTeam } from '../../app/utils/npm'
44
import { validateScopeTeam } from '../../cli/src/npm-client'
5+
import { getSpdxLicenseUrl } from '../../shared/utils/spdx'
6+
7+
describe('getSpdxLicenseUrl', () => {
8+
it('returns SPDX URL for valid license identifiers', () => {
9+
expect(getSpdxLicenseUrl('MIT')).toBe('https://spdx.org/licenses/MIT.html')
10+
expect(getSpdxLicenseUrl('ISC')).toBe('https://spdx.org/licenses/ISC.html')
11+
expect(getSpdxLicenseUrl('Apache-2.0')).toBe('https://spdx.org/licenses/Apache-2.0.html')
12+
expect(getSpdxLicenseUrl('GPL-3.0-only')).toBe('https://spdx.org/licenses/GPL-3.0-only.html')
13+
expect(getSpdxLicenseUrl('BSD-2-Clause')).toBe('https://spdx.org/licenses/BSD-2-Clause.html')
14+
expect(getSpdxLicenseUrl('GPL-3.0+')).toBe('https://spdx.org/licenses/GPL-3.0+.html')
15+
})
16+
17+
it('trims whitespace from license identifiers', () => {
18+
expect(getSpdxLicenseUrl(' MIT ')).toBe('https://spdx.org/licenses/MIT.html')
19+
})
20+
21+
it('returns null for undefined or empty', () => {
22+
expect(getSpdxLicenseUrl(undefined)).toBeNull()
23+
expect(getSpdxLicenseUrl('')).toBeNull()
24+
expect(getSpdxLicenseUrl(' ')).toBeNull()
25+
})
26+
27+
it('returns null for invalid license identifiers', () => {
28+
// Compound expressions are not in the SPDX list
29+
expect(getSpdxLicenseUrl('MIT OR Apache-2.0')).toBeNull()
30+
// Non-existent licenses
31+
expect(getSpdxLicenseUrl('INVALID-LICENSE')).toBeNull()
32+
expect(getSpdxLicenseUrl('Custom')).toBeNull()
33+
})
34+
})
535

636
describe('buildScopeTeam', () => {
737
it('constructs scope:team with @ prefix', () => {

0 commit comments

Comments
 (0)