Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add REUSE license plugin to extract license information for built assets #250

Merged
merged 1 commit into from
Aug 5, 2024
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
15 changes: 15 additions & 0 deletions lib/appConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import EmptyJSDirPlugin, { EmptyJSDirPluginOptions } from './plugins/EmptyJSDir.
import replace from '@rollup/plugin-replace'
import injectCSSPlugin from 'vite-plugin-css-injected-by-js'
import { CSSEntryPointsPlugin } from './plugins/CSSEntryPoints.js'
import { REUSELicensesPlugin, REUSELicensesPluginOptions } from './plugins/REUSELicensesPlugin.js'

type VitePluginInjectCSSOptions = Parameters<typeof injectCSSPlugin>[0]

Expand Down Expand Up @@ -71,6 +72,12 @@ export interface AppOptions extends Omit<BaseOptions, 'inlineCSS'> {
* @default 'js/vendor.LICENSE.txt'
*/
thirdPartyLicense?: false | string

/**
* Extract license information from built assets into `.license` files
* This is needed to be REUSE complient
susnux marked this conversation as resolved.
Show resolved Hide resolved
*/
extractLicenseInformation?: true | REUSELicensesPluginOptions
}

/**
Expand Down Expand Up @@ -145,6 +152,14 @@ export const createAppConfig = (entries: { [entryAlias: string]: string }, optio
plugins.push(CSSEntryPointsPlugin({ createEmptyEntryPoints: options.createEmptyCSSEntryPoints }))
}

if (options.extractLicenseInformation && env.mode !== 'development') {
plugins.push(REUSELicensesPlugin(
typeof options.extractLicenseInformation === 'object'
? options.extractLicenseInformation
: {},
))
}

// defaults to true so only not adding if explicitly set to false
if (options?.emptyOutputDirectory !== false) {
// Ensure `js/` is empty as we can not use the build in option (see below)
Expand Down
244 changes: 244 additions & 0 deletions lib/plugins/REUSELicensesPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later OR MIT
*/

import type { Plugin } from 'vite'

import { access, constants, readFile } from 'fs/promises'
import { dirname, isAbsolute, parse } from 'path'
import { cwd } from 'process'
import parseExpression from 'spdx-expression-parse'

interface PackageJSON {
author?: string | { name?: string, mail?: string }
name?: string
version?: string
license?: string
licenses?: ({ type: string } | string)[]
}

export interface REUSELicensesPluginOptions {
/**
* Optional mapping of package names and licenses to allow overwriting when packages do not set the license in the package.json correctly
* @example
* ```js
* {
* foo: 'MIT',
* 'bar@3.0.0': 'ISC',
* }
* ```
*/
overwriteLicenses?: Record<string, string>
/**
* Enable license validation (checking that the license is a valid SPDX identifier)
* @default false
*/
validateLicenses?: boolean
/**
* Enable `.license` files also for sourcemap files
* @default false
*/
includeSourceMaps?: boolean
}

/**
* Plugin to extract `.license` files for every built chunk
* @param options Options to pass to the plugin
*/
export function REUSELicensesPlugin(options: REUSELicensesPluginOptions = {}): Plugin {

options = {
overwriteLicenses: {},
...options,
}

let onError = console.error

const licenseCache = new Map<string, string>()

/**
* Implementation of `verifyLicense`
* @param license The license to verify
* @param name The package name
* @param version The package version
*/
function verifyLicenseImpl(license: string, name?: string, version?: string) {
if (name && name in options.overwriteLicenses) {
return options.overwriteLicenses[name]
}
if (name && version && `${name}@${version}` in options.overwriteLicenses) {
return options.overwriteLicenses[`${name}@${version}`]
}
if (!license) {
onError(`No license information for package ${name} @ ${version}, consider using 'overwriteLicenses' option.`)
return 'unkown'
} else if (options.validateLicenses) {
try {
parseExpression(license)
} catch (e) {
onError(`Invalid license information "${license}" for package ${name} @ ${version}`)
}
}
return license
}

/**
* Verify a license of a specific package
* This will also handle overwriting licenses
* @param license The license to verify
* @param name Package name
* @param version Version
*/
function verifyLicense(license: string, name?: string, version?: string) {
if (licenseCache.has(`${name}@${version}`)) {
return licenseCache.get(`${name}@${version}`)
}
const value = verifyLicenseImpl(license, name, version)
licenseCache.set(`${name}@${version}`, value)
return value
}

/**
* Get the needed fields of the package JSON data
* @param data package JSON data
*/
function neededFields(data: PackageJSON) {
// Handle legacy packages
let license = data.license ?? (
Array.isArray(data.licenses)
? data.licenses.map((entry) => typeof entry === 'object' ? (entry.type ?? entry) : entry).join(' OR ')
: String(data.licenses)
)
license = license.trim()
license = license.includes(' ') && !license.startsWith('(') ? `(${license})` : license

license = verifyLicense(license, data.name, data.version)

// Handle both object style and string style author
const author = typeof data.author === 'object'
? `${data.author.name}` + (data.author.mail ? ` <${data.author.mail}>` : '')
: data.author ?? `${data.name} developers`

return {
author,
license,
name: data.name,
version: data.version,
}
}

const packageCache = new Map<string, ReturnType<typeof neededFields>>()

/**
* Find the nearest package.json
* @param dir Directory to start checking
*/
async function findPackage(dir: string) {
// check cache first
if (packageCache.has(dir)) {
return packageCache.get(dir)
}

// invalid directories
if (!dir || dir === '/' || dir === '.' || dir === dirname(cwd())) {
return null
}

const packageJson = `${dir}/package.json`
try {
await access(packageJson, constants.F_OK)
} catch (e) {
// There is no package.json in this directory so check the one below
packageCache.set(dir, await findPackage(dirname(dir)))
return packageCache.get(dir)
}

let packageInfo = JSON.parse((await readFile(packageJson)).toString())
// "private" is set in internal package.json which should not be resolved but the parent package.json
// Same if no name is set in package.json
if (packageInfo.private === true || !packageInfo.name) {
packageInfo = await findPackage(dirname(dir)) ?? packageInfo
}
packageCache.set(dir, neededFields(packageInfo))
return packageCache.get(dir)
}

/**
* Get the module path from a module name (handle internal vite modules)
* @param name Raw module name
*/
function sanitizeName(name: string): string {
if (name.startsWith('\0')) {
name = name.slice(1)
if (!isAbsolute(name)) {
try {
name = import.meta.resolve(name)
} catch (e) {
try {
name = import.meta.resolve(name.split('/')[0])
} catch (e) {
// nop
}
}
}
}
return parse(name).dir
}

return {
name: 'reuse-licenses',

async renderChunk(_, chunk, config) {
onError = this.error

const modules = new Set<string>()
for (const [moduleName, module] of Object.entries(chunk.modules)) {
if (module.renderedLength > 0) {
modules.add(moduleName)
}
}

const packages = new Set((
await Promise.all(
[...modules.values()]
.map(sanitizeName)
.map(findPackage),
)).filter(Boolean),
)

const sortedPackages = [...packages].sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version, undefined, { numeric: true }))
const authors = new Set(sortedPackages.map(({ author }) => author))
const licenses = new Set<string>()

let source = 'This file is generated from multiple sources. Included packages:\n'
for (const pkg of sortedPackages) {
const license = verifyLicense(pkg.license, pkg.name, pkg.version)
licenses.add(license)
source += `- ${pkg.name}\n\t- version: ${pkg.version}\n\t- license: ${license}\n`
}
source = [...licenses.values()].sort().map((license) => `SPDX-License-Identifier: ${license}`).join('\n')
+ '\n'
+ [...authors.values()].sort().map((author) => `SPDX-FileCopyrightText: ${author}`).join('\n')
+ '\n\n'
+ source

this.emitFile({
name: `${chunk.name}.license`,
fileName: `${chunk.fileName}.license`,
type: 'asset',
source,
})

// Also emit the sourcemap license file (as it includes the sources we need the same licenses)
if (config.sourcemap && config.sourcemap !== 'inline' && options.includeSourceMaps) {
this.emitFile({
name: `${chunk.name}.map.license`,
fileName: `${chunk.fileName}.map.license`,
type: 'asset',
source,
})
}
},
}
}
45 changes: 42 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"rollup-plugin-esbuild-minify": "^1.1.1",
"rollup-plugin-license": "^3.5.2",
"rollup-plugin-node-externals": "^7.1.2",
"spdx-expression-parse": "^4.0.0",
"vite-plugin-css-injected-by-js": "^3.5.1",
"vite-plugin-dts": "^3.9.1",
"vite-plugin-node-polyfills": "^0.22.0"
Expand Down
Loading