@mendix/pluggable-widgets-tools
Version:
Mendix Pluggable Widgets Tools
325 lines (290 loc) • 12.3 kB
JavaScript
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import fg from "fast-glob";
import fsExtra from "fs-extra";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { dirname, join, parse } from "path";
import copy from "recursive-copy";
import { promisify } from "util";
import resolve from "resolve";
import _ from "lodash";
import moment from "moment";
import mkdirp from "mkdirp";
import { LICENSE_GLOB } from "./common/glob.mjs";
const { readJson, writeJson } = fsExtra;
const dependencies = [];
export function collectDependencies({
onlyNative,
outputDir,
widgetName,
licenseOptions = null,
copyJsModules = true
}) {
const licensePlugin = new LicensePlugin(licenseOptions);
const managedDependencies = [];
let rollupOptions;
return {
name: "collect-native-deps",
async buildStart(options) {
rollupOptions = options;
managedDependencies.length = 0;
},
async resolveId(source, importer) {
// eslint-disable-next-line no-control-regex
const sourceCleanedNullChar = source.replace(/\x00/g, "");
if (sourceCleanedNullChar.startsWith(".") || sourceCleanedNullChar.startsWith("/")) {
return null;
}
const resolvedPackagePath = await resolvePackage(
source,
dirname(importer ? importer : rollupOptions.input[0])
);
if (resolvedPackagePath) {
const isNotOnlyNativeOrHasNativeCode = !onlyNative || (await hasNativeCode(resolvedPackagePath));
if (isNotOnlyNativeOrHasNativeCode && !managedDependencies.includes(resolvedPackagePath)) {
managedDependencies.push(resolvedPackagePath);
}
if (!dependencies.some(dependency => dependency.packagePath === resolvedPackagePath)) {
dependencies.push({ packagePath: resolvedPackagePath, isTransitive: false });
}
return isNotOnlyNativeOrHasNativeCode ? { external: true, id: source } : null;
}
return null;
},
async generateBundle() {
if (!licenseOptions) {
return;
}
for (const dependency of dependencies) {
const packageJson = await scanDependency(dependency.packagePath);
if (packageJson) {
licensePlugin.addDependency(packageJson, dependency);
}
const transitiveDependencies = await getTransitiveDependencies(
dependency.packagePath,
rollupOptions.external
);
for (const transitiveDependency of transitiveDependencies) {
if (!dependencies.some(s => s.packagePath === transitiveDependency)) {
dependencies.push({ packagePath: transitiveDependency, isTransitive: true });
}
}
}
licensePlugin.config();
},
async writeBundle() {
if (!copyJsModules) {
return;
}
const nativeDependencies = new Set(
onlyNative ? managedDependencies : await asyncWhere(managedDependencies, hasNativeCode)
);
for (const dependency of managedDependencies) {
const destinationPath = join(outputDir, "node_modules", getModuleName(dependency));
await copyJsModule(dependency, destinationPath);
const transitiveDependencies = await getTransitiveDependencies(dependency, rollupOptions.external);
for (const transitiveDependency of transitiveDependencies) {
if (await hasNativeCode(transitiveDependency)) {
nativeDependencies.add(dependency);
if (!managedDependencies.includes(transitiveDependency)) {
managedDependencies.push(transitiveDependency);
}
} else if (!transitiveDependency.startsWith(dependency)) {
await copyJsModule(
transitiveDependency,
join(destinationPath, "node_modules", getModuleName(transitiveDependency))
);
}
}
}
await writeNativeDependenciesJson(nativeDependencies, outputDir, widgetName);
}
};
}
async function resolvePackage(target, sourceDir, optional = false) {
const targetParts = target.split("/");
const targetPackage = targetParts[0].startsWith("@") ? `${targetParts[0]}/${targetParts[1]}` : targetParts[0];
try {
return dirname(await promisify(resolve)(join(targetPackage, "package.json"), { basedir: sourceDir }));
} catch (e) {
if (
e.message.includes("Cannot find module") &&
!/\.((j|t)sx?)|json|(pn|jpe?|sv)g|(tif|gi)f$/g.test(targetPackage) &&
!/configs\/jsActions/i.test(import.meta.url) && // Ignore errors about missing package.json in 'jsActions/**/src/*' folders
!optional // Certain (peer)dependencies can be optional, ignore throwing an error if an optional (peer)dependency is considered missing.
) {
throw e;
}
return undefined;
}
}
async function hasNativeCode(dir) {
return (await fg(["**/{android,ios}/*", "**/*.podspec"], { cwd: dir })).length > 0;
}
async function getTransitiveDependencies(packagePath, isExternal) {
const queue = [packagePath];
const result = new Set();
while (queue.length) {
const nextPath = queue.shift();
if (result.has(nextPath)) {
continue;
}
result.add(nextPath);
const packageJson = await readJson(join(nextPath, "package.json"));
const dependencies = Object.keys(packageJson.dependencies || {}).concat(
Object.keys(packageJson.peerDependencies || {})
);
const optionalDependencies = Object.keys(packageJson.optionalDependencies || {}); // certain dependencies can be optionally available, described in package.json `optionalDependencies`.
const optionalPeerDependencies = Object.entries(packageJson.peerDependenciesMeta || {}) // certain peerDependencies can be optionally available, described in package.json `peerDependencyMeta`.
.filter(dependency => !!dependency[1].optional)
.map(dependency => dependency[0]);
for (const dependency of dependencies) {
if (isExternal(dependency)) {
continue;
}
const resolvedPackagePath = await resolvePackage(
dependency,
nextPath,
optionalDependencies.includes(dependency) || optionalPeerDependencies.includes(dependency)
);
if (!resolvedPackagePath) {
continue;
}
queue.push(resolvedPackagePath);
}
}
return Array.from(result);
}
async function copyJsModule(moduleSourcePath, to) {
if (existsSync(to)) {
return;
}
return promisify(copy)(moduleSourcePath, to, {
filter: [
"**/*.*",
LICENSE_GLOB,
"!**/{android,ios,windows,mac,jest,github,gradle,__*__,docs,jest,example*}/**/*",
"!**/*.{config,setup}.*",
"!**/*.{podspec,flow}"
]
});
}
function getModuleName(modulePath) {
return modulePath.split(/[\\/]node_modules[\\/]/).pop();
}
async function writeNativeDependenciesJson(nativeDependencies, outputDir, widgetName) {
if (nativeDependencies.size === 0) {
return;
}
const dependencies = {};
for (const dependency of nativeDependencies) {
const dependencyJson = await readJson(join(dependency, "package.json"));
dependencies[dependencyJson.name] = dependencyJson.version;
}
await writeJson(join(outputDir, `${widgetName}.json`), { nativeDependencies: dependencies }, { spaces: 2 });
}
async function asyncWhere(array, filter) {
return (await Promise.all(array.map(async el => ((await filter(el)) ? [el] : [])))).flat();
}
async function scanDependency(dir) {
const packageJsonPath = join(dir, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
const packageJson = JSON.parse(readFileSync(packageJsonPath));
const license = packageJson.license || packageJson.licenses;
const hasLicense = license && license.length > 0;
const name = packageJson.name;
if (!name && !hasLicense) {
return null;
}
const absolutePath = join(dir, LICENSE_GLOB);
const licenseFile = (await fg([absolutePath], { cwd: dir, caseSensitiveMatch: false }))[0];
if (licenseFile) {
packageJson.licenseText = readFileSync(licenseFile, "utf-8");
}
return packageJson;
}
class LicensePlugin {
constructor(options) {
this._options = options;
this._dependencies = {};
}
addDependency(packageJson, { isTransitive }) {
const name = packageJson.name;
if (!name) {
console.warn("Trying to add dependency without any name, skipping it!");
} else if (!_.has(this._dependencies, name)) {
this._dependencies[name] = new Dependency({ ...packageJson, isTransitive });
}
}
config() {
if (!this._options) {
return;
}
const thirdParty = this._options.thirdParty;
if (!thirdParty) {
return;
}
const thirdPartyOutput = thirdParty.output;
if (thirdPartyOutput) {
_.forEach(_.castArray(thirdPartyOutput), output => {
this._exportThirdPartiesToOutput(_.chain(this._dependencies).values().value(), output);
});
}
}
_exportThirdPartiesToOutput(outputDependencies, output) {
if (_.isFunction(output)) {
output(outputDependencies);
return;
}
const template = _.isString(output.template)
? dependencies => _.template(output.template)({ dependencies, _, moment })
: output.template;
const defaultTemplate = dependencies =>
_.isEmpty(dependencies) ? "" : _.map(dependencies, d => d.text()).join("\n\n---\n\n");
const text = _.isFunction(template) ? template(outputDependencies) : defaultTemplate(outputDependencies);
const isOutputFile = _.isString(output);
const file = isOutputFile ? output : output.file;
const encoding = isOutputFile ? "utf-8" : output.encoding || "utf-8";
mkdirp.sync(parse(file).dir);
writeFileSync(file, (text || "").trim(), { encoding });
}
}
class Dependency {
constructor(packageJson) {
this.name = packageJson.name || null;
this.maintainers = packageJson.maintainers || [];
this.version = packageJson.version || null;
this.description = packageJson.description || null;
this.repository = packageJson.repository || null;
this.homepage = packageJson.homepage || null;
this.private = packageJson.private || false;
this.license = packageJson.license || null;
this.licenseText = packageJson.licenseText || null;
this.isTransitive = packageJson.isTransitive || false;
}
text() {
const lines = [];
lines.push(`Name: ${this.name}`);
lines.push(`Version: ${this.version}`);
lines.push(`License: ${this.license}`);
lines.push(`Private: ${this.private}`);
if (this.description) {
lines.push(`Description: ${this.description || false}`);
}
if (this.repository) {
const url = typeof this.repository === "string" ? this.repository : this.repository.url;
lines.push(`Repository: ${url}`);
}
if (this.homepage) {
lines.push(`Homepage: ${this.homepage}`);
}
if (this.licenseText) {
lines.push("License Copyright:");
lines.push("===");
lines.push("");
lines.push(this.licenseText);
}
return lines.join("\n");
}
}