import * as path from "path"; import fs from "fs"; import { spawnSync } from "child_process"; import * as webpack from "webpack"; const LicenseCheckerWebpackPlugin = require("license-checker-webpack-plugin"); const config: webpack.Configuration = { entry: { bundle: ["./src/main-webpack-licenses.ts"] }, mode: "production", resolve: { alias: { // Note: Later in this config file, we'll automatically add paths from `tsconfig.compilerOptions.paths` svelte: path.resolve("node_modules", "svelte") }, extensions: [".ts", ".js", ".svelte"], mainFields: ["svelte", "browser", "module", "main"] }, output: { path: path.resolve(__dirname, "dist"), publicPath: "/dist/", filename: "[name].js", chunkFilename: "[name].[id].js" }, module: { rules: [] }, plugins: [ // License Checker Webpack Plugin validates the license compatibility of all dependencies which are compiled into the webpack bundle // It also writes the third-party license notices to a file which is displayed in the application // https://github.com/microsoft/license-checker-webpack-plugin new LicenseCheckerWebpackPlugin({ allow: "(Apache-2.0 OR BSD-2-Clause OR BSD-3-Clause OR MIT OR 0BSD)", emitError: true, outputFilename: "third-party-licenses.txt", outputWriter: formatThirdPartyLicenses, // Workaround for failure caused in WebPack 5: https://github.com/microsoft/license-checker-webpack-plugin/issues/25#issuecomment-833325799 filter: /(^.*[/\\]node_modules[/\\]((?:@[^/\\]+[/\\])?(?:[^@/\\][^/\\]*)))/, }), // new SvelteCheckPlugin(), ], experiments: { asyncWebAssembly: true, }, }; // Load path aliases from the tsconfig.json file const tsconfigPath = path.resolve(__dirname, "tsconfig.json"); const tsconfig = fs.existsSync(tsconfigPath) ? require(tsconfigPath) : {}; if ("compilerOptions" in tsconfig && "paths" in tsconfig.compilerOptions) { const aliases = tsconfig.compilerOptions.paths; for (const alias in aliases) { const paths = aliases[alias].map((p: string) => path.resolve(__dirname, p)); // Our tsconfig uses glob path formats, whereas webpack just wants directories // We'll need to transform the glob format into a format acceptable to webpack const wpAlias = alias.replace(/(\\|\/)\*$/, ""); const wpPaths = paths.map((p: string) => p.replace(/(\\|\/)\*$/, "")); if (config.resolve && config.resolve.alias) { if (!(wpAlias in config.resolve.alias) && wpPaths.length) { (config.resolve.alias as any)[wpAlias] = wpPaths.length > 1 ? wpPaths : wpPaths[0]; } } } } module.exports = config; interface LicenseInfo { licenseName: string; licenseText: string; packages: PackageInfo[] } interface PackageInfo { name: string; version: string; author: string; repository: string; } interface Dependency extends PackageInfo { licenseName: string; licenseText?: string; } function formatThirdPartyLicenses(jsLicenses: {dependencies: Dependency[]}): string { let rustLicenses: LicenseInfo[] | undefined; if (process.env.SKIP_CARGO_ABOUT === undefined) { try { rustLicenses = generateRustLicenses(); } catch (err) { // Nothing to show. Error messages were printed above. } if (rustLicenses === undefined) { // This is probably caused by cargo about not being installed console.error( ` Could not run \`cargo about\`, which is required to generate license information. To install cargo-about on your system, you can run \`cargo install cargo-about\`. License information is required on production builds. Aborting. ` .trim() .split("\n") .map((line) => line.trim()) .join("\n") ); process.exit(1); } } // Remove the HTML character encoding caused by Handlebars let licenses = (rustLicenses || []).map((rustLicense): LicenseInfo => ({ licenseName: htmlDecode(rustLicense.licenseName), licenseText: trimBlankLines(htmlDecode(rustLicense.licenseText)), packages: rustLicense.packages.map((packageInfo): PackageInfo => ({ name: htmlDecode(packageInfo.name), version: htmlDecode(packageInfo.version), author: htmlDecode(packageInfo.author).replace(/\[(.*), \]/, "$1"), repository: htmlDecode(packageInfo.repository), })), })); // De-duplicate any licenses with the same text by merging their lists of packages licenses.forEach((license, licenseIndex) => { licenses.slice(0, licenseIndex).forEach((comparisonLicense) => { if (license.licenseText === comparisonLicense.licenseText) { license.packages.push(...comparisonLicense.packages); comparisonLicense.packages = []; // After emptying the packages, the redundant license with no packages will be removed in the next step's `filter()` } }); }); // Delete the internal Graphite crates, which are not third-party and belong elsewhere licenses = licenses.filter((license) => { license.packages = license.packages.filter((packageInfo) => !(packageInfo.repository && packageInfo.repository.includes("github.com/GraphiteEditor/Graphite"))); return license.packages.length > 0; }); // Augment the imported Rust license list with the provided JS license list jsLicenses.dependencies.forEach((jsLicense) => { const { name, version, author, repository, licenseName } = jsLicense; const licenseText = trimBlankLines(jsLicense.licenseText ?? ""); // Remove the `git+` or `git://` prefix and `.git` suffix const repo = repository ? repository.replace(/^.*(github.com\/.*?\/.*?)(?:.git)/, "https://$1") : repository; const matchedLicense = licenses.find((license) => trimBlankLines(license.licenseText) === licenseText); const packages: PackageInfo = { name, version, author, repository: repo }; if (matchedLicense) matchedLicense.packages.push(packages); else licenses.push({ licenseName, licenseText, packages: [packages] }); }); // Sort the licenses, and the packages using each license, alphabetically licenses.sort((a, b) => a.licenseName.localeCompare(b.licenseName)); licenses.sort((a, b) => a.licenseText.localeCompare(b.licenseText)); licenses.forEach((license) => { license.packages.sort((a, b) => a.name.localeCompare(b.name)); }); // Generate the formatted text file let formattedLicenseNotice = "GRAPHITE THIRD-PARTY SOFTWARE LICENSE NOTICES\n\n"; if (!rustLicenses) formattedLicenseNotice += "WARNING: Licenses for Rust packages are excluded in debug mode to improve performance — do not release without their inclusion!\n\n"; licenses.forEach((license) => { let packagesWithSameLicense = ""; license.packages.forEach((packageInfo) => { const { name, version, author, repository } = packageInfo; packagesWithSameLicense += `${name} ${version}${author ? ` - ${author}` : ""}${repository ? ` - ${repository}` : ""}\n`; }); packagesWithSameLicense = packagesWithSameLicense.trim(); const packagesLineLength = Math.max(...packagesWithSameLicense.split("\n").map((line) => line.length)); formattedLicenseNotice += `-------------------------------------------------------------------------------- The following packages are licensed under the terms of the ${license.licenseName} license as printed beneath: ${"_".repeat(packagesLineLength)} ${packagesWithSameLicense} ${"‾".repeat(packagesLineLength)} ${license.licenseText} `; }); return formattedLicenseNotice; } function generateRustLicenses(): LicenseInfo[] | undefined { console.info("Generating license information for Rust code"); // This `about.hbs` file is written so it generates a valid JavaScript array expression which we evaluate below const { stdout, stderr, status } = spawnSync("cargo", ["about", "generate", "about.hbs"], { cwd: path.join(__dirname, ".."), encoding: "utf8", timeout: 60000, // One minute shell: true, windowsHide: true, // Hide the terminal on Windows }); if (status !== 0) { if (status !== 101) { // Cargo returns 101 when the subcommand wasn't found console.error("cargo-about failed", status, stderr); } return undefined; } // Make sure the output starts with this expected label, we don't want to eval an error message. if (!stdout.trim().startsWith("GENERATED_BY_CARGO_ABOUT:")) { console.error("Unexpected output from cargo-about", stdout); return undefined; } // Security-wise, eval() isn't any worse than require(), but it doesn't need a temporary file. // eslint-disable-next-line no-eval return eval(stdout) as LicenseInfo[]; } function htmlDecode(input: string): string { if (!input) return input; const htmlEntities = { nbsp: " ", copy: "©", reg: "®", lt: "<", gt: ">", amp: "&", apos: "'", quot: `"`, }; return input.replace(/&([^;]+);/g, (entity: string, entityCode: string) => { let match; const maybeEntity = Object.entries(htmlEntities).find(([key, _]) => key === entityCode); if (maybeEntity) { return maybeEntity[1]; } // eslint-disable-next-line no-cond-assign if ((match = entityCode.match(/^#x([\da-fA-F]+)$/))) { return String.fromCharCode(parseInt(match[1], 16)); } // eslint-disable-next-line no-cond-assign if ((match = entityCode.match(/^#(\d+)$/))) { return String.fromCharCode(~~match[1]); } return entity; }); } function trimBlankLines(input: string): string { let result = input.replace(/\r/g, ""); while (result.charAt(0) === "\r" || result.charAt(0) === "\n") { result = result.slice(1); } while (result.slice(-1) === "\r" || result.slice(-1) === "\n") { result = result.slice(0, -1); } return result; }