diff --git a/docs/config/build-options.md b/docs/config/build-options.md index c6f667ee120743..e23660368b3e90 100644 --- a/docs/config/build-options.md +++ b/docs/config/build-options.md @@ -185,6 +185,71 @@ export default defineConfig({ }) ``` +## build.license + +- **Type:** `boolean | { fileName?: string }` +- **Default:** `false` + +When set to `true`, the build will generate a `.vite/license.md` file that includes all bundled dependencies' licenses. It can be hosted to display and acknowledge the dependencies used by the app. When `fileName` is passed, it will be used as the license file name relative to the `outDir`. An example output may look like this: + +```md +# Licenses + +The app bundles dependencies which contain the following licenses: + +## dep-1 - 1.2.3 (CC0-1.0) + +CC0 1.0 Universal + +... + +## dep-2 - 4.5.6 (MIT) + +MIT License + +... +``` + +If the `fileName` ends with `.json`, the raw JSON metadata will be generated instead and can be used for further processing. For example: + +```json +[ + { + "name": "dep-1", + "version": "1.2.3", + "identifier": "CC0-1.0", + "text": "CC0 1.0 Universal\n\n..." + }, + { + "name": "dep-2", + "version": "4.5.6", + "identifier": "MIT", + "text": "MIT License\n\n..." + } +] +``` + +::: tip +If you'd like to reference the license file in the built code, you can use `build.rollupOptions.output.banner` to inject a comment at the top of the files. For example: + +```js twoslash [vite.config.js] +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + license: true, + rollupOptions: { + output: { + banner: + '/* See licenses of bundled dependencies at https://example.com/license.md */', + }, + }, + }, +}) +``` + +::: + ## build.manifest - **Type:** `boolean | string` diff --git a/packages/vite/src/node/__tests__/plugins/__snapshots__/license.spec.ts.snap b/packages/vite/src/node/__tests__/plugins/__snapshots__/license.spec.ts.snap new file mode 100644 index 00000000000000..d93b6c9515daa3 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/__snapshots__/license.spec.ts.snap @@ -0,0 +1,47 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`json 1`] = ` +"[ + { + "name": "@vitejs/test-dep-licence-cc0", + "version": "0.0.0", + "identifier": "CC0-1.0", + "text": "CC0 1.0 Universal\\n\\n..." + }, + { + "name": "@vitejs/test-dep-license-mit", + "version": "0.0.0", + "identifier": "MIT", + "text": "MIT License\\n\\nCopyright (c) ..." + }, + { + "name": "@vitejs/test-dep-nested-license-isc", + "version": "0.0.0", + "identifier": "ISC", + "text": "Copyright (c) ..." + } +]" +`; + +exports[`markdown 1`] = ` +"# Licenses + +The app bundles dependencies which contain the following licenses: + +## @vitejs/test-dep-licence-cc0 - 0.0.0 (CC0-1.0) + +CC0 1.0 Universal + +... + +## @vitejs/test-dep-license-mit - 0.0.0 (MIT) + +MIT License + +Copyright (c) ... + +## @vitejs/test-dep-nested-license-isc - 0.0.0 (ISC) + +Copyright (c) ... +" +`; diff --git a/packages/vite/src/node/__tests__/plugins/esbuild.spec.ts b/packages/vite/src/node/__tests__/plugins/esbuild.spec.ts index 22952ca72da04e..79cd8b4f3da6a0 100644 --- a/packages/vite/src/node/__tests__/plugins/esbuild.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/esbuild.spec.ts @@ -23,7 +23,6 @@ describe('resolveEsbuildTranspileOptions', () => { 'es', ) expect(options).toEqual({ - charset: 'utf8', loader: 'js', target: 'es2020', format: 'esm', @@ -67,7 +66,6 @@ describe('resolveEsbuildTranspileOptions', () => { 'es', ) expect(options).toEqual({ - charset: 'utf8', loader: 'js', target: undefined, format: 'esm', @@ -98,7 +96,6 @@ describe('resolveEsbuildTranspileOptions', () => { 'es', ) expect(options).toEqual({ - charset: 'utf8', loader: 'js', target: 'es2020', format: 'esm', @@ -131,7 +128,6 @@ describe('resolveEsbuildTranspileOptions', () => { 'es', ) expect(options).toEqual({ - charset: 'utf8', loader: 'js', target: undefined, format: 'esm', @@ -164,7 +160,6 @@ describe('resolveEsbuildTranspileOptions', () => { 'cjs', ) expect(options).toEqual({ - charset: 'utf8', loader: 'js', target: undefined, format: 'cjs', @@ -196,7 +191,6 @@ describe('resolveEsbuildTranspileOptions', () => { 'es', ) expect(options).toEqual({ - charset: 'utf8', loader: 'js', target: undefined, format: 'esm', @@ -232,7 +226,6 @@ describe('resolveEsbuildTranspileOptions', () => { 'cjs', ) expect(options).toEqual({ - charset: 'utf8', loader: 'js', target: undefined, format: 'cjs', diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/index.js b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/index.js new file mode 100644 index 00000000000000..60c71f346d9a3e --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/index.js @@ -0,0 +1 @@ +export default 'ok' diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/licence b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/licence new file mode 100644 index 00000000000000..7837407e70efb5 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/licence @@ -0,0 +1,3 @@ +CC0 1.0 Universal + +... diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/package.json b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/package.json new file mode 100644 index 00000000000000..9e4c8b22a55c6f --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0/package.json @@ -0,0 +1,7 @@ +{ + "name": "@vitejs/test-dep-licence-cc0", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "CC0-1.0" +} diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/index.js b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/index.js new file mode 100644 index 00000000000000..42bd75ecce8bd7 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/index.js @@ -0,0 +1,3 @@ +import nestedDep from '@vitejs/test-dep-nested-license-isc' + +export default 'ok' + nestedDep diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/license b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/license new file mode 100644 index 00000000000000..1732da241e5252 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/license @@ -0,0 +1,3 @@ +MIT License + +Copyright (c) ... diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/package.json b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/package.json new file mode 100644 index 00000000000000..4b4a49eb85ca76 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit/package.json @@ -0,0 +1,10 @@ +{ + "name": "@vitejs/test-dep-license-mit", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "MIT", + "dependencies": { + "@vitejs/test-dep-nested-license-isc": "file:../dep-nested-license-isc" + } +} diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/LICENSE b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/LICENSE new file mode 100644 index 00000000000000..40ce705e530b07 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/LICENSE @@ -0,0 +1 @@ +Copyright (c) ... diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/index.js b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/index.js new file mode 100644 index 00000000000000..60c71f346d9a3e --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/index.js @@ -0,0 +1 @@ +export default 'ok' diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/package.json b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/package.json new file mode 100644 index 00000000000000..70b3745d3dc0ef --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc/package.json @@ -0,0 +1,7 @@ +{ + "name": "@vitejs/test-dep-nested-license-isc", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "ISC" +} diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/index.html b/packages/vite/src/node/__tests__/plugins/fixtures/license/index.html new file mode 100644 index 00000000000000..b0825ecb300d5b --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/index.html @@ -0,0 +1,5 @@ + diff --git a/packages/vite/src/node/__tests__/plugins/fixtures/license/package.json b/packages/vite/src/node/__tests__/plugins/fixtures/license/package.json new file mode 100644 index 00000000000000..4e06638e94f0cc --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/fixtures/license/package.json @@ -0,0 +1,10 @@ +{ + "name": "@vitejs/test-license", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": { + "@vitejs/test-dep-license-mit": "file:./dep-license-mit", + "@vitejs/test-dep-licence-cc0": "file:./dep-licence-cc0" + } +} diff --git a/packages/vite/src/node/__tests__/plugins/license.spec.ts b/packages/vite/src/node/__tests__/plugins/license.spec.ts new file mode 100644 index 00000000000000..67876e7b900bbe --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/license.spec.ts @@ -0,0 +1,38 @@ +import { fileURLToPath } from 'node:url' +import type { OutputAsset, RollupOutput } from 'rollup' +import { expect, test } from 'vitest' +import { build } from '../../build' + +test('markdown', async () => { + const result = (await build({ + root: fileURLToPath(new URL('./fixtures/license', import.meta.url)), + logLevel: 'silent', + build: { + write: false, + license: true, + }, + })) as RollupOutput + const licenseAsset = result.output.find( + (asset) => asset.fileName === '.vite/license.md', + ) as OutputAsset | undefined + expect(licenseAsset).toBeDefined() + expect(licenseAsset?.source).toMatchSnapshot() +}) + +test('json', async () => { + const result = (await build({ + root: fileURLToPath(new URL('./fixtures/license', import.meta.url)), + logLevel: 'silent', + build: { + write: false, + license: { + fileName: '.vite/license.json', + }, + }, + })) as RollupOutput + const licenseAsset = result.output.find( + (asset) => asset.fileName === '.vite/license.json', + ) as OutputAsset | undefined + expect(licenseAsset).toBeDefined() + expect(licenseAsset?.source).toMatchSnapshot() +}) diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index a26c406134b843..ae52ee134e0681 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -71,6 +71,7 @@ import { getHookHandler } from './plugins' import { BaseEnvironment } from './baseEnvironment' import type { MinimalPluginContextWithoutEnvironment, Plugin } from './plugin' import type { RollupPluginHooks } from './typeUtils' +import { type LicenseOptions, licensePlugin } from './plugins/license' import { BasicMinimalPluginContext, basePluginContextMeta, @@ -209,6 +210,12 @@ export interface BuildEnvironmentOptions { * @default true */ copyPublicDir?: boolean + /** + * Whether to emit a `.vite/license.md` file that includes all bundled dependencies' + * licenses. Pass an object to customize the output file name. + * @default false + */ + license?: boolean | LicenseOptions /** * Whether to emit a .vite/manifest.json in the output dir to map hash-less filenames * to their hashed versions. Useful when you want to generate your own HTML @@ -380,6 +387,7 @@ const _buildEnvironmentOptionsDefaults = Object.freeze({ write: true, emptyOutDir: null, copyPublicDir: true, + license: false, manifest: false, lib: false, // ssr @@ -495,7 +503,12 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ buildEsbuildPlugin(), terserPlugin(config), ...(!config.isWorker - ? [manifestPlugin(), ssrManifestPlugin(), buildReporterPlugin(config)] + ? [ + licensePlugin(), + manifestPlugin(), + ssrManifestPlugin(), + buildReporterPlugin(config), + ] : []), buildLoadFallbackPlugin(), ], diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 544f2d92cdbe76..fca201bdee4bda 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -1518,6 +1518,9 @@ export async function resolveConfig( ? false : { jsxDev: !isProduction, + // change defaults that fit better for vite + charset: 'utf8', + legalComments: 'none', ...config.esbuild, }, server, diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index dbc86b15fb34a1..f58f3870e947e0 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -858,6 +858,7 @@ async function prepareEsbuildOptimizerRun( metafile: true, plugins, charset: 'utf8', + legalComments: 'none', ...esbuildOptions, supported: { ...defaultEsbuildSupported, diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index a6f0bd76ae7514..71e3f6d525999c 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -2194,7 +2194,7 @@ function resolveMinifyCssEsbuildOptions( options: ESBuildOptions, ): TransformOptions { const base: TransformOptions = { - charset: options.charset ?? 'utf8', + charset: options.charset, logLevel: options.logLevel, logLimit: options.logLimit, logOverride: options.logOverride, diff --git a/packages/vite/src/node/plugins/define.ts b/packages/vite/src/node/plugins/define.ts index ef4c8af77a6084..e8f327f598b6c7 100644 --- a/packages/vite/src/node/plugins/define.ts +++ b/packages/vite/src/node/plugins/define.ts @@ -199,7 +199,7 @@ export async function replaceDefine( const result = await transform(code, { loader: 'js', - charset: esbuildOptions.charset ?? 'utf8', + charset: esbuildOptions.charset, platform: 'neutral', define, sourcefile: id, diff --git a/packages/vite/src/node/plugins/esbuild.ts b/packages/vite/src/node/plugins/esbuild.ts index 6e20f7a1146d8b..f3cd9724d4593c 100644 --- a/packages/vite/src/node/plugins/esbuild.ts +++ b/packages/vite/src/node/plugins/esbuild.ts @@ -249,7 +249,6 @@ export function esbuildPlugin(config: ResolvedConfig): Plugin { // and for build as the final optimization is in `buildEsbuildPlugin` const transformOptions: TransformOptions = { target: 'esnext', - charset: 'utf8', ...esbuildTransformOptions, minify: false, minifyIdentifiers: false, @@ -403,7 +402,6 @@ export function resolveEsbuildTranspileOptions( const esbuildOptions = config.esbuild || {} const options: TransformOptions = { - charset: 'utf8', ...esbuildOptions, loader: 'js', target: target || undefined, diff --git a/packages/vite/src/node/plugins/license.ts b/packages/vite/src/node/plugins/license.ts new file mode 100644 index 00000000000000..96d6cde7e5dbe7 --- /dev/null +++ b/packages/vite/src/node/plugins/license.ts @@ -0,0 +1,150 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { Plugin } from '../plugin' +import { isInNodeModules, sortObjectKeys } from '../utils' +import type { PackageCache } from '../packages' +import { findNearestMainPackageData } from '../packages' + +export interface LicenseEntry { + /** + * Package name + */ + name: string + /** + * Package version + */ + version: string + /** + * SPDX license identifier (from package.json "license" field) + */ + identifier?: string + /** + * License file text + */ + text?: string +} + +export interface LicenseOptions { + /** + * The output file name of the license file relative to the output directory. + * Specify a path that ends with `.json` to output the raw JSON metadata. + * + * @default '.vite/license.md' + */ + fileName: string +} + +const licenseConfigDefaults = Object.freeze({ + fileName: '.vite/license.md', +} satisfies LicenseOptions) + +// https://github.com/npm/npm-packlist/blob/53b2a4f42b7fef0f63e8f26a3ea4692e23a58fed/lib/index.js#L284-L286 +const licenseFiles = [/^license/i, /^licence/i, /^copying/i] + +export function licensePlugin(): Plugin { + return { + name: 'vite:license', + + async generateBundle(_, bundle) { + const licenseOption = this.environment.config.build.license + if (licenseOption === false) return + + const packageCache: PackageCache = new Map() + // Track license via a key to its license entry. + // A key consists of "name@version" of a package. + const licenses: Record = {} + + for (const file in bundle) { + const chunk = bundle[file] + if (chunk.type === 'asset') continue + + for (const moduleId of chunk.moduleIds) { + if (moduleId.startsWith('\0') || !isInNodeModules(moduleId)) continue + + // Find the dependency package.json + const pkgData = findNearestMainPackageData( + path.dirname(moduleId), + packageCache, + ) + if (!pkgData) continue + + // Grab the package.json keys and check if already exists in the licenses + const { name, version = '0.0.0', license } = pkgData.data + const key = `${name}@${version}` + if (licenses[key]) continue + + // If not, create a new license entry + const entry: LicenseEntry = { name, version } + if (license) { + entry.identifier = license.trim() + } + const licenseFile = findLicenseFile(pkgData.dir) + if (licenseFile) { + entry.text = fs.readFileSync(licenseFile, 'utf-8').trim() + } + licenses[key] = entry + } + } + + const licenseEntries = Object.values(sortObjectKeys(licenses)) + const licenseOutputFileName = + typeof licenseOption === 'object' + ? licenseOption.fileName + : licenseConfigDefaults.fileName + + // Emit as a JSON file + if (licenseOutputFileName.endsWith('.json')) { + this.emitFile({ + fileName: licenseOutputFileName, + type: 'asset', + source: JSON.stringify(licenseEntries, null, 2), + }) + return + } + + // Emit a license file as markdown + const markdown = licenseEntryToMarkdown(licenseEntries) + this.emitFile({ + fileName: licenseOutputFileName, + type: 'asset', + source: markdown, + }) + }, + } +} + +function licenseEntryToMarkdown(licenses: LicenseEntry[]) { + if (licenses.length === 0) { + return `\ +# Licenses + +The app does not bundle any dependencies with licenses. +` + } + + let text = `\ +# Licenses + +The app bundles dependencies which contain the following licenses: +` + for (const license of licenses) { + const nameAndVersionText = `${license.name} - ${license.version}` + const identifierText = license.identifier ? ` (${license.identifier})` : '' + + text += `\n## ${nameAndVersionText}${identifierText}\n` + if (license.text) { + text += `\n${license.text}\n` + } + } + return text +} + +function findLicenseFile(pkgDir: string) { + const files = fs.readdirSync(pkgDir) + const matchedFile = files.find((file) => + licenseFiles.some((re) => re.test(file)), + ) + if (matchedFile) { + return path.join(pkgDir, matchedFile) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea8c3c4fc538b5..d0711d0387ca5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -484,6 +484,25 @@ importers: specifier: link:../child version: link:../child + packages/vite/src/node/__tests__/plugins/fixtures/license: + dependencies: + '@vitejs/test-dep-licence-cc0': + specifier: file:./dep-licence-cc0 + version: file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0 + '@vitejs/test-dep-license-mit': + specifier: file:./dep-license-mit + version: file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit + + packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0: {} + + packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit: + dependencies: + '@vitejs/test-dep-nested-license-isc': + specifier: file:../dep-nested-license-isc + version: file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc + + packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc: {} + packages/vite/src/node/server/__tests__/fixtures/lerna/nested: {} packages/vite/src/node/server/__tests__/fixtures/none/nested: {} @@ -3705,6 +3724,15 @@ packages: '@vitejs/test-dep-incompatible@file:playground/optimize-deps/dep-incompatible': resolution: {directory: playground/optimize-deps/dep-incompatible, type: directory} + '@vitejs/test-dep-licence-cc0@file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0': + resolution: {directory: packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0, type: directory} + + '@vitejs/test-dep-license-mit@file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit': + resolution: {directory: packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit, type: directory} + + '@vitejs/test-dep-nested-license-isc@file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc': + resolution: {directory: packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc, type: directory} + '@vitejs/test-dep-no-discovery@file:playground/optimize-deps-no-discovery/dep-no-discovery': resolution: {directory: playground/optimize-deps-no-discovery/dep-no-discovery, type: directory} @@ -9322,6 +9350,14 @@ snapshots: '@vitejs/test-dep-incompatible@file:playground/optimize-deps/dep-incompatible': {} + '@vitejs/test-dep-licence-cc0@file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-licence-cc0': {} + + '@vitejs/test-dep-license-mit@file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-license-mit': + dependencies: + '@vitejs/test-dep-nested-license-isc': file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc + + '@vitejs/test-dep-nested-license-isc@file:packages/vite/src/node/__tests__/plugins/fixtures/license/dep-nested-license-isc': {} + '@vitejs/test-dep-no-discovery@file:playground/optimize-deps-no-discovery/dep-no-discovery': {} '@vitejs/test-dep-node-env@file:playground/optimize-deps/dep-node-env': {}