import { dirname, parse, resolve, join, basename, normalize } from "path" import { getWorkingDirectory, toPascalCase, sortPriority } from "@factor/api/utils" import { getPath } from "@factor/api/paths" import fs from "fs-extra" import glob from "glob" import log from "@factor/api/logger" import { FactorPackageJson, FactorExtension, ExtendTypes, LoadTargets, NormalizedLoadTarget, LoadTarget, CommandOptions, } from "./types" interface LoaderFile { _id: string file: string priority: number path?: string writeFile?: { filename: string; content: string } } /** * Returns a normalized directory path * Normalization prevents any problems with windows paths * @param rawPath - the raw path */ const nd = (rawPath: string): string => { return normalize(dirname(rawPath)) } /** * Gets the package.json from the CWD (current working directory) * @param cwd - working directory path */ export const getWorkingDirectoryPackage = (cwd?: string): FactorPackageJson => { let pkg try { const p = require(`${getWorkingDirectory(cwd)}/package.json`) if (p.name === "@factor/wrapper") { if (process.env.FACTOR_ENV != "test") { log.warn( "Couldn't generate loaders - Working directory is monorepo/workspace root" ) } } else { pkg = p } } catch (error) { if (error.code === "MODULE_NOT_FOUND") { log.warn("Couldn't generate loaders - working directory has no package.json") } else throw error } return pkg } /** * Gets the resolved 'main' directory path for an extension * @param name - package.json name * @param isCwd - is the package the app? * @param cwd - the working directory * @param main - the main file */ const getDirectory = ({ name, isCwd, cwd, main = "", }: { name: string main?: string isCwd: boolean cwd?: string }): string => { const resolver = isCwd ? getWorkingDirectory(cwd) : name let root if (main) { root = require.resolve(resolver, { paths: [main] }) } else { root = require.resolve(`${resolver}/package.json`) } return nd(root) } /** * Gets the path needed for node resolve taking into account main files and cwd * @param name - package.json name * @param isCwd - is the package the app? * @param cwd - the working directory * @param main - the main file */ const getResolver = ({ name, isCwd, cwd, main = "package.json", }: { name: string main?: string isCwd: boolean cwd?: string }): string => { const resolverRoot = isCwd ? getWorkingDirectory(cwd) : name return `${resolverRoot}/${main}` } /** * Gets the base module require path * Follows the node format rather than path * * @param isCwd - is the package the working directory * @param name - the module name (from package.json) * @param main - the main file of the package */ const getRequireBase = ({ isCwd, name, main = "package.json", }: { isCwd: boolean name: string main?: string }): string => { const mainFile = join(...[isCwd ? ".." : name, main]) return nd(mainFile) } /** * Set ordering priority by file type * @example * - plugin = 100 (default) * - theme = 150 * - app = 1000 * * @param extend - type of extension * @param priority - use assigned priority if its set * @param isCwd - is the package the working directory of the app */ const getPriority = ({ extend, priority, isCwd, }: { extend: ExtendTypes priority?: number name: string isCwd: boolean }): number => { if (priority && priority >= 0) return priority const out = 100 if (isCwd) { return 1000 } else if (extend == ExtendTypes.Theme) { return 150 } return out } /** * Webpack doesn't allow dynamic paths in require statements * In order to make dynamic require statements, we build loader files * * @param extensions - factor extensions * @param loadTarget - the loading environment * @param callback - function to call with the results * @param additional - additional files to add to results (advanced) */ const makeModuleLoader = ({ extensions, loadTarget, callback, additional = [], }: { extensions: FactorExtension[] loadTarget: LoadTargets callback: (files: LoaderFile[]) => void cwd?: string additional?: LoaderFile[] }): void => { const files: LoaderFile[] = [] const filtered = extensions.filter(({ load }) => load[loadTarget]) filtered.forEach((extension) => { const { load, name, isCwd } = extension load[loadTarget].forEach(({ _id = "", file, priority = 100 }) => { const _module = `${isCwd ? ".." : name}/${file}` const moduleName = _module.replace(/\.[^./]+$/, "").replace(/\/index$/, "") files.push({ _id, file: moduleName, priority }) }) }) callback(sortPriority([...files, ...additional])) } /** * Scans Factor directories for a file name via glob * @param extensions - the extensions to scan * @param filename - the name of the file (w glob support) * @param callback - the function to call with the results * @param cwd - working directory * @additional - additional files to add to callback (advanced) */ const makeFileLoader = ({ extensions, filename, callback, cwd, additional = [], }: { extensions: FactorExtension[] filename: string callback: (files: LoaderFile[]) => void cwd?: string loadTarget: LoadTargets additional?: LoaderFile[] }): void => { const files: LoaderFile[] = [] extensions.forEach((_) => { const { name, isCwd, _id, priority } = _ const dir = getDirectory({ name, isCwd, cwd }) const requireBase = getRequireBase({ isCwd, name }) const fileGlob = `${dir}/**/${filename}` glob .sync(fileGlob) .filter((path) => { // Don't include anything inside of node_modules // this isn't very efficient since it searches them anyway, but couldn't make it work otherwise const sub = path.replace(dir, "") return sub.includes("node_modules") ? false : true }) .map((fullPath, index) => { const _module = fullPath.replace(dir, requireBase) const moduleName = _module.replace(/\.[jt]s$/, "").replace(/\/index$/, "") return { _id: index == 0 ? _id : `${_id}_${index}`, file: moduleName, path: fullPath, priority, } }) .forEach((lPath) => { if (lPath) { files.push(lPath) } }) }) callback([...files, ...additional]) } /** * Recursively gets dependencies with 'factor' attribute * Also track modules that are disabled by apps/themes/plugins * @param dependents - dependencies of a package * @param pkg - the calling package.json */ const recursiveDependencies = ( dependents: FactorPackageJson[], pkg: FactorPackageJson, disabled: string[], options?: { shallow?: true } ): { dependents: FactorPackageJson[]; disabled: string[] } => { const { dependencies = {}, factor: { disable = [] } = {} } = pkg disabled = [...disabled, ...disable] Object.keys(dependencies) .map((_) => require(`${_}/package.json`)) .filter((_) => typeof _.factor != "undefined" || _.name.includes("factor")) .forEach((_) => { // don't add if it's already there if (!dependents.find((pkg) => pkg.name == _.name)) { dependents.push(_) if (!options?.shallow) { // Preceding (;) is needed when not using const/let ({ dependents, disabled } = recursiveDependencies( dependents, _, disabled, options )) } } }) return { dependents, disabled } } /** * Gets a standard reference ID based on package.json params */ const getId = ({ _id = "", name = "", main = "index", file = "", isCwd = false, }): string => { let __ if (isCwd) { __ = "cwd" } else if (_id) { __ = _id } else { const afterSlash = name.split(/plugin-|theme-|@factor/gi).pop() ?? "id" __ = afterSlash.replace(/\//gi, "").replace(/-([a-z])/g, (g) => g[1].toUpperCase()) } // Add file specific ID to end if (file && parse(file).name != parse(main).name) { __ += toPascalCase(file) } return __ } const writeFile = ({ destination, content, }: { destination: string content: string }): void => { fs.ensureDirSync(nd(destination)) fs.writeFileSync(destination, content) } export const makeEmptyLoaders = (): void => { const l = ["loader-server", "loader-app", "loader-styles", "loader-settings"] l.forEach((pathId) => { const content = pathId == "loader-styles" ? "" : `` writeFile({ destination: getPath(pathId), content }) }) } /** * Normalize load key from package.json > factor * Allow for both simple syntax or full control * @example * - load: ["app", "server"] - load main on app/server * - load: { * "server": ["_id": "myId", "file": "some-file.js"] * } * * @param object.load - load target "server" or "client" * @param main - main file * @param _id - extension id, helps with naming convention * * @returns normalized object representing desired auto-load */ const normalizeLoadTarget = ({ load, main, _id, isCwd, }: { load: LoadTarget main: string _id: string isCwd: boolean }): NormalizedLoadTarget => { const __: NormalizedLoadTarget = { app: [], server: [] } if (!load) return __ if (Array.isArray(load)) { load.forEach((t) => { __[t] = [{ file: main, _id }] }) } else if (typeof load == "object") { Object.keys(load).forEach((t) => { const val = load[t] if (!Array.isArray(val)) { __[t] = [ { file: join(nd(main), val), _id: getId({ _id, main, file: val, isCwd }) }, ] } else { __[t] = val.map((v) => { return typeof v == "string" ? { file: join(nd(main), v), _id: getId({ _id, main, file: v, isCwd }) } : { ...v, file: join(nd(main), v.file) } }) } }) } return __ } /** * Creates a string import statement to load a file * @param files - module names used to create the string */ const loaderString = (files: LoaderFile[]): string => { return files.map(({ file }) => `import "${file}"`).join("\n") } /** * Creates an import string, that loads things in a priority based order * @param files - module names to write to string */ const loaderStringOrdered = (files: LoaderFile[]): string => { const lines = files.map( ({ _id, file, priority }) => `import { default as ${_id} } from "${file}" // ${priority}` ) lines.push(`\n\nexport default [ ${files.map(({ _id }) => _id).join(", ")} ]`) return lines.join("\n") } /** * Generates a normalized list of extensions to work with * @param packagePaths - All package.json files from Factor extensions * @param packageBase - The base package.json */ export const generateExtensionList = ( packagePaths: FactorPackageJson[], packageBase: FactorPackageJson ): FactorExtension[] => { const loader: FactorExtension[] = [] packagePaths.forEach((_) => { const { name, factor: { priority = -1, load = [], extend = ExtendTypes.Plugin } = {}, version, main = "index", } = _ let { factor: { _id = "" } = {} } = _ const isCwd = packageBase.name == name if (!_id) _id = getId({ _id, name, isCwd }) loader.push({ version, name, main, extend, priority: getPriority({ priority, name, extend, isCwd }), load: normalizeLoadTarget({ load, main, _id, isCwd }), isCwd, _id, }) }) return sortPriority(loader) } /** * Use root application dependencies as the start of the factor dependency tree * Recursively get all factor dependencies * * @param pkg - the root application package.json */ export const loadExtensions = ( pkg: FactorPackageJson, options?: { shallow?: true } ): FactorExtension[] => { const { dependents, disabled } = recursiveDependencies([pkg], pkg, [], options) const deps = dependents.filter((_) => !disabled.includes(_.name)) return generateExtensionList(deps, pkg) } /** * Gets a list of the names of themes/plugins by package name * @param pkg - root package * @param options.shallow - only look at the plugins/themes from root pkg */ export const installedExtensions = ( pkg: FactorPackageJson, options?: { shallow?: true } ): { themes: string[]; plugins: string[] } => { const list = loadExtensions(pkg, options) const themes: string[] = list .filter((item) => item.extend == "theme") .map((_) => _.name) const plugins: string[] = list .filter((item) => item.extend == "plugin") .map((_) => _.name) return { themes, plugins } } /** * Verify that the main files and loading setup from package.json resolves to actual files * Without this check errors occur that don't hint to what is happening * @param extensions - all factor extensions and app */ const verifyMainFiles = (extensions: FactorExtension[], cwd?: string): void | never => { let mainFiles: string[] = [] extensions.forEach(({ isCwd, load: { app, server }, name }) => { [app, server].forEach((environment) => { if (environment.length > 0) { mainFiles.push( ...environment.map((_) => getResolver({ name, isCwd, cwd, main: _.file, }) ) ) } }) }) // remove duplicates mainFiles = mainFiles.filter((item, index) => { return mainFiles.indexOf(item) === index }) mainFiles.forEach((fi) => { try { require.resolve(fi) } catch (error) { throw new Error(`There was a problem resolving a main file (${fi}).`) } }) } /** * Gets Factor extensions based on working directory package.json * * @param cwd - working directory path * * @returns array - list of factor extension */ const __extensions: Record = {} export const getExtensions = (cwd?: string): FactorExtension[] => { const workingDirectory = getWorkingDirectory(cwd) if (__extensions[workingDirectory]) { return __extensions[workingDirectory] } else { const cwdPackage = getWorkingDirectoryPackage(cwd) if (cwdPackage) { const extensions = loadExtensions(cwdPackage) verifyMainFiles(extensions, cwd) __extensions[workingDirectory] = extensions return extensions } else { return [] } } } /** * Gets the directories for current app and all Factor extensions */ export const getFactorDirectories = (): string[] => { return getExtensions().map(({ name, main, isCwd }) => { return getDirectory({ name, main, isCwd }) }) } export const generateLoaders = (options?: CommandOptions): void => { const { cwd, clean, controlFiles } = options || {} const workingDirectory = getWorkingDirectory(cwd) const folders = { generated: resolve(workingDirectory, ".factor"), distribution: resolve(workingDirectory, "dist"), } Object.values(folders).forEach((folder) => { if (clean) { fs.removeSync(folder) } fs.ensureDirSync(folder) }) // Control files allow apps to be customized from other builds // Useful in advanced setups, e.g. added for theme demo const controls: { [key in LoadTargets]?: LoaderFile[] } = {} if (controlFiles) { controlFiles.forEach(({ file, target, writeFile }) => { let _filename if (writeFile) { const { filename, content } = writeFile _filename = filename fs.writeFileSync(join(folders.generated, filename), content) } else if (file) { _filename = basename(file) fs.copySync(file, join(folders.generated, _filename)) } if (_filename) { const filenameBase = _filename.split(".").slice(0, -1).join(".") controls[target] = [ { _id: filenameBase, file: `./${filenameBase}`, priority: 800 }, ] } }) } // Get extensions based on working directory dependencies const extensions = getExtensions(cwd) if (extensions.length == 0) return /** * SERVER MODULES LOADER */ makeModuleLoader({ extensions, loadTarget: LoadTargets.Server, additional: controls[LoadTargets.Server], cwd, callback: (files: LoaderFile[]) => { writeFile({ destination: getPath("loader-server", cwd), content: loaderString(files), }) }, }) /** * APP MODULES LOADER */ makeModuleLoader({ extensions, loadTarget: LoadTargets.App, additional: controls[LoadTargets.App], cwd, callback: (files: LoaderFile[]) => { writeFile({ destination: getPath("loader-app", cwd), content: loaderString(files) }) }, }) /** * SETTINGS FILES LOADER */ makeFileLoader({ extensions, filename: "factor-settings.*", loadTarget: LoadTargets.Settings, additional: controls[LoadTargets.Settings], cwd, callback: (files: LoaderFile[]) => { writeFile({ destination: getPath("loader-settings", cwd), content: loaderStringOrdered(files), }) }, }) /** * STYLE FILES LOADER */ makeFileLoader({ extensions, filename: "factor-styles.*", loadTarget: LoadTargets.Style, additional: controls[LoadTargets.Style], cwd, callback: (files: LoaderFile[]) => { const imports = files.map((_) => `@import (less) "~${_.file}";`).join(`\n`) const content = `${imports}` writeFile({ destination: getPath("loader-styles", cwd), content }) }, }) return }