import path from "node:path"; import fs from "node:fs"; import minimist from "minimist"; import prompts from "prompts"; import { fileURLToPath } from "node:url"; import { blue, cyan, green, magenta, lightCyan, reset, yellow } from "kolorist"; import chalk from "chalk"; const POLYGONJS_EDITOR_VERSION = "1.5.94-1"; // Avoids autoconversion to number of the project name by defining that the args // non associated with an option ( _ ) needs to be parsed as a string. See #4606 const argv = minimist<{ t?: string; template?: string; }>(process.argv.slice(2), { string: ["_"] }); const cwd = process.cwd(); type ColorFunc = (str: string | number) => string; type FrameworkVariant = { name: string; display: string; color: ColorFunc; // customCommand?: string ts: boolean; }; type Framework = { name: string; display: string; color: ColorFunc; variants: FrameworkVariant[]; }; const DEFAULT_TARGET_DIR = "polygon-project"; const FRAMEWORKS: Framework[] = [ { name: "vanilla", display: "Vanilla", color: yellow, variants: [ { name: "vanilla-ts", display: "TypeScript", color: blue, ts: true, }, { name: "vanilla", display: "JavaScript", color: yellow, ts: false, }, ], }, { name: "three", display: "Three", color: magenta, variants: [ { name: "three-ts", display: "TypeScript", color: blue, ts: true, }, { name: "three", display: "JavaScript", color: yellow, ts: false, }, ], }, { name: "vue", display: "Vue", color: green, variants: [ { name: "vue-ts", display: "TypeScript", color: blue, ts: true, }, { name: "vue", display: "JavaScript", color: yellow, ts: false, }, ], }, { name: "react", display: "React", color: cyan, variants: [ { name: "react-ts", display: "TypeScript", color: blue, ts: true, }, { name: "react", display: "JavaScript", color: yellow, ts: false, }, ], }, { name: "react-three-fiber", display: "React Three Fiber", color: lightCyan, variants: [ { name: "react-three-fiber-ts", display: "TypeScript", color: blue, ts: true, }, { name: "react-three-fiber", display: "JavaScript", color: yellow, ts: false, }, ], }, ]; const TEMPLATES = FRAMEWORKS.map( (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name] ).reduce((a, b) => a.concat(b), []); const renameFiles: Record = { _gitignore: ".gitignore", _env: ".env", "_eslintrc.js": ".eslintrc.js", "_prettierrc.json": ".prettierrc.json", _vscode: ".vscode", }; function formatTargetDir(targetDir: string | undefined) { return targetDir?.trim().replace(/\/+$/g, ""); } function isEmpty(path: string) { const files = fs.readdirSync(path); return files.length === 0 || (files.length === 1 && files[0] === ".git"); } function emptyDir(dir: string) { if (!fs.existsSync(dir)) { return; } for (const file of fs.readdirSync(dir)) { if (file === ".git") { continue; } fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); } } function pkgFromUserAgent(userAgent: string | undefined) { if (!userAgent) return undefined; const pkgSpec = userAgent.split(" ")[0]; const pkgSpecArr = pkgSpec.split("/"); return { name: pkgSpecArr[0], version: pkgSpecArr[1], }; } function copy(src: string, dest: string) { const stat = fs.statSync(src); if (stat.isDirectory()) { copyDir(src, dest); } else { fs.copyFileSync(src, dest); } } function isValidPackageName(projectName: string) { return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( projectName ); } function toValidPackageName(projectName: string) { return projectName .trim() .toLowerCase() .replace(/\s+/g, "-") .replace(/^[._]/, "") .replace(/[^a-z\d\-~]+/g, "-"); } function copyDir(srcDir: string, destDir: string) { fs.mkdirSync(destDir, { recursive: true }); for (const file of fs.readdirSync(srcDir)) { const srcFile = path.resolve(srcDir, file); const destFile = path.resolve(destDir, file); copy(srcFile, destFile); } } function getTemplateDir(templateName: string) { const _templateDir = path.resolve( fileURLToPath(import.meta.url), "../..", `templates/${templateName}` ); return _templateDir; } interface WriteOptions { root: string; dir: string; file: string; content?: string; } const write = (options: WriteOptions) => { const { root, dir, file, content } = options; const targetPath = path.join(root, renameFiles[file] ?? file); if (content) { fs.writeFileSync(targetPath, content); } else { copy(path.join(dir, file), targetPath); } }; async function init() { const argTargetDir = formatTargetDir(argv._[0]); const argTemplate = argv.template || argv.t; let targetDir = argTargetDir || DEFAULT_TARGET_DIR; const getProjectName = () => targetDir === "." ? path.basename(path.resolve()) : targetDir; let result: prompts.Answers< "projectName" | "overwrite" | "packageName" | "framework" | "variant" >; try { result = await prompts([ { type: argTargetDir ? null : "text", name: "projectName", message: reset("Project name:"), initial: DEFAULT_TARGET_DIR, onState: (state) => { targetDir = formatTargetDir(state.value) || DEFAULT_TARGET_DIR; }, }, { type: () => !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : "confirm", name: "overwrite", message: () => (targetDir === "." ? "Current directory" : `Target directory "${targetDir}"`) + ` is not empty. Remove existing files and continue?`, }, { type: () => isValidPackageName(getProjectName()) ? null : "text", name: "packageName", message: reset("Package name:"), initial: () => toValidPackageName(getProjectName()), validate: (dir) => isValidPackageName(dir) || "Invalid package.json name", }, { type: argTemplate && TEMPLATES.includes(argTemplate) ? null : "select", name: "framework", message: typeof argTemplate === "string" && !TEMPLATES.includes(argTemplate) ? reset( `"${argTemplate}" isn't a valid template. Please choose from below: ` ) : reset("Select a framework:"), initial: 0, choices: FRAMEWORKS.map((framework) => { const frameworkColor = framework.color; return { title: frameworkColor( framework.display || framework.name ), value: framework, }; }), }, { type: (framework: Framework) => framework && framework.variants ? "select" : null, name: "variant", message: reset("Select a variant:"), choices: (framework: Framework) => framework.variants.map((variant) => { const variantColor = variant.color; return { title: variantColor( variant.display || variant.name ), value: variant, }; }), }, ]); } catch (cancelled: any) { console.log(cancelled.message); return; } // const projectName: string = result.projectName; const overwrite: boolean = result.overwrite; const packageName: string = result.packageName; const framework: Framework = result.framework; const variant: FrameworkVariant | undefined = result.variant; const root = path.join(cwd, targetDir); if (overwrite) { emptyDir(root); } else if (!fs.existsSync(root)) { fs.mkdirSync(root, { recursive: true }); } const template: string | undefined = variant?.name || framework?.name || argTemplate; if (!template) { console.log("no variant selected"); return; } const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); const pkgManager = pkgInfo ? pkgInfo.name : "npm"; // const isYarn1 = pkgManager === "yarn" && pkgInfo?.version.startsWith("1."); console.log(`\nScaffolding project in ${root}...`); const templateDir = getTemplateDir(template); const commonDir = getTemplateDir("common"); const commonTsDir = getTemplateDir("common-ts"); function _writeTemplateFiles() { const templateFiles = fs.readdirSync(templateDir); for (const file of templateFiles.filter((f) => f !== "package.json")) { write({ root, dir: templateDir, file }); } } function _writeCommonFiles() { const commonFiles = fs.readdirSync(commonDir); for (const file of commonFiles.filter((f) => f !== "package.json")) { write({ root, dir: commonDir, file }); } } function _writeCommonTsFiles() { const commonTsFiles = fs.readdirSync(commonTsDir); for (const file of commonTsFiles.filter((f) => f !== "package.json")) { write({ root, dir: commonTsDir, file }); } } _writeTemplateFiles(); _writeCommonFiles(); if (variant && variant.ts) { _writeCommonTsFiles(); } // write package.json const clean_build = pkgManager != "yarn" ? `${pkgManager} run clean_build` : `yarn clean_build`; const pkgTemplate = fs .readFileSync(path.join(templateDir, `package.json`), "utf-8") .replace("yarn clean_build", clean_build); const pkg = JSON.parse(pkgTemplate); pkg.dependencies["polygonjs-editor"] = POLYGONJS_EDITOR_VERSION; pkg.name = packageName || getProjectName(); write({ root, dir: templateDir, file: "package.json", content: JSON.stringify(pkg, null, `\t`), }); // write .env const envContent = fs .readFileSync(path.join(commonDir, `_env`), "utf-8") .replace(`<% projectName %>`, pkg.name); write({ root, dir: templateDir, file: ".env", content: envContent, }); // console.log(`\nYour project '${getProjectName()}' is created. Now run:\n`); const cdAndInstallDeps: string[] = []; if (root !== cwd) { // console.log(` cd ${path.relative(cwd, root)}`); const cdCmd = `cd ${path.relative(cwd, root)}`; cdAndInstallDeps.push(cdCmd); } switch (pkgManager) { case "yarn": cdAndInstallDeps.push("yarn"); break; default: cdAndInstallDeps.push(`${pkgManager} install`); break; } const s1 = "1. Go in project and install dependencies: "; const s2 = "2. Start polygonjs 3D editor: "; const s3 = "3. Optionally, start local web server (from a new terminal): "; console.log(chalk.green.bgBlack(s1), cdAndInstallDeps.join(" && ")); const runPolygonjsEditor = s2; const runServer = s3; switch (pkgManager) { case "yarn": console.log( chalk.green.bgBlack(runPolygonjsEditor), "yarn polygon" ); console.log(chalk.green.bgBlack(runServer), "yarn dev"); break; default: console.log( chalk.green.bgBlack(runPolygonjsEditor), `${pkgManager} run polygon` ); console.log( chalk.green.bgBlack(runServer), `${pkgManager} run dev` ); break; } console.log(); } init();