]> git.ipfire.org Git - thirdparty/vuejs/create-vue.git/commitdiff
feat: implement the `create-vue` command (no readme or cli argument support yet)
authorHaoqun Jiang <haoqunjiang@gmail.com>
Sun, 25 Jul 2021 15:19:47 +0000 (23:19 +0800)
committerHaoqun Jiang <haoqunjiang@gmail.com>
Sun, 25 Jul 2021 15:19:47 +0000 (23:19 +0800)
emptyDir.js [new file with mode: 0644]
index.js [changed mode: 0644->0755]
package.json
pnpm-lock.yaml
renderTemplate.js

diff --git a/emptyDir.js b/emptyDir.js
new file mode 100644 (file)
index 0000000..204f66b
--- /dev/null
@@ -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)
+    }
+  }
+}
old mode 100644 (file)
new mode 100755 (executable)
index cf41185..d07f8dc
--- a/index.js
+++ b/index.js
+#!/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)
+})
index 90b1484a36ef4739589ec7dc7b52883dfaabcff1..ff5106e87c1b2cbf7a87c2e4070fc501ecaef42e 100644 (file)
@@ -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"
   }
 }
index 6cb35189c81dd5699fa6ce55018ac9ec5008d9ba..7465d29cb6ab1a91af7a34d8905f5cfbf291e8eb 100644 (file)
@@ -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'}
index cdf3fbcece4e24550f9d7b5e748ed89b90adaa50..17a0c7374e5ac1a8348436b0886e39d23d6f8bd4 100644 (file)
@@ -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