-
Notifications
You must be signed in to change notification settings - Fork 12k
/
Copy pathlicense-extractor.ts
176 lines (156 loc) · 5.9 KB
/
license-extractor.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import type { Metafile } from 'esbuild';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
/**
* The path segment used to signify that a file is part of a package.
*/
const NODE_MODULE_SEGMENT = 'node_modules';
/**
* String constant for the NPM recommended custom license wording.
*
* See: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#license
*
* Example:
* ```
* {
* "license" : "SEE LICENSE IN <filename>"
* }
* ```
*/
const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN ';
/**
* A list of commonly named license files found within packages.
*/
const LICENSE_FILES = ['LICENSE', 'LICENSE.txt', 'LICENSE.md'];
/**
* Header text that will be added to the top of the output license extraction file.
*/
const EXTRACTION_FILE_HEADER = '';
/**
* The package entry separator to use within the output license extraction file.
*/
const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n';
/**
* Extracts license information for each node module package included in the output
* files of the built code. This includes JavaScript and CSS output files. The esbuild
* metafile generated during the bundling steps is used as the source of information
* regarding what input files where included and where they are located. A path segment
* of `node_modules` is used to indicate that a file belongs to a package and its license
* should be include in the output licenses file.
*
* The package name and license field are extracted from the `package.json` file for the
* package. If a license file (e.g., `LICENSE`) is present in the root of the package, it
* will also be included in the output licenses file.
*
* @param metafile An esbuild metafile object.
* @param rootDirectory The root directory of the workspace.
* @returns A string containing the content of the output licenses file.
*/
export async function extractLicenses(metafile: Metafile, rootDirectory: string) {
let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`;
const seenPaths = new Set<string>();
const seenPackages = new Set<string>();
for (const entry of Object.values(metafile.outputs)) {
for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) {
// Skip if not included in output
if (bytesInOutput <= 0) {
continue;
}
// Skip already processed paths
if (seenPaths.has(inputPath)) {
continue;
}
seenPaths.add(inputPath);
// Skip non-package paths
if (!inputPath.includes(NODE_MODULE_SEGMENT)) {
continue;
}
// Extract the package name from the path
let baseDirectory = path.join(rootDirectory, inputPath);
let nameOrScope, nameOrFile;
let found = false;
while (baseDirectory !== path.dirname(baseDirectory)) {
const segment = path.basename(baseDirectory);
if (segment === NODE_MODULE_SEGMENT) {
found = true;
break;
}
nameOrFile = nameOrScope;
nameOrScope = segment;
baseDirectory = path.dirname(baseDirectory);
}
// Skip non-package path edge cases that are not caught in the includes check above
if (!found || !nameOrScope) {
continue;
}
const packageName = nameOrScope.startsWith('@')
? `${nameOrScope}/${nameOrFile}`
: nameOrScope;
const packageDirectory = path.join(baseDirectory, packageName);
// Load the package's metadata to find the package's name, version, and license type
const packageJsonPath = path.join(packageDirectory, 'package.json');
let packageJson;
try {
packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
name: string;
version: string;
// The object form is deprecated and should only be present in old packages
license?: string | { type: string };
};
} catch {
// Invalid package
continue;
}
// Skip already processed packages
const packageId = `${packageName}@${packageJson.version}`;
if (seenPackages.has(packageId)) {
continue;
}
seenPackages.add(packageId);
// Attempt to find license text inside package
let licenseText = '';
if (
typeof packageJson.license === 'string' &&
packageJson.license.toLowerCase().startsWith(CUSTOM_LICENSE_TEXT)
) {
// Attempt to load the package's custom license
let customLicensePath;
const customLicenseFile = path.normalize(
packageJson.license.slice(CUSTOM_LICENSE_TEXT.length + 1).trim(),
);
if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) {
// Path is attempting to access files outside of the package
// TODO: Issue warning?
} else {
customLicensePath = path.join(packageDirectory, customLicenseFile);
try {
licenseText = await readFile(customLicensePath, 'utf-8');
break;
} catch {}
}
} else {
// Search for a license file within the root of the package
for (const potentialLicense of LICENSE_FILES) {
const packageLicensePath = path.join(packageDirectory, potentialLicense);
try {
licenseText = await readFile(packageLicensePath, 'utf-8');
break;
} catch {}
}
}
// Generate the package's license entry in the output content
extractedLicenseContent += `Package: ${packageJson.name}\n`;
extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`;
extractedLicenseContent += `\n${licenseText}\n`;
extractedLicenseContent += EXTRACTION_FILE_SEPARATOR;
}
}
return extractedLicenseContent;
}