I have written the vuE3 + TS enterprise development environment as a scaffold, which was supposed to be used to quickly build the development environment of the company’s various projects, and it is being integrated little by little. Here’s the effect:

[Article objective] :

  • This section describes some toolkits and their application scenarios
  • Scaffolding Principle (development idea)
  • Implement a scaffold

For better understanding and more efficient learning, it is recommended to download this project Lu-cli first.

Here are some of the most common toolkits used in scaffolding, and I’ll explain the main uses of these in the following articles. Let’s take a look at some of the packages available:

  • commanderCommand line interface complete solution.Portal πŸšͺ
  • inquirerInteractive command interface collection.Portal πŸšͺ
  • globbyPath matching tool.Portal πŸšͺ
  • execaChild process management tool.Portal πŸšͺ
  • fs-extraEnhanced file system.Portal πŸšͺ
  • download-git-repoDownload the code repository tool.Portal πŸšͺ
  • ejsTemplate rendering.Portal πŸšͺ
  • chalkTerminal character styles.Portal πŸšͺ
  • boxenTerminal “box”.Portal πŸšͺ
  • vue-codemodConvert the contents of the file toAST.Portal πŸšͺ
  • oraTerminal loading effect.Portal πŸšͺ
  • figletThe terminal logo is displayed.Portal πŸšͺ
  • openCross-platform open links.Portal πŸšͺ
  • .

There are more interesting bags to dig up.

Core knowledge collection (toolkit introduction and application scenarios)

I will not describe the details of the package application, recommended that you first NPM init a development project, you can first call these packages to see what they are used to do, what the effect is like, easy to understand, the official website address has been listed above for you.

1. Create a command

The Commander package is a complete command-line solution for creating scaffolding commands such as the Lucli Create app:

// index.js

#!/usr/bin/env node

const program = require("commander");

program
.version("0.0.1"."-v, --version") // Define the version
.command("create app") // Command name
.description("create an application") / / description
.action((source,destination) = >{ // Perform the callback
  console.log('1',source);
  console.log('2',destination);
  // Execute some logic, such as the following interactive logic
})

// Parse the command line
program.parse();
Copy the code

Note: Be sure to execute program.parse(); Parse the command because otherwise you’ll end up being silly and the scaffolding collapses before it starts.

Test in a development environment with the following command:

node ./bin/index.js create app
Copy the code

When our scaffolding is developed, we do the mapping in the bin field of package.json. Such as:

// package.json

"bin": {"lucli":"./index.js"
}
Copy the code

By NPM, index.js will be mapped to global bins of Lucli after the user has installed our scaffolding tool globally, so that it can be executed on the command line:

lucli create app
Copy the code

Of course, we can also do the mapping manually with the NPM link command before the release.

Note: The command line permission is incorrect. Windows users are advised to run the command in administrator mode. MAC users recommend using sudo

npm link 

or

sudo npm install
Copy the code

After that we can also run the lucli create app command.

2. Collect user interaction information

Use the Inquirer to interact with users.

const inquirer = require("inquirer")

 inquirer.prompt([
    {
      name:"name".message:"the name of project: ".// Project name
      type:"input".// Character type
      default:"lucli-demo".// Default name
      validate: (name) = > { // Verify that the name is correct
        return validProjectName(name).errMessage || true; }}, {name:"framework".message:"project framework".// Project framework
      type:"list".choices: [/ / options
        {
          name: "vue + ts".value: "vue"
        },
        {
          name: "react".value: "react"
        }
      ]
    }
  ])
  .then((answers) = > {
    console.log('Result:',answers);
  })
  .catch((error) = > {
    if (error.isTtyError) {
      // Prompt couldn't be rendered in the current environment
    } else {
      // Something else went wrong}});Copy the code

3. Determine the target project path

We get the name of the project created by the user in the above code, and we use path to determine the project path.

const path = require("path");

// Remember the targetDir
const targetDir = path.join(process.cwd(), 'Project name');
Copy the code

TargetDir is the local absolute address of your target project, for example:

 /Users/lucas/repository/study/lu-cli
Copy the code

4. Match the directory

Globby is used to read our template directory in the scaffold.

const globby = require("globby");

const files = await globby(["* * / *"] and {cwd: './template'.dot: true })

console.log(files)
Copy the code

The result is an array of paths based on all the files in the directory you specify.

An example is a directory like this:

β”‚ β”œβ”€template β”‚ β”œβ”€ SRC β”‚ β”œβ”€index.js β”‚ β”œβ”€router β”‚ β”œβ”€index.ts β”‚ β”œβ”€template β”‚ β”œβ”€ SRC β”‚ β”œβ”€router β”‚ β”œβ”€index.tsCopy the code

Take the file as the smallest unit. The specific role of this tool is more obvious in actual combat, and then look back.

5. Read and write files

Fs-extra is an enhanced version of the FS module, adding some new apis to the existing functionality.

const fs = require("fs-extra")

// Read the contents of the file
const content = fs.readFileSync('File path'.'utf-8'); 

// Write a file
fs.writeFileSync('File path'.'File contents')
Copy the code

In addition to the above methods, template files can also be downloaded from the code repository in the following ways.

6. Download the repository code

Git Clone the code from the repository via download-git-repo.

const download = require("download-git-repo");

download('https://www.xxx.. git'.'test/tmp'.function (err) {
  console.log(err)
})
Copy the code

7. Template rendering

Using EJS usually requires rendering according to different conditions in the template. Such as:

  • package.jsonAccording to whether the user needs to installbabelAdd aboutbabelSome configuration of.
  • main.jsAccording to the function of the file, rendering different code.
  • xx.vueDynamic Settings in the template ofcssPrecompiled.
  • .
const ejs = require('ejs')

// demo-01
const template = (
  `<%_ if (isTrue) { _%>`
  + 'content'
  + ` _ % > < % _} `
)

const newContent = ejs.render(template, {
  isTrue: true,})//demo-02
const template = (
  `<style lang="<%= cssPre %>">`
  +`.redColor{`
  +`color:red`
  +`} `
  + `</style>`
)

const newContent = ejs.render(template, {
  cssPre: 'less',})Copy the code

8. Child process execution manager

Execa is used to execute terminal commands, such as NPM install, and the tool can also set up the installation source.

const executeCommand = (command, args, cwd) = > {
  return new Promise((resolve, reject) = > {
    const child = execa(command, args, {
      cwd,
      stdio: ['inherit'.'pipe'.'inherit'],
    })

    child.stdout.on('data'.buffer= > {
      const str = buffer.toString()
      if (/warning/.test(str)) {
        return
      }
      process.stdout.write(buffer)
    })

    child.on('close'.code= > {
      if(code ! = =0) {
        reject(new Error(`command failed: ${command}`))
        return
      }
      resolve()
    })
  })
}

// This is the target project path, where NPM install is executed
await executeCommand('npm'['install'], targetDir)
Copy the code

9. Terminal string style.

Chalk is used to display different style strings in the terminal.

const chalk = require('chalk');

console.log(chalk.blue('Hello world! '));
Copy the code

10. The file content is converted toAST

In vue-CLI, vue-codemod converts the contents of the file into an AST, thus implementing the function of injecting code into the file and returning a string of file contents.

const { runTransformation } = require("vue-codemod")

const fileInfo = {
  path: "src/main.js".source: "File contents"
}

// Parse the code to get the AST and insert the statement from the imports parameter
const injectImports = (fileInfo, api, { imports }) = > {
  const j = api.jscodeshift
  const root = j(fileInfo.source)

  const toImportAST = i= > j(`${i}\n`).nodes()[0].program.body[0]
  const toImportHash = node= > JSON.stringify({
    specifiers: node.specifiers.map(s= > s.local.name),
    source: node.source.raw,
  })

  const declarations = root.find(j.ImportDeclaration)
  const importSet = new Set(declarations.nodes().map(toImportHash))
  const nonDuplicates = node= >! importSet.has(toImportHash(node))const importASTNodes = imports.map(toImportAST).filter(nonDuplicates)

  if (declarations.length) {
    declarations
      .at(-1)
      // a tricky way to avoid blank line after the previous import
      .forEach(({ node }) = > delete node.loc)
      .insertAfter(importASTNodes)
  } else {
    // no pre-existing import declarationsroot.get().node.program.body.unshift(... importASTNodes) }return root.toSource()
}

const params = { 
  imports: [ "import { store, key } from './store';"]}const newContent = runTransformation(fileInfo, transformation, params)
Copy the code

Scaffolding Principle (development idea)

conclusion

The scaffolding principle is to read the template information we prepared in advance according to different customized needs after collecting user interaction information, and then make some differentiated updates to individual files. The core of the update is how to modify the contents of files, which can be done in three ways:

  • usingvue-codemod
  • Template rendering
  • Regular match

This section will be explained in detail later, and finally the corresponding content will be written under our target project. The installation command is being executed.

There are many ways to read templates, but you can also download them directly from the remote repository code using the download-git-repo method. This depends on how you use it. If you download the complete project code without any configuration, you may have a lot of templates to prepare, which is relatively rigid. You can also reduce the package size by putting generic templates in the repository and downloading them in this way, rather than putting them in the project. Even when it comes to version updates, there are pros and cons. Usually we put the template in the project for easy maintenance.

The core principles are the same, and the more you need to think about how to organize your code, the better.

The following I more through the way of code structure to help you to comb out the specific how to develop scaffolding an idea.

The development train of thought

Create a new js file to create the scaffold command. You can debug it using node or using the NPM link in package.json.

In the callback after the command is executed we create the user interaction, wait for the user interaction to complete, and assume that we have this information:

{
    name:"lucli-demo".// Project name
    framework:"vue"./ / the vue framework
    funcList: ["router"."vuex"."less"] // Feature list
}
Copy the code

Our goal is now clear, to build a vUE development environment that integrates Router, VUex, and less. The next idea is to download the template and inject the code into the corresponding file, such as adding the corresponding dependencies in package.json.

We divided the templates according to their functions. The template directory of vUE framework is as follows:

vue-plugins/default/  // Default template
vue-plugins/router/ // Routing template
vue-plugins/vuex/ / / vuex template
Copy the code

Downloading a template is simply reading the template content and generating a file:

// Write a file
fs.writeFileSync('File path'.'File contents')
Copy the code

Can we generate multiple files by iterating through an object? Based on that, let’s imagine putting the list of files we want to build into one object. The structure of the array is as follows:

{
  'src/main.js':'content'.'src/App.vue':'content'.'src/theme.less':'content'.'package.json':'content'. }Copy the code

The next thing to do is to generate this object. First we create a Generator class where we will put the directory of the final files to render:

// src/Generator.js

class Generator{
  constructor(){
    this.files = {}; // File directory}}Copy the code

Using globby to read our template directory, and then traversing the object, using the file system (FS-extra) to read the corresponding file content, we use the Router template as an example.

// Generator.js

class Generator{
  constructor(){
    this.files = {}; // File directory
  }
  
  render(){
    // Read the router function template
    const files = await globby(["* * / *"] and {cwd: './src/vue-plugins/router'.dot: true })
    for (const rawPath of files) {
      // Read the contents of the file
      const content = getFileContent(rawPath)
      // Update the files object
      this.files[rawPath] = content; }}}Copy the code

We create an index.js under each template directory and save the template information in the files object by calling the render method.

// src/vue-plugins/router/index.js

module.exports = (generator) = > {
  // Render template
  generator.render("vue-plugins/router/template");
}
Copy the code

After we get the list of features, we loop through the list, executing the render method in turn.

const generator = new Generator();
// The loop function loads the template
_funcsList.forEach(funcName= > {
  require(`.. /src/${pluginName}/${funcName}/index.js`)(generator);
});
Copy the code

Of course, let’s not forget to load our default template, which is the main body of the project architecture.

So our files object is the directory to render the files to.

All that’s left is to do some differentiation in specific files, such as introducing the router in main.js.

There are three ways to insert code into a file:

  • vue-cliIn the use ofvue-codemodThis package converts the contents of the file intoASTAnd then inASTInsert the contents of the corresponding node, and thenASTConvert to file content.
  • The contents of the file are essentially read as a string, and the insert code just inserts the string in the right place. We can match the position by using regular expressions.
  • If you are not familiar with template rendering, it is recommended to learn about EJS first. If you are not familiar with template rendering, it is recommended to learn about EJS first.

We do package.json separately, using simple object merges for related differentiation, and also consider template rendering for complex configurations.

The idea is the same, we create a codeInFiles object in the Generator to hold the file to be inserted and the contents to be inserted, and a PKG object to hold the package.json contents.

// src/Generator.js

class Generator{
  constructor(){
    this.pkg = {
      name,
      version: "1.0.0".description: "".scripts: {
        dev: "vite --mode development".build: "vue-tsc --noEmit && vite build".prebuild: "vue-tsc --noEmit && vite build --mode staging".serve: "vite preview",}};// package.json
    this.files = {}; // File directory
    this.codeInFiles = { // The object to insert code into
      'path':new Set()
    };
  }
  
  // Update the codeInFiles object to insert code into
  injectImports(path, source) {
    const _imports = this.codeInFiles[path] || (this.codeInFiles[path] = new Set());
    (Array.isArray(source) ? source : [source]).forEach(item= > {
      _imports.add(item)
    })
  }
}
Copy the code

For example, in the router function template index.js, call:

// src/vue-plugins/router/index.js

module.exports = (generator) = > {

  // Render template
  generator.render("vue-plugins/router/template");
  
   // Add dependencies
  generator.extendPackage({
    "dependencies": {
      "vue-router": "^ 4.0.10",}})// Inject the code
  generator.injectImports("src/main.ts"."import router from './router';");
}
Copy the code

Insert code by looping through the codeInFiles object, updating the files object with the new file contents. I’ll use vue-codemod as an example:

// Generator.js

  // Handle package objects
  extendPackage(obj) {
    for (const key in obj) {
      const value = obj[key];
      if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
        this.pkg[key] = Object.assign(this.pkg[key] || {}, value);
      } else {
        this.pkg[key] = value; }}}// Insert code into files
  injectImports(){
    Object.keys(_codeInFiles).forEach(file= > {
      const imports = _codeInFiles[file] instanceof Set ? Array.from(_codeInFiles[file]) : [];
      if (imports && imports.length) {
       // Update the files object with the contents of the newly inserted file
        _files[file] = runTransformation(
          { path: file, source: _files[file] },
          injectImports,
          { imports },
        )
      }
    })
}
Copy the code

Then according to the file directory files in turn to generate files.

// Generate package,json file
fs.writeFileSync('package.json'.JSON.stringify(this.pkg, null.2))
// Generate other files
Object.keys(files).forEach((name) = > {
  fs.writeFileSync(filePath, files[name])
})
Copy the code

So our project architecture is basically set up.

Finally, install the dependencies through ExecA.

// Run the NPM install command to install dependency packages
await executeCommand('npm'['install'], targetDir)
Copy the code

Actual scaffolding

Note: in the actual combat process, the author will not say particularly fine, the code will not be very complete, in order to better learning efficiency:

I suggest you download this project lu- CLI, in actual combat can be used as a reference, and find the code.

Initialize the project

Initialize our scaffolding project with NPM init to improve the directory structure below:

β”œ ─ cli - project// Project nameβ”‚ β”œβ”€ Bass Exercises β”‚ β”œβ”€ Index.js// Command fileβ”‚ β”œ ─ the SRC/ / the source codeβ”‚ β”œ ─ package. Json// Configuration fileβ”‚ β”œ ─ the README, mdCopy the code

Create a command

// bin/index.js

#!/usr/bin/env node
const program = require("commander");
const handlePrompts = require(".. /src/create");

program
  .version("0.0.1"."-v, --version")
  .command("create app")
  .description("create an application")
  .action(() = > {
    // Handle the interaction
    handlePrompts();
  })

// Parse the command line
program.parse();
Copy the code

Run the debug

node ./bin/index.js create app
Copy the code

Creating interactive

We created SRC/creation.js in the root directory to handle the interaction logic. I won’t go into this section too much. You can design the interaction as you like.

// src/create.js

const inquirer = require("inquirer")
const boxen = require('boxen');
const chalk = require('chalk');
const path = require("path");
const { promptTypes } = require("./enum");
const getPromptsByType = require("./getPrompts");
const Generator = require("./Generator");

const {
  executeCommand,
  validProjectName
} = require("./utils/index");

module.exports = () = > {
  // Print our welcome message
  console.log(chalk.green(boxen("Welcome to Lucli ~", { borderStyle: 'classic'.padding: 1.margin: 1 })));
    
  inquirer.prompt([
    {
      name: "name".message: "the name of project: ".// Project name
      type: "input".// Character type
      default: "lucli-demo".// Default name
      validate: (name) = > {
        return validProjectName(name).errMessage || true; }}, {name: "framework".message: "project framework".// Project framework
      type: "list".choices: [{name: "vue + ts".value: promptTypes.VUE
        },
        {
          name: "react".value: promptTypes.REACT
        }
      ]
    }
  ]).then(answers= >{
    Prompts are chosen according to the frame
    const prompts = getPromptsByType(answers.framework);
    if (prompts.length) {
      // Select function
      inquirer.prompt(prompts).then(async (funcs) => {
        // Logic processing will code})}else {
      console.log('Sorry, under development, stay tuned! '); }})}Copy the code

Create the Generator class

After processing the interaction, we have the corresponding information:

{
    name:"lucli-demo".// Project name
    framework:"vue"./ / the vue framework
    funcList: ["router"."vuex"."less"] // Feature list
}
Copy the code

The next step is to generate the files file. First, create the Generator class and initialize our files, codeInFiles, PKG objects and some handler functions.

This class is actually in the development of a little bit of improvement, the author to lazy directly posted. You can try to write it yourself.

// src/Generator.js

const path = require("path");
const ejs = require('ejs');
const fs = require("fs-extra");
const { runTransformation } = require("vue-codemod")
const {
  writeFileTree,
  injectImports,
  injectOptions,
  isObject
} = require(".. /src/utils/index")
const {
  isBinaryFileSync
} = require('isbinaryfile');

class Generator {

  constructor({ name, targetDir }) {
    this.targetDir = targetDir;
    this.pkg = { // package.json
      name,
      version: "1.0.0".description: "".scripts: {
        dev: "vite --mode development".build: "vue-tsc --noEmit && vite build".prebuild: "vue-tsc --noEmit && vite build --mode staging".serve: "vite preview",}};this.files = {}; // File directory
    this.codeInFiles = {}; // The file to insert the code into
    this.optionInFiles = {}; / / injection
    this.middlewareFuns = []; // A list of functions that handle file directories
  }

  // Handle package objects
  extendPackage(obj) {
    for (const key in obj) {
      const value = obj[key];
      if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
        this.pkg[key] = Object.assign(this.pkg[key] || {}, value);
      } else {
        this.pkg[key] = value; }}}// Update the object to insert code into
  injectImports(path, source) {
    const _imports = this.codeInFiles[path] || (this.codeInFiles[path] = new Set());
    (Array.isArray(source) ? source : [source]).forEach(item= > {
      _imports.add(item)
    })
  }

  // Update the object of the option to be inserted
  injectOptions(path, source) {
    const _options = this.optionInFiles[path] || (this.optionInFiles[path] = new Set());
    (Array.isArray(source) ? source : [source]).forEach(item= > {
      _options.add(item)
    })
  }

  // Parse the contents of the file
  resolveFile(sourcePath) {
    // Return the binary file directly
    if (isBinaryFileSync(sourcePath)) {
      return fs.readFileSync(sourcePath);
    }
    const template = fs.readFileSync(sourcePath, 'utf-8');
    // This is not necessary, if you have a template rendering need to add
    const content = ejs.render(template);
    return content;
  }

  // Render method
  async render(source) {
    this.middlewareFuns.push(async() = > {const relativePath = `./src/${source}`;
      const globby = require("globby");
      // Get the file directory
      const files = await globby(["* * / *"] and {cwd: relativePath, dot: true })
      for (const rawPath of files) {
        // Get the absolute address to read the file
        const sourcePath = path.resolve(relativePath, rawPath)
        const content = this.resolveFile(sourcePath)
        // There is file content
        if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
          this.files[rawPath] = content; }}})}// Execute the function
  async generator() {
  
    // Set the value of files
    for (const middleawre of this.middlewareFuns) {
      await middleawre();
    }
    
    const _files = this.files;
    const _codeInFiles = this.codeInFiles;
    const _optionsInFiles = this.optionInFiles;

    // Insert code into files
    Object.keys(_codeInFiles).forEach(file= > {
      const imports = _codeInFiles[file] instanceof Set ? Array.from(_codeInFiles[file]) : [];
      if (imports && imports.length) {
        _files[file] = runTransformation(
          { path: file, source: _files[file] },
          injectImports,
          { imports },
        )
      }
    })

    // Insert code into files
    Object.keys(_optionsInFiles).forEach(file= > {
      const injections = _optionsInFiles[file] instanceof Set ? Array.from(_optionsInFiles[file]) : [];
      if(injections && injections.length) { _files[file] = injectOptions(_files[file], injections); }})await writeFileTree(this.targetDir, this.files)

    // Generate a package.json file
    await writeFileTree(this.targetDir, {
      "package.json": JSON.stringify(this.pkg, null.2)}}}module.exports = Generator;
Copy the code

To load a template

Then we define our project path, instantiate a Generator class, loop through our list of functions, load the template file, and build our Files object. For specific template information, please refer to the template in the project.

// src/create.js

module.exports = () = >{... inquirer.prompt(prompts).then(async (funcs) => {
    // Logic processing will code
   
   // Project path
    const targetDir = path.join(process.cwd(), answers.name);
    // Create an instance
    const generator = new Generator({ name: answers.name, targetDir });
    
    const _funcsList = funcs.funcList;
    // Select CSS precompile
    if (_funcsList.includes("precompile")) {
      const result = await inquirer.prompt([
        {
          name: "cssPrecle".message: "less or sass ?".type: "list".choices: [{name: "less".value: "less"
            },
            {
              name: "sass".value: "sass"}}]]); _funcsList.pop();// Add precompiled dependencies
      generator.extendPackage({
        "devDependencies": {
          [result.cssPrecle]: result.cssPrecle === "less" ? "^ 4.4.1" : "^ 1.35.2"}})}let pluginName = ' ';
    // Define the frame template
    switch (answers.framework) {
      case promptTypes.VUE:
        pluginName = 'vue-plugins'
        break;
      case promptTypes.REACT:
        pluginName = 'vue-plugins'
        break;
    };

    // Load the default template
    require(`.. /src/${pluginName}/default/index.js`)(generator);

    // Load the function template
    _funcsList.forEach(funcName= > {
      require(`.. /src/${pluginName}/${funcName}/index.js`)(generator); }); . })Copy the code
// src/vue-plugins/vuex/index.js

module.exports = (generator) = > {
  // Add dependencies
  generator.extendPackage({
    "dependencies": {
      "vuex": "^ 4.0.2." "}})// Inject the code
  generator.injectImports("src/main.ts"."import { store, key } from './store';");

  // The injection option
  generator.injectOptions("src/main.ts".".use(store, key)");

  // Render template
  generator.render("vue-plugins/vuex/template");
}
Copy the code

You can use the Inquirer with flexibility, such as the CSS precompile option. You can also use type:expand to do this.

Generate the file

Then execute the Generator function to write the files object as a file, and finally install the dependencies in package.json:

//src/create.js

const {
  executeCommand,
  validProjectName
} = require("./utils/index"); .// Execute the render generated file
await generator.generator();

// Run the NPM install command to install dependency packages
await executeCommand('npm'['install'], targetDir)

console.log(chalk.green(boxen("Build success", { borderStyle: 'double'.padding: 1 })));
Copy the code

So we have developed a basic scaffolding.

The last

By mastering this project you can not only develop tools like scaffolding, you can write more tools like scaffolding to improve your productivity.

There are many things that can be optimized for this project, such as selecting the installation source when you install dependencies, checking if the folder already exists when you create the project, and configuring some development specifications. Interested partners can explore and submit to me Mr.

I’ll update this post as well if I have time.

thank you