From: Haoqun Jiang Date: Sun, 25 Jul 2021 15:19:47 +0000 (+0800) Subject: feat: implement the `create-vue` command (no readme or cli argument support yet) X-Git-Tag: v3.0.0-alpha.2~35 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=e3ed2527154af9d7a84164db45c929c9ddd822ca;p=thirdparty%2Fvuejs%2Fcreate-vue.git feat: implement the `create-vue` command (no readme or cli argument support yet) --- diff --git a/emptyDir.js b/emptyDir.js new file mode 100644 index 00000000..204f66b3 --- /dev/null +++ b/emptyDir.js @@ -0,0 +1,18 @@ +import fs from 'fs' +import path from 'path' + +export default function emptyDir(dir) { + if (!fs.existsSync(dir)) { + return + } + for (const file of fs.readdirSync(dir)) { + const abs = path.resolve(dir, file) + // baseline is Node 12 so can't use rmSync :( + if (fs.lstatSync(abs).isDirectory()) { + emptyDir(abs) + fs.rmdirSync(abs) + } else { + fs.unlinkSync(abs) + } + } +} diff --git a/index.js b/index.js old mode 100644 new mode 100755 index cf411850..d07f8dc9 --- a/index.js +++ b/index.js @@ -1,40 +1,226 @@ +#!/usr/bin/env node +// @ts-check + +import fs from 'fs' +import minimist from 'minimist' import prompts from 'prompts' -import kolorist from 'kolorist' +import { red, green, bold } from 'kolorist' +import emptyDir from './emptyDir.js' import renderTemplate from './renderTemplate.js' +import path from 'path' + +function isValidPackageName(projectName) { + return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test( + projectName + ) +} + +function toValidPackageName(projectName) { + return projectName + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/^[._]/, '') + .replace(/[^a-z0-9-~]+/g, '-') +} + +function canSafelyOverwrite(dir) { + return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0 +} + +async function init() { + const cwd = process.cwd() + const argv = minimist(process.argv.slice(2)) + + let targetDir = argv._[0] + const defaultProjectName = !targetDir ? 'vue-project' : targetDir + + let result = {} + try { + // Prompts: + // - Project name: + // - whether to overwrite the existing directory or not? + // - enter a valid package name for package.json + // - Project language: JavaScript / TypeScript + // - Install Vue Router & Vuex for SPA development? + // - Add Cypress for testing? + result = await prompts([ + { + name: 'projectName', + type: targetDir ? null : 'text', + message: 'Project name:', + initial: defaultProjectName, + onState: (state) => + (targetDir = String(state.value).trim() || defaultProjectName) + }, + { + name: 'shouldOverwrite', + type: () => canSafelyOverwrite(targetDir) ? null : 'confirm', + message: () => { + const dirForPrompt = targetDir === '.' + ? 'Current directory' + : `Target directory "${targetDir}"` + + return `${dirForPrompt} is not empty. Remove existing files and continue?` + } + }, + { + name: 'overwriteChecker', + type: (prev, values = {}) => { + if (values.shouldOverwrite === false) { + throw new Error(red('✖') + ' Operation cancelled') + } + return null + } + }, + { + name: 'packageName', + type: () => (isValidPackageName(targetDir) ? null : 'text'), + message: 'Package name:', + initial: () => toValidPackageName(targetDir), + validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name' + }, + { + name: 'shouldUseTypeScript', + type: 'toggle', + message: 'Add TypeScript?', + initial: false, + active: 'Yes', + inactive: 'No' + }, + { + name: 'isSPA', + type: 'toggle', + message: 'Install Vue Router & Vuex for Single Page Application development?', + initial: false, + active: 'Yes', + inactive: 'No' + }, + { + name: 'shouldAddCypress', + type: 'toggle', + message: 'Add Cypress for testing?', + initial: false, + active: 'Yes', + inactive: 'No' + } + ], { + onCancel: () => { + throw new Error(red('✖') + ' Operation cancelled') + } + }) + } catch (cancelled) { + console.log(cancelled.message) + process.exit(1) + } + + const { packageName, shouldOverwrite, shouldUseTypeScript, isSPA, shouldAddCypress } = result + const root = path.join(cwd, targetDir) + + if (shouldOverwrite) { + emptyDir(root) + } else if (!fs.existsSync(root)) { + fs.mkdirSync(root) + } + + // TODO: + // Add command-line option as a template-shortcut, + // so that we can generate them in playgrounds + // e.g. `--template typescript-spa` and `--with-tests` + + console.log(`\nScaffolding project in ${root}...`) + + const pkg = { name: packageName, version: '0.0.0' } + fs.writeFileSync( + path.resolve(root, 'package.json'), + JSON.stringify(pkg, null, 2) + ) + + const templateRoot = new URL('./template', import.meta.url).pathname + const render = function render(templateName) { + const templateDir = path.resolve(templateRoot, templateName) + renderTemplate(templateDir, root) + } + + // Add configs. + render('config/base') + if (shouldAddCypress) { + render('config/cypress') + } + if (shouldUseTypeScript) { + render('config/typescript') + } + + // Render code template. + const codeTemplate = + (shouldUseTypeScript ? 'typescript-' : '') + + (isSPA ? 'spa' : 'default') + render(`code/${codeTemplate}`) + + // TODO: README generation + + // Cleanup. + + if (shouldUseTypeScript) { + // Should remove the `vite.config.js` from the base config + fs.unlinkSync(path.resolve(root, 'vite.config.js')) + } + + if (!shouldAddCypress) { + // All templates assumes the need of tests. + // If the user doesn't need it: + // rm -rf cypress **/__tests__/ + function removeTestDirectories (dir) { + for (const filename of fs.readdirSync(dir)) { + const subdir = path.resolve(dir, filename) + const stats = fs.lstatSync(subdir) + + if (!stats.isDirectory()) { continue } + + if (filename === 'cypress' || filename === '__tests__') { + emptyDir(subdir) + fs.rmdirSync(subdir) + continue + } + + removeTestDirectories(subdir) + } + } + + removeTestDirectories(root) + } + + // Instructions: + // Supported package managers: pnpm > yarn > npm + const packageManager = /pnpm/.test(process.env.npm_execpath) + ? 'pnpm' + : /yarn/.test(process.env.npm_execpath) + ?'yarn' + : 'npm' + + const commandsMap = { + install: { + pnpm: 'pnpm install', + yarn: 'yarn', + npm: 'npm install' + }, + dev: { + pnpm: 'pnpm dev', + yarn: 'yarn dev', + npm: 'npm run dev' + } + } + + console.log(`\nDone. Now run:\n`) + if (root !== cwd) { + console.log(` ${bold(green(`cd ${path.relative(cwd, root)}`))}`) + } + console.log(` ${bold(green(commandsMap.install[packageManager]))}`) + console.log(` ${bold(green(commandsMap.dev[packageManager]))}`) + console.log() +} -// Prompts: -// - Project name: -// - Project language: JavaScript / TypeScript -// - Install Vue Router & Vuex for Single Page Applications? -// - Adding tests? - -// TODO: -// add command-line for all possible option combinations -// so that we can generate them in playgrounds - -// Add configs. -// renderTemplate('config/base') -// if (needs tests) { -// renderTemplate('config/cypress') -// } -// if (is typescript) { -// renderTemplate('config/typescript') -// } - -// templateName = -// (isTs ? 'typescript-' : '') + -// (isSPA ? 'spa' : 'default') -// renderTemplate(`code/${templateName}`) - -// Cleanup. - -// All templates assumes the need of tests. -// If the user doesn't need it: -// rm -rf cypress **/__tests__/ - -// TS config template may add redundant tsconfig.json. -// Should clean that too. - -// Instructions: -// Supported package managers: pnpm > yarn > npm +init().catch((e) => { + console.error(e) +}) diff --git a/package.json b/package.json index 90b1484a..ff5106e8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "homepage": "https://github.com/vuejs/create-vue#readme", "dependencies": { "kolorist": "^1.5.0", + "minimist": "^1.2.5", "prompts": "^2.4.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cb35189..7465d29c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2,10 +2,12 @@ lockfileVersion: 5.3 specifiers: kolorist: ^1.5.0 + minimist: ^1.2.5 prompts: ^2.4.1 dependencies: kolorist: 1.5.0 + minimist: 1.2.5 prompts: 2.4.1 packages: @@ -19,6 +21,10 @@ packages: resolution: {integrity: sha512-pPobydIHK884YBtkS/tWSZXpSAEpcMbilyun3KL37ot935qL2HNKm/tI45i/Rd+MxdIWEhm7/LmUQzWZYK+Qhg==} dev: false + /minimist/1.2.5: + resolution: {integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==} + dev: false + /prompts/2.4.1: resolution: {integrity: sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ==} engines: {node: '>= 6'} diff --git a/renderTemplate.js b/renderTemplate.js index cdf3fbce..17a0c737 100644 --- a/renderTemplate.js +++ b/renderTemplate.js @@ -1,7 +1,73 @@ -export default function renderTemplate(templateFolder) { - // TODO: - // Recursively copy all files under `template/${templateFolder}`, - // with the following exception: - // - `_filename` should be renamed to `.filename` - // - Fields in `package.json` should be recursively merged +import fs from 'fs' +import path from 'path' + +const isObject = val => val && typeof val === 'object' +const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b])) + +/** + * Recursively merge the content of the new object to the existing one + * @param {Object} target the existing object + * @param {Object} obj the new object + */ +function deepMerge(target, obj) { + for (const key of Object.keys(obj)) { + const oldVal = target[key] + const newVal = obj[key] + + if (Array.isArray(oldVal) && Array.isArray(newVal)) { + target[key] = mergeArrayWithDedupe(oldVal, newVal) + } else if (isObject(oldVal) && isObject(newVal)) { + target[key] = deepMerge(oldVal, newVal) + } else { + target[key] = newVal + } + } + + return target } + +/** + * Renders a template folder/file to the file system, + * by recursively copying all files under the `src` directory, + * with the following exception: + * - `_filename` should be renamed to `.filename` + * - Fields in `package.json` should be recursively merged + * @param {string} src source filename to copy + * @param {string} dest destination filename of the copy operation + */ +function renderTemplate(src, dest) { + const stats = fs.statSync(src) + + if (stats.isDirectory()) { + // if it's a directory, render its subdirectories and files recusively + fs.mkdirSync(dest, { recursive: true }) + for (const file of fs.readdirSync(src)) { + renderTemplate(path.resolve(src, file), path.resolve(dest, file)) + } + return + } + + const filename = path.basename(src) + + if (filename === 'package.json' && fs.existsSync(dest)) { + // merge instead of overwriting + const pkg = deepMerge( + JSON.parse(fs.readFileSync(dest)), + JSON.parse(fs.readFileSync(src)) + ) + fs.writeFileSync(dest, JSON.stringify(pkg, null, 2)) + return + } + + if (filename.startsWith('_')) { + // rename `_file` to `.file` + dest = path.resolve( + path.dirname(dest), + filename.replace(/^_/, '.') + ) + } + + fs.copyFileSync(src, dest) +} + +export default renderTemplate