Skip to content

Commit

Permalink
feat: Add REUSE license plugin to extract license information for bui…
Browse files Browse the repository at this point in the history
…lt assets

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Jul 15, 2024
1 parent fd8d907 commit 888a0fe
Show file tree
Hide file tree
Showing 4 changed files with 303 additions and 4 deletions.
17 changes: 16 additions & 1 deletion 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
*/
extractLicenseInformation?: true | REUSELicensesPluginOptions
}

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

if (options.extractLicenseInformation) {
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)
plugins.push(
EmptyJSDirPlugin(
typeof options.emptyOutputDirectory === 'object'
? options.emptyOutputDirectory
: undefined
: undefined,
),
)
}
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

Check warning on line 40 in lib/plugins/REUSELicensesPlugin.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Invalid JSDoc tag name "default:"
*/
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

0 comments on commit 888a0fe

Please sign in to comment.