diff --git a/lib/appConfig.ts b/lib/appConfig.ts index 4a94c15..6ac3833 100644 --- a/lib/appConfig.ts +++ b/lib/appConfig.ts @@ -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[0] @@ -71,6 +72,12 @@ export interface AppOptions extends Omit { * @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 } /** @@ -145,6 +152,14 @@ 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) @@ -152,7 +167,7 @@ export const createAppConfig = (entries: { [entryAlias: string]: string }, optio EmptyJSDirPlugin( typeof options.emptyOutputDirectory === 'object' ? options.emptyOutputDirectory - : undefined + : undefined, ), ) } diff --git a/lib/plugins/REUSELicensesPlugin.ts b/lib/plugins/REUSELicensesPlugin.ts new file mode 100644 index 0000000..179ee7d --- /dev/null +++ b/lib/plugins/REUSELicensesPlugin.ts @@ -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 + /** + * 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() + + /** + * 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>() + + /** + * 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() + 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() + + 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, + }) + } + }, + } +} diff --git a/package-lock.json b/package-lock.json index 7a5c0bf..5df1639 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,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" @@ -3527,6 +3528,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "peer": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/eslint-plugin-n": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-16.0.1.tgz", @@ -6464,15 +6476,24 @@ "spdx-ranges": "^2.0.0" } }, + "node_modules/spdx-compare/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/spdx-exceptions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==" }, "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -6486,6 +6507,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/spdx-expression-validate/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/spdx-license-ids": { "version": "3.0.13", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", @@ -6506,6 +6536,15 @@ "spdx-ranges": "^2.0.0" } }, + "node_modules/spdx-satisfies/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index 49d268d..cdc77a7 100644 --- a/package.json +++ b/package.json @@ -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"