docs: improve documentation for plugin development

This commit is contained in:
Evan You
2018-02-05 19:58:13 -05:00
parent 920d8fa539
commit e597d12f41
5 changed files with 332 additions and 51 deletions

View File

@@ -2,7 +2,9 @@
> This is the branch for `@vue/cli` 3.0.
**Status: alpha**
## Status: alpha
Certain combinations of plugins may not work properly, and things may break until we reach beta phase. Do not use in production yet unless you are adventurous.
## Install

View File

@@ -1,15 +1,14 @@
# Plugin Development Guide
#### Important Development Note
A plugin with a generator that injects additional dependencies other than packages in this repo (e.g. `chai` is injected by `@vue/cli-plugin-unit-mocha/generator/index.js`) should have those dependencies listed in its own `devDependencies` field. This ensures that:
1. the package always exist in this repo's root `node_modules` so that we don't have to reinstall them on every test.
2. `yarn.lock` stays consistent so that CI can better use it for inferring caching behavior.
## Core Concepts
- [Creator](#creator)
- [Service](#service)
- [CLI Plugin](#cli-plugin)
- [Service Plugin](#service-plugin)
- [Generator](#generator)
- [Prompts](#prompts)
There are two major parts of the system:
- `@vue/cli`: globally installed, exposes the `vue create <app>` command;
@@ -25,17 +24,36 @@ Both utilize a plugin-based architecture.
[Service][service-class] is the class created when invoking `vue-cli-service <command> [...args]`. Responsible for managing the internal webpack configuration, and exposes commands for serving and building the project.
### Plugin
### CLI Plugin
Plugins are locally installed into the project as dependencies. `@vue/cli-service`'s [built-in commands][commands] and [config modules][config] are also all implemented as plugins. This repo also contains a number of plugins that are published as individual packages.
A CLI plugin is an npm package that can add additional features to a `@vue/cli` project. It should always contain a [Service Plugin](#service-plugin) as its main export, and can optionally contain a [Generator](#generator) and a [Prompt File](#prompts-for-3rd-party-plugins).
A plugin should export a function which receives two arguments:
A typical CLI plugin's folder structure looks like the following:
```
.
├── README.md
├── generator.js # generator (optional)
├── prompts.js # prompts file (optional)
├── index.js # service plugin
└── package.json
```
### Service Plugin
Service plugins are loaded automatically when a Service instance is created - i.e. every time the `vue-cli-service` command is invoked inside a project.
Note the concept of a "service plugin" we are discussing here is narrower than that of a "CLI plugin", which is published as an npm package. The former only refers to a module that will be loaded by `@vue/cli-service` when it's initialized, and is usually a part of the latter.
In addition, `@vue/cli-service`'s [built-in commands][commands] and [config modules][config] are also all implemented as service plugins.
A service plugin should export a function which receives two arguments:
- A [PluginAPI][plugin-api] instance
- Project local options specified in `vue.config.js`, or in the `"vue-cli"` field in `package.json`.
- An object containing project local options specified in `vue.config.js`, or in the `"vue-cli"` field in `package.json`.
The API allows plugins to extend/modify the internal webpack config for different environments and inject additional commands to `vue-cli-service`. Example:
The API allows service plugins to extend/modify the internal webpack config for different environments and inject additional commands to `vue-cli-service`. Example:
``` js
module.exports = (api, projectOptions) => {
@@ -54,9 +72,53 @@ module.exports = (api, projectOptions) => {
}
```
#### Environment Variables in Service Plugins
An important thing to note about env variables is knowing when they are resolved. Typically, a command like `vue-cli-service serve` or `vue-cli-service build` will always call `api.setMode()` as the first thing it does. However, this also means those env variables may not yet be available when a service plugin is invoked:
``` js
module.exports = api => {
process.env.NODE_ENV // may not be resolved yet
api.regsiterCommand('build', () => {
api.setMode('production')
})
}
```
Instead, it's safer to rely on env variables in `configureWebpack` or `chainWebpack` functions, which are called lazily only when `api.resolveWebpackConfig()` is finally called:
``` js
module.exports = api => {
api.configureWebpack(config => {
if (process.env.NODE_ENV === 'production') {
// ...
}
})
}
```
#### Custom Options for 3rd Party Plugins
The exports from `vue.config.js` will be [validated against a schema](https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-service/lib/options.js#L3) to avoid typos and wrong config values. However, a 3rd party plugin can still allow the user to configure its behavior via the `pluginOptions` field. For example, with the following `vue.config.js`:
``` js
module.exports = {
pluginOptions: {
foo: { /* ... */ }
}
}
```
The 3rd party plugin can read `projectOptions.pluginOptions.foo` to determine conditional configurations.
### Generator
A plugin published as a package can also contain a `generator.js` or `generator/index.js` file. The generator inside a plugin will be invoked after the plugin is installed.
A CLI plugin published as a package can contain a `generator.js` or `generator/index.js` file. The generator inside a plugin will be invoked in two possible scenarios:
- During a project's initial creation, if the CLI plugin is installed as part of the project creation preset.
- When the plugin is installed after project's creation and invoked individually via `vue invoke`.
The [GeneratorAPI][generator-api] allows a generator to inject additional dependencies or fields into `package.json` and add files to the project.
@@ -64,19 +126,25 @@ A generator should export a function which receives three arguments:
1. A `GeneratorAPI` instance;
2. The generator options for this plugin. These options are resolved during the prompt phase of project creation, or loaded from a saved `~/.vuerc`. For example, if the saved `~/.vuerc` looks like this:
2. The generator options for this plugin. These options are resolved during the prompt phase of project creation, or loaded from a saved preset in `~/.vuerc`. For example, if the saved `~/.vuerc` looks like this:
``` json
{
"plugins": {
"@vue/cli-plugin-foo": { "option": "bar" }
"presets" : {
"foo": {
"plugins": {
"@vue/cli-plugin-foo": { "option": "bar" }
}
}
}
}
```
Then the plugin `@vue/cli-plugin-foo` will receive `{ option: 'bar' }` as its second argument.
And if the user creates a project using the `foo` preset, then the generator of `@vue/cli-plugin-foo` will receive `{ option: 'bar' }` as its second argument.
3. The entire `.vuerc` object will be passed as the third argument.
For a 3rd party plugin, the options will be resolved from the prompts or command line arguments when the user executes `vue invoke` (see [Prompts for 3rd Party Plugins](#prompts-for-3rd-party-plugins)).
3. The entire preset (`presets.foo`) will be passed as the third argument.
**Example:**
@@ -98,9 +166,11 @@ module.exports = (api, options, rootOptions) => {
}
```
### Prompt Modules
### Prompts
Currently, only built-in plugins have the ability to customize the prompts when creating a new project, and the prompt modules are located [inside the `@vue/cli` package][prompt-modules].
#### Prompts for Built-in Plugins
Only built-in plugins have the ability to customize the initial prompts when creating a new project, and the prompt modules are located [inside the `@vue/cli` package][prompt-modules].
A prompt module should export a function that receives a [PromptModuleAPI][prompt-api] instance. The prompts are presented using [inquirer](https://github.com/SBoudrias/Inquirer.js) under the hood:
@@ -133,6 +203,26 @@ module.exports = api => {
}
```
#### Prompts for 3rd Party Plugins
3rd party plugins are typically installed manually after a project is already created, and the user will initialize the plugin by calling `vue invoke`. If the plugin contains a `prompt.js` in its root directory, it will be used during invocation. The file should export an array of [Questions](https://github.com/SBoudrias/Inquirer.js#question) that will be handled by Inquirer.js. The resolved answers object will be passed to the plugin's generator as options.
Alternatively, the user can skip the prompts and directly initialize the plugin by passing options via the command line, e.g.:
``` sh
vue invoke my-plugin --mode awesome
```
## Note on Development of Core Plugins
> This section only applies if you are working on a built-in plugin inside this very repository.
A plugin with a generator that injects additional dependencies other than packages in this repo (e.g. `chai` is injected by `@vue/cli-plugin-unit-mocha/generator/index.js`) should have those dependencies listed in its own `devDependencies` field. This ensures that:
1. the package always exist in this repo's root `node_modules` so that we don't have to reinstall them on every test.
2. `yarn.lock` stays consistent so that CI can better use it for inferring caching behavior.
[creator-class]: https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli/lib/Creator.js
[service-class]: https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli-service/lib/Service.js
[generator-api]: https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli/lib/GeneratorAPI.js

View File

@@ -1 +1,52 @@
# WIP
## Introduction
## The CLI
## Configuration
### Vue CLI options
### Modes and Environment Variables
### Webpack
- #### Basic Configuration
- #### Chaining
- #### Using Resolved Config as a File
### Babel
- link to: babel preset
- link to: babel plugin
### CSS
- #### PostCSS
- #### CSS Modules
- #### Other Pre-Processors
### ESLint
- link to: eslint plugin
### TypeScript
- link to: typescript plugin
### Unit Testing
- #### Jest
- #### Mocha (via `mocha-webpack`)
### E2E Testing
- #### Cypress
- #### Nightwatch

View File

@@ -1,15 +1,31 @@
const path = require('path')
module.exports = class PluginAPI {
class PluginAPI {
/**
* @param {string} id - Id of the plugin.
* @param {Service} service - A vue-cli-service instance.
*/
constructor (id, service) {
this.id = id
this.service = service
}
/**
* Resolve path for a project.
*
* @param {string} _path - Relative path from project root
* @return {string} The resolved absolute path.
*/
resolve (_path) {
return path.resolve(this.service.context, _path)
}
/**
* Check if the project has a given plugin.
*
* @param {string} id - Plugin id, can omit the (@vue/|vue-)-cli-plugin- prefix
* @return {boolean}
*/
hasPlugin (id) {
const prefixRE = /^(@vue\/|vue-)cli-plugin-/
return this.service.plugins.some(p => {
@@ -17,8 +33,13 @@ module.exports = class PluginAPI {
})
}
// set project mode.
// this should be called by any registered command as early as possible.
/**
* Set project mode and resolve env variables for that mode.
* this should be called by any registered command as early as possible, and
* should be called only once per command.
*
* @param {string} mode
*/
setMode (mode) {
process.env.VUE_CLI_MODE = mode
// by default, NODE_ENV and BABEL_ENV are set to "development" unless mode
@@ -31,6 +52,19 @@ module.exports = class PluginAPI {
this.service.loadEnv(mode)
}
/**
* Register a command that will become available as `vue-cli-service [name]`.
*
* @param {string} name
* @param {object} [opts]
* {
* description: string,
* usage: string,
* options: { [string]: string }
* }
* @param {function} fn
* (args: { [string]: string }, rawArgs: string[]) => ?Promise
*/
registerCommand (name, opts, fn) {
if (typeof opts === 'function') {
fn = opts
@@ -39,23 +73,63 @@ module.exports = class PluginAPI {
this.service.commands[name] = { fn, opts }
}
/**
* Regsiter a function that will receive a chainable webpack config
* the function is lazy and won't be called until `resolveWebpackConfig` is
* called
*
* @param {function} fn
*/
chainWebpack (fn) {
this.service.webpackChainFns.push(fn)
}
/**
* Regsiter
* - a webpack configuration object that will be merged into the config
* OR
* - a function that will receive the raw webpack config.
* the function can either mutate the config directly or return an object
* that will be merged into the config.
*
* @param {object | function} fn
*/
configureWebpack (fn) {
this.service.webpackRawConfigFns.push(fn)
}
/**
* Register a dev serve config function. It will receive the express `app`
* instnace of the dev server.
*
* @param {function} fn
*/
configureDevServer (fn) {
this.service.devServerConfigFns.push(fn)
}
/**
* Resolve the final raw webpack config, that will be passed to webpack.
* Typically, you should call `setMode` before calling this.
*
* @return {object} Raw webpack config.
*/
resolveWebpackConfig () {
return this.service.resolveWebpackConfig()
}
/**
* Resolve an intermediate chainable webpack config instance, which can be
* further tweaked before generating the final raw webpack config.
* You can call this multiple times to generate different branches of the
* base webpack config.
* See https://github.com/mozilla-neutrino/webpack-chain
*
* @return {ChainableWebpackConfig}
*/
resolveChainableWebpackConfig () {
return this.service.resolveChainableWebpackConfig()
}
configureDevServer (fn) {
this.service.devServerConfigFns.push(fn)
}
}
module.exports = PluginAPI

View File

@@ -22,7 +22,13 @@ function getLink (id) {
)
}
module.exports = class GeneratorAPI {
class GeneratorAPI {
/**
* @param {string} id - Id of the owner plugin
* @param {Generator} generator - The invoking Generator instance
* @param {object} options - generator options passed to this plugin
* @param {object} rootOptions - root options (the entire preset)
*/
constructor (id, generator, options, rootOptions) {
this.id = id
this.generator = generator
@@ -43,6 +49,11 @@ module.exports = class GeneratorAPI {
})
}
/**
* Resolves the data when rendering templates.
*
* @private
*/
_resolveData (additionalData) {
return Object.assign({
options: this.options,
@@ -51,10 +62,47 @@ module.exports = class GeneratorAPI {
}, additionalData)
}
injectFileMiddleware (middleware) {
/**
* Inject a file processing middleware.
*
* @private
* @param {FileMiddleware} middleware - A middleware function that receives the
* virtual files tree object, and an ejs render function. Can be async.
*/
_injectFileMiddleware (middleware) {
this.generator.fileMiddlewares.push(middleware)
}
/**
* Resolve path for a project.
*
* @param {string} _path - Relative path from project root
* @return {string} The resolved absolute path.
*/
resolve (_path) {
return path.resolve(this.generator.context, _path)
}
/**
* Check if the project has a given plugin.
*
* @param {string} id - Plugin id, can omit the (@vue/|vue-)-cli-plugin- prefix
* @return {boolean}
*/
hasPlugin (id) {
return this.generator.hasPlugin(id)
}
/**
* Extend the package.json of the project.
* Nested fields are deep-merged unless `{ merge: false }` is passed.
* Also resolves dependency conflicts between plugins.
* Tool configuration fields may be extracted into standalone files before
* files are written to disk.
*
* @param {object} fields - Fields to merge.
* @param {object} [options] - pass { merge: false } to disable deep merging.
*/
extendPackage (fields, options = { merge: true }) {
const pkg = this.generator.pkg
const toMerge = isFunction(fields) ? fields(pkg) : fields
@@ -81,13 +129,24 @@ module.exports = class GeneratorAPI {
}
}
render (fileDir, additionalData = {}, ejsOptions = {}) {
/**
* Render template files into the virtual files tree object.
*
* @param {string | object | FileMiddleware} source -
* Can be one of:
* - relative path to a directory;
* - Object hash of { sourceTemplate: targetFile } mappings;
* - a custom file middleware function.
* @param {object} [additionalData] - additional data available to templates.
* @param {object} [ejsOptions] - options for ejs.
*/
render (source, additionalData = {}, ejsOptions = {}) {
const baseDir = extractCallDir()
if (isString(fileDir)) {
fileDir = path.resolve(baseDir, fileDir)
this.injectFileMiddleware(async (files) => {
if (isString(source)) {
source = path.resolve(baseDir, source)
this._injectFileMiddleware(async (files) => {
const data = this._resolveData(additionalData)
const _files = await globby(['**/*'], { cwd: fileDir })
const _files = await globby(['**/*'], { cwd: source })
for (const rawPath of _files) {
let filename = path.basename(rawPath)
// dotfiles are ignored when published to npm, therefore in templates
@@ -96,7 +155,7 @@ module.exports = class GeneratorAPI {
filename = `.${filename.slice(1)}`
}
const targetPath = path.join(path.dirname(rawPath), filename)
const sourcePath = path.resolve(fileDir, rawPath)
const sourcePath = path.resolve(source, rawPath)
const content = renderFile(sourcePath, data, ejsOptions)
// only set file if it's not all whitespace, or is a Buffer (binary files)
if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
@@ -104,37 +163,40 @@ module.exports = class GeneratorAPI {
}
}
})
} else if (isObject(fileDir)) {
this.injectFileMiddleware(files => {
} else if (isObject(source)) {
this._injectFileMiddleware(files => {
const data = this._resolveData(additionalData)
for (const targetPath in fileDir) {
const sourcePath = path.resolve(baseDir, fileDir[targetPath])
for (const targetPath in source) {
const sourcePath = path.resolve(baseDir, source[targetPath])
const content = renderFile(sourcePath, data, ejsOptions)
if (Buffer.isBuffer(content) || content.trim()) {
files[targetPath] = content
}
}
})
} else if (isFunction(fileDir)) {
this.injectFileMiddleware(fileDir)
} else if (isFunction(source)) {
this._injectFileMiddleware(source)
}
}
/**
* Push a file middleware that will be applied after all normal file
* middelwares have been applied.
*
* @param {FileMiddleware} cb
*/
postProcessFiles (cb) {
this.generator.postProcessFilesCbs.push(cb)
}
/**
* Push a callback to be called when the files have been written to disk.
*
* @param {function} cb
*/
onCreateComplete (cb) {
this.generator.completeCbs.push(cb)
}
resolve (_path) {
return path.resolve(this.generator.context, _path)
}
hasPlugin (id) {
return this.generator.hasPlugin(id)
}
}
function extractCallDir () {
@@ -152,3 +214,5 @@ function renderFile (name, data, ejsOptions) {
}
return ejs.render(fs.readFileSync(name, 'utf-8'), data, ejsOptions)
}
module.exports = GeneratorAPI