xchange-create
Version:
Create Xchange Project
314 lines (274 loc) • 10.3 kB
text/typescript
import { execa } from "execa";
import { Extension, Options, TemplateDescriptor, isDefined } from "../types";
import { baseDir } from "../utils/consts";
import { extensionDict } from "../utils/extensions-tree";
import { findFilesRecursiveSync } from "../utils/find-files-recursively";
import { mergePackageJson } from "../utils/merge-package-json";
import fs from "fs";
import url from 'url'
import ncp from "ncp";
import path from "path";
import { promisify } from "util";
import link from "../utils/link";
const copy = promisify(ncp);
let copyOrLink = copy
const expandExtensions = (options: Options): Extension[] => {
const expandedExtensions = options.extensions
.map((extension) => extensionDict[extension])
.map((extDescriptor) =>
[extDescriptor.extends, extDescriptor.value].filter(isDefined)
)
.flat()
// this reduce just removes duplications
.reduce(
(exts, ext) => (exts.includes(ext) ? exts : [...exts, ext]),
[] as Extension[]
);
return expandedExtensions;
};
const isTemplateRegex = /([^\/\\]*?)\.template\./;
const isPackageJsonRegex = /package\.json/;
const isPnpmLockRegex = /pnpm-lock\.yaml/;
const isNextGeneratedRegex = /packages\/nextjs\/generated/
const isConfigRegex = /([^\/\\]*?)\\config\.json/;
const isArgsRegex = /([^\/\\]*?)\.args\./;
const isExtensionFolderRegex = /extensions$/;
const isPackagesFolderRegex = /packages$/;
const copyBaseFiles = async (
{ dev: isDev }: Options,
basePath: string,
targetDir: string
) => {
await copyOrLink(basePath, targetDir, {
clobber: false,
filter: (fileName) => { // NOTE: filter IN
const isTemplate = isTemplateRegex.test(fileName)
const isPackageJson = isPackageJsonRegex.test(fileName)
const isPnpmLock = isPnpmLockRegex.test(fileName)
const isNextGenerated = isNextGeneratedRegex.test(fileName)
const skipAlways = isTemplate || isPackageJson
const skipDevOnly = isPnpmLock || isNextGenerated
const shouldSkip = skipAlways || (isDev && skipDevOnly)
return !shouldSkip
},
});
const basePackageJsonPaths = findFilesRecursiveSync(basePath, path => isPackageJsonRegex.test(path))
basePackageJsonPaths.forEach(packageJsonPath => {
const partialPath = packageJsonPath.split(basePath)[1]
mergePackageJson(
path.join(targetDir, partialPath),
path.join(basePath, partialPath),
isDev
);
})
if (isDev) {
const basePnpmLockPaths = findFilesRecursiveSync(basePath, path => isPnpmLockRegex.test(path))
basePnpmLockPaths.forEach(pnpmLockPath => {
const partialPath = pnpmLockPath.split(basePath)[1]
copy(
path.join(basePath, partialPath),
path.join(targetDir, partialPath)
)
})
const nextGeneratedPaths = findFilesRecursiveSync(basePath, path => isNextGeneratedRegex.test(path))
nextGeneratedPaths.forEach(nextGeneratedPath => {
const partialPath = nextGeneratedPath.split(basePath)[1]
copy(
path.join(basePath, partialPath),
path.join(targetDir, partialPath)
)
})
}
}
const copyExtensionsFiles = async (
{ extensions, dev: isDev }: Options,
targetDir: string
) => {
await Promise.all(extensions.map(async (extension) => {
const extensionPath = extensionDict[extension].path;
// copy (or link if dev) root files
await copyOrLink(extensionPath, path.join(targetDir), {
clobber: false,
filter: (path) => {
const isConfig = isConfigRegex.test(path);
const isArgs = isArgsRegex.test(path);
const isExtensionFolder =
isExtensionFolderRegex.test(path) && fs.lstatSync(path).isDirectory();
const isPackagesFolder =
isPackagesFolderRegex.test(path) && fs.lstatSync(path).isDirectory();
const isTemplate = isTemplateRegex.test(path);
// PR NOTE: this wasn't needed before because ncp had the clobber: false
const isPackageJson = isPackageJsonRegex.test(path);
const shouldSkip =
isConfig ||
isArgs ||
isTemplate ||
isPackageJson ||
isExtensionFolder ||
isPackagesFolder;
return !shouldSkip;
},
});
// merge root package.json
mergePackageJson(
path.join(targetDir, "package.json"),
path.join(extensionPath, "package.json"),
isDev
);
const extensionPackagesPath = path.join(extensionPath, "packages");
const hasPackages = fs.existsSync(extensionPackagesPath);
if (hasPackages) {
// copy extension packages files
await copyOrLink(extensionPackagesPath, path.join(targetDir, "packages"), {
clobber: false,
filter: (path) => {
const isArgs = isArgsRegex.test(path);
const isTemplate = isTemplateRegex.test(path);
const isPackageJson = isPackageJsonRegex.test(path);
const shouldSkip = isArgs || isTemplate || isPackageJson;
return !shouldSkip;
},
});
// copy each package's package.json
const extensionPackages = fs.readdirSync(extensionPackagesPath);
extensionPackages.forEach((packageName) => {
mergePackageJson(
path.join(targetDir, "packages", packageName, "package.json"),
path.join(extensionPath, "packages", packageName, "package.json"),
isDev
);
});
}
}));
};
const processTemplatedFiles = async (
{ extensions, dev: isDev }: Options,
basePath: string,
targetDir: string
) => {
const baseTemplatedFileDescriptors: TemplateDescriptor[] =
findFilesRecursiveSync(basePath, (path) => isTemplateRegex.test(path)).map(
(baseTemplatePath) => ({
path: baseTemplatePath,
fileUrl: url.pathToFileURL(baseTemplatePath).href,
relativePath: baseTemplatePath.split(basePath)[1],
source: "base",
})
);
const extensionsTemplatedFileDescriptors: TemplateDescriptor[] = extensions
.map((ext) =>
findFilesRecursiveSync(extensionDict[ext].path, (filePath) =>
isTemplateRegex.test(filePath)
).map((extensionTemplatePath) => ({
path: extensionTemplatePath,
fileUrl: url.pathToFileURL(extensionTemplatePath).href,
relativePath: extensionTemplatePath.split(extensionDict[ext].path)[1],
source: `extension ${extensionDict[ext].name}`,
}))
)
.flat();
await Promise.all(
[
...baseTemplatedFileDescriptors,
...extensionsTemplatedFileDescriptors,
].map(async (templateFileDescriptor) => {
const templateTargetName =
templateFileDescriptor.path.match(isTemplateRegex)?.[1]!;
const argsPath = templateFileDescriptor.relativePath.replace(
isTemplateRegex,
`${templateTargetName}.args.`
);
const argsFileUrls = extensions
.map((extension) => {
const argsFilePath = path.join(
extensionDict[extension].path,
argsPath
);
const fileExists = fs.existsSync(argsFilePath);
if (!fileExists) {
return [];
}
return url.pathToFileURL(argsFilePath).href;
})
.flat();
const args = await Promise.all(
argsFileUrls.map(async (argsFileUrl) => await import(argsFileUrl))
);
const template = (await import(templateFileDescriptor.fileUrl)).default;
if (!template) {
throw new Error(
`Template ${templateTargetName} from ${templateFileDescriptor.source} doesn't have a default export`
);
}
if (typeof template !== "function") {
throw new Error(
`Template ${templateTargetName} from ${templateFileDescriptor.source} is not exporting a function by default`
);
}
const freshArgs: { [key: string]: string[] } = Object.fromEntries(
Object.keys(args[0] ?? {}).map((key) => [
key, // INFO: key for the freshArgs object
[], // INFO: initial value for the freshArgs object
])
);
const combinedArgs: { [key: string]: string[] } = args.reduce(
(accumulated, arg) => {
Object.entries(arg).map(([key, value]) => {
accumulated[key].push(value);
});
return accumulated;
},
freshArgs
);
// TODO test: if first arg file found only uses 1 name, I think the rest are not used?
const output = template(combinedArgs);
const targetPath = path.join(
targetDir,
templateFileDescriptor.relativePath.split(templateTargetName)[0],
templateTargetName
);
fs.writeFileSync(targetPath, output);
if (isDev) {
const hasCombinedArgs = Object.keys(combinedArgs).length > 0
const hasArgsPaths = argsFileUrls.length > 0
const devOutput = `--- TEMPLATE FILE
templates/${templateFileDescriptor.source}${templateFileDescriptor.relativePath}
--- ARGS FILES
${hasArgsPaths
? argsFileUrls.map(url => `\t- ${path.join('templates', url.split('templates')[1])}`).join('\n')
: '(no args files writing to the template)'
}
--- RESULTING ARGS
${hasCombinedArgs
? Object.entries(combinedArgs)
.map(([argName, argValue]) => `\t- ${argName}:\t[${argValue.join(',')}]`)
// TODO improvement: figure out how to add the values added by each args file
.join('\n')
: '(no args sent for the template)'
}
`
fs.writeFileSync(`${targetPath}.dev`, devOutput);
}
})
);
};
export async function copyTemplateFiles(
options: Options,
templateDir: string,
targetDir: string
) {
copyOrLink = options.dev ? link : copy
const basePath = path.join(templateDir, baseDir);
// 1. Copy base template to target directory
await copyBaseFiles(options, basePath, targetDir)
// 2. Add "parent" extensions (set via config.json#extend field)
const expandedExtension = expandExtensions(options);
options.extensions = expandedExtension;
// 3. Copy extensions folders
await copyExtensionsFiles(options, targetDir);
// 4. Process templated files and generate output
await processTemplatedFiles(options, basePath, targetDir);
// 5. Initialize git repo to avoid husky error
await execa("git", ["init"], { cwd: targetDir });
await execa("git", ["checkout", "-b", "main"], { cwd: targetDir });
}