@factor/cli
Version:
Learn how to use Factor's CLI tool at [https://factor.dev]
767 lines (669 loc) • 19.1 kB
text/typescript
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 }
group?: 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
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)
.map((_) => normalize(_))
.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)
/**
* Make sure posix path,
* remove file extension and
* index if at end
*/
const moduleName = _module
.replace(/\\/g, "/")
.replace(/\.[jt]s$/, "")
.replace(/\/index$/, "")
const reg = new RegExp(`.*${filename}-(.*)\\.`)
const capture = _module.match(reg)
const group = capture && capture.length > 1 ? capture[1] : ""
let newId = _id
if (group) newId += `_${group}`
if (index > 0) newId += `_${index}`
return {
_id: newId,
file: moduleName,
path: fullPath,
priority,
group,
}
})
.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(normalize(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")
}
/**
* Creates an import string, that loads things in a priority based order
* @param files - module names to write to string
*/
const loaderStringOrderedAndGrouped = (files: LoaderFile[]): string => {
const lines = files.map(
({ _id, file, priority }) =>
`import { default as ${_id} } from "${file}" // ${priority}`
)
const groups: { [key: string]: LoaderFile[] } = {}
files.forEach((file) => {
const grp = file.group ? file.group : "en"
if (!groups[grp]) groups[grp] = []
groups[grp].push(file)
})
const exportLines: string[] = []
Object.keys(groups).forEach((key) => {
exportLines.push(`${key}: [${groups[key].map(({ _id }) => _id).join(", ")}]`)
})
lines.push(`\n\nexport default {\n ${exportLines.join(`,\n `)}\n}`)
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 {
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<string, FactorExtension[]> = {}
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",
additional: controls[LoadTargets.Settings],
cwd,
callback: (files: LoaderFile[]) => {
writeFile({
destination: getPath("loader-settings", cwd),
content: loaderStringOrdered(files),
})
},
})
/**
* SETTINGS FILES LOADER
*/
makeFileLoader({
extensions,
filename: "factor-lang",
additional: controls[LoadTargets.Lang],
cwd,
callback: (files: LoaderFile[]) => {
writeFile({
destination: getPath("loader-lang", cwd),
content: loaderStringOrderedAndGrouped(files),
})
},
})
/**
* STYLE FILES LOADER
*/
makeFileLoader({
extensions,
filename: "factor-styles",
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
}