From e597d12f41f0e28cd183b14760154b60255eae40 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 5 Feb 2018 19:58:13 -0500 Subject: [PATCH] docs: improve documentation for plugin development --- README.md | 4 +- docs/Plugin.md | 132 +++++++++++++++++---- docs/README.md | 51 ++++++++ packages/@vue/cli-service/lib/PluginAPI.js | 88 ++++++++++++-- packages/@vue/cli/lib/GeneratorAPI.js | 108 +++++++++++++---- 5 files changed, 332 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 10bd22ed7..504313b7c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/Plugin.md b/docs/Plugin.md index 4dfae7ea2..779d04c84 100644 --- a/docs/Plugin.md +++ b/docs/Plugin.md @@ -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 ` command; @@ -25,17 +24,36 @@ Both utilize a plugin-based architecture. [Service][service-class] is the class created when invoking `vue-cli-service [...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 diff --git a/docs/README.md b/docs/README.md index 85e6ff194..4d242c5d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/packages/@vue/cli-service/lib/PluginAPI.js b/packages/@vue/cli-service/lib/PluginAPI.js index 42606ddc8..8f3f5ee59 100644 --- a/packages/@vue/cli-service/lib/PluginAPI.js +++ b/packages/@vue/cli-service/lib/PluginAPI.js @@ -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 diff --git a/packages/@vue/cli/lib/GeneratorAPI.js b/packages/@vue/cli/lib/GeneratorAPI.js index e7e434a11..af3c86e3d 100644 --- a/packages/@vue/cli/lib/GeneratorAPI.js +++ b/packages/@vue/cli/lib/GeneratorAPI.js @@ -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