diff --git a/packages/shuvi/src/api/__tests__/api.test.ts b/packages/shuvi/src/api/__tests__/api.test.ts index 485e1fc54..ee71de89a 100644 --- a/packages/shuvi/src/api/__tests__/api.test.ts +++ b/packages/shuvi/src/api/__tests__/api.test.ts @@ -1,18 +1,10 @@ -import { getApi, Api } from '../api'; +import { getApi } from '../api'; import { PluginApi } from '../pluginApi'; -import { resolvePlugin } from './utils'; import { IApiConfig, IPaths } from '@shuvi/types'; import path from 'path'; +import { resolvePreset } from './utils'; describe('api', () => { - let gApi: Api; - beforeAll(async () => { - gApi = await getApi({ - mode: 'development', - config: {} - }); - }); - test('should has "production" be default mode', async () => { const prodApi = await getApi({ config: {} }); expect(prodApi.mode).toBe('production'); @@ -28,19 +20,6 @@ describe('api', () => { expect(pluginApi!.paths).toBe(api.paths); }); - test('should modify config', async () => { - let pluginApi: PluginApi; - const api = await getApi({ - config: { - plugins: [resolvePlugin('modify-config'), api => (pluginApi = api)] - } - }); - const plugins = (pluginApi! as any).__plugins; - expect(plugins.length).toBe(1); - expect(plugins[0].name).toBe('modify-config'); - expect(api.config.publicPath).toBe('/bar'); - }); - test('should access config and paths', async () => { let config: IApiConfig; let paths: IPaths; @@ -62,12 +41,38 @@ describe('api', () => { }); }); - test('getPluginApi', () => { - const pluginApi = gApi.getPluginApi(); + describe('presets', () => { + test('should work', async () => { + const api = await getApi({ + config: { presets: [resolvePreset('a-b-preset')] } + }); + const plugins = (api as any)._presetPlugins; + expect(plugins.length).toBe(2); + expect(plugins[0].id).toMatch(/plugin-a/); + expect(plugins[1].id).toMatch(/plugin-b/); + }); + + test('should work with nested preset', async () => { + const api = await getApi({ + config: { presets: [resolvePreset('nest-preset-preset')] } + }); + const plugins = (api as any)._presetPlugins; + expect(plugins.length).toBe(3); + expect(plugins[0].id).toMatch(/plugin-a/); + expect(plugins[1].id).toMatch(/plugin-b/); + expect(plugins[2].id).toMatch(/plugin-c/); + }); + }); + + test('getPluginApi', async () => { + let pluginApi!: PluginApi; + const api = await getApi({ + config: { plugins: [api => (pluginApi = api)] } + }); - expect(pluginApi.mode).toBe(gApi.mode); - expect(pluginApi.paths).toBe(gApi.paths); - expect(pluginApi.config).toBe(gApi.config); + expect(pluginApi.mode).toBe(api.mode); + expect(pluginApi.paths).toBe(api.paths); + expect(pluginApi.config).toBe(api.config); [ 'tap', diff --git a/packages/shuvi/src/api/__tests__/fixtures/plugins/modify-config.js b/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-a.js similarity index 61% rename from packages/shuvi/src/api/__tests__/fixtures/plugins/modify-config.js rename to packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-a.js index dabcf2651..4984809b8 100644 --- a/packages/shuvi/src/api/__tests__/fixtures/plugins/modify-config.js +++ b/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-a.js @@ -1,12 +1,7 @@ module.exports = class Plugin { constructor(options) { this.options = options; - this.name = 'modify-config'; - } - - modifyConfig(config) { - config.publicPath = '/bar'; - return config; + this.name = 'a'; } apply(api) { diff --git a/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-b.js b/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-b.js new file mode 100644 index 000000000..6c6fad3e2 --- /dev/null +++ b/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-b.js @@ -0,0 +1,11 @@ +module.exports = class Plugin { + constructor(options) { + this.options = options; + this.name = 'b'; + } + + apply(api) { + api.__plugins = api.__plugins || []; + api.__plugins.push(this); + } +}; diff --git a/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-c.js b/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-c.js new file mode 100644 index 000000000..4a54aac29 --- /dev/null +++ b/packages/shuvi/src/api/__tests__/fixtures/plugins/plugin-c.js @@ -0,0 +1,11 @@ +module.exports = class Plugin { + constructor(options) { + this.options = options; + this.name = 'c'; + } + + apply(api) { + api.__plugins = api.__plugins || []; + api.__plugins.push(this); + } +}; diff --git a/packages/shuvi/src/api/__tests__/fixtures/presets/a-b-preset.js b/packages/shuvi/src/api/__tests__/fixtures/presets/a-b-preset.js new file mode 100644 index 000000000..6be52a46e --- /dev/null +++ b/packages/shuvi/src/api/__tests__/fixtures/presets/a-b-preset.js @@ -0,0 +1,12 @@ +const path = require('path'); + +function resolvePlugin(name) { + return path.join(__dirname, '..', 'plugins', name); +} + +module.exports = (api, options) => { + return { + presets: [], + plugins: [resolvePlugin('plugin-a'), resolvePlugin('plugin-b')] + }; +}; diff --git a/packages/shuvi/src/api/__tests__/fixtures/presets/nest-preset-preset.js b/packages/shuvi/src/api/__tests__/fixtures/presets/nest-preset-preset.js new file mode 100644 index 000000000..d22c61fd6 --- /dev/null +++ b/packages/shuvi/src/api/__tests__/fixtures/presets/nest-preset-preset.js @@ -0,0 +1,16 @@ +const path = require('path'); + +function resolvePlugin(name) { + return path.join(__dirname, '..', 'plugins', name); +} + +function resolvePreset(name) { + return path.join(__dirname, '..', 'presets', name); +} + +module.exports = (api, options) => { + return { + presets: [resolvePreset('a-b-preset')], + plugins: [resolvePlugin('plugin-c')] + }; +}; diff --git a/packages/shuvi/src/api/__tests__/fixtures/presets/simple-preset.js b/packages/shuvi/src/api/__tests__/fixtures/presets/simple-preset.js new file mode 100644 index 000000000..11d393a52 --- /dev/null +++ b/packages/shuvi/src/api/__tests__/fixtures/presets/simple-preset.js @@ -0,0 +1,12 @@ +module.exports = (api, options) => { + api.__presets = api.__presets || []; + api.__presets.push({ + name: 'simple-preset', + options + }); + + return { + presets: [], + plugins: [] + }; +}; diff --git a/packages/shuvi/src/api/__tests__/plugin.test.ts b/packages/shuvi/src/api/__tests__/plugin.test.ts index 21e8ac1ee..5403a893c 100644 --- a/packages/shuvi/src/api/__tests__/plugin.test.ts +++ b/packages/shuvi/src/api/__tests__/plugin.test.ts @@ -1,6 +1,6 @@ import path from 'path'; -import { IPluginConfig } from '@shuvi/types'; -import { resolvePlugins } from '../plugin'; +import { IPluginConfig, IPresetConfig } from '@shuvi/types'; +import { resolvePlugins, resolvePresets } from '../plugin'; function callPlugins(context: any, ...plugins: IPluginConfig[]) { resolvePlugins(plugins, { @@ -8,6 +8,12 @@ function callPlugins(context: any, ...plugins: IPluginConfig[]) { }).forEach(p => p.get().apply(context)); } +function callPresets(context: any, ...presets: IPresetConfig[]) { + resolvePresets(presets, { + dir: path.join(__dirname, 'fixtures/presets') + }).forEach(p => p.get()(context)); +} + describe('plugin', () => { test('should accept class module as a plugin', () => { const api = {}; @@ -70,3 +76,22 @@ describe('plugin', () => { }); }); }); + +describe('preset', () => { + test('should accept function module as a plugin', () => { + const api = {}; + callPresets(api, './simple-preset'); + const presets = (api as any).__presets; + expect(presets.length).toBe(1); + expect(presets[0].name).toBe('simple-preset'); + }); + + test('should accept module and option', () => { + const api = {}; + callPresets(api, ['./simple-preset', { test: 1 }]); + const presets = (api as any).__presets; + expect(presets.length).toBe(1); + expect(presets[0].name).toBe('simple-preset'); + expect(presets[0].options).toMatchObject({ test: 1 }); + }); +}); diff --git a/packages/shuvi/src/api/__tests__/utils.ts b/packages/shuvi/src/api/__tests__/utils.ts index 0b05b3bdd..97fc388a7 100644 --- a/packages/shuvi/src/api/__tests__/utils.ts +++ b/packages/shuvi/src/api/__tests__/utils.ts @@ -3,3 +3,7 @@ import path from 'path'; export function resolvePlugin(name: string) { return path.join(__dirname, 'fixtures', 'plugins', name); } + +export function resolvePreset(name: string) { + return path.join(__dirname, 'fixtures', 'presets', name); +} diff --git a/packages/shuvi/src/api/api.ts b/packages/shuvi/src/api/api.ts index 388bc3ef5..8daf34382 100644 --- a/packages/shuvi/src/api/api.ts +++ b/packages/shuvi/src/api/api.ts @@ -10,17 +10,18 @@ import { import { App, IRouteConfig, IFile } from '@shuvi/core'; import { joinPath } from '@shuvi/utils/lib/string'; import { deepmerge } from '@shuvi/utils/lib/deepmerge'; +import invariant from '@shuvi/utils/lib/invariant'; import { Hookable } from '@shuvi/hooks'; import { setRuntimeConfig } from '../lib/runtimeConfig'; import { serializeRoutes, normalizeRoutes } from '../lib/routes'; import { PUBLIC_PATH, ROUTE_RESOURCE_QUERYSTRING } from '../constants'; import { runtime } from '../runtime'; import { defaultConfig, IConfig, loadConfig } from '../config'; -import { IResources, IBuiltResource, IPlugin } from './types'; +import { IResources, IBuiltResource, IPlugin, IPreset } from './types'; import { Server } from '../server'; import { setupApp } from './setupApp'; import { initCoreResource } from './initCoreResource'; -import { resolvePlugins } from './plugin'; +import { resolvePlugins, resolvePresets } from './plugin'; import { createPluginApi, PluginApi } from './pluginApi'; import { getPaths } from './paths'; @@ -44,7 +45,9 @@ class Api extends Hookable implements IApi { private _server!: Server; private _resources: IResources = {} as IResources; private _routes: IRouteConfig[] = []; + private _presetPlugins: IPlugin[] = []; private _plugins!: IPlugin[]; + private _presets!: IPreset[]; private _pluginApi!: PluginApi; constructor({ cwd, mode, config, configFile }: IApiOPtions) { @@ -83,46 +86,7 @@ class Api extends Hookable implements IApi { overrides: this._userConfig }); - const config: IApiConfig = deepmerge(defaultConfig, configFromFile); - - // init plugins - this._plugins = resolvePlugins(config.plugins || [], { - dir: config.rootDir - }); - - let runPlugins = function runNext( - next?: Function, - cur: Function = () => void 0 - ) { - if (next) { - return (n: Function) => - runNext(n, () => { - cur(); - next(); - }); - } - return cur(); - }; - for (const plugin of this._plugins) { - const pluginInst = plugin.get(); - if (typeof pluginInst.modifyConfig === 'function') { - this.tap('getConfig', { - name: 'pluginModifyConfig', - fn(config) { - return pluginInst.modifyConfig!(config); - } - }); - } - runPlugins = runPlugins(() => { - pluginInst.apply(this.getPluginApi()); - }); - } - - // prepare all properties befofre run plugins, so plugin can use all api of Api - this._config = await this.callHook({ - name: 'getConfig', - initialValue: config - }); + this._config = deepmerge(defaultConfig, configFromFile); // do not allow to modify config Object.freeze(this._config); this._paths = getPaths({ @@ -133,12 +97,12 @@ class Api extends Hookable implements IApi { // do not allow to modify paths Object.freeze(this._paths); - runPlugins(); + this._initPresetsAndPlugins(); initCoreResource(this); // TODO?: move into application - if (typeof this.config.runtimeConfig === 'object') { - setRuntimeConfig(this.config.runtimeConfig); + if (typeof this._config.runtimeConfig === 'object') { + setRuntimeConfig(this._config.runtimeConfig); } } @@ -307,7 +271,15 @@ class Api extends Hookable implements IApi { return joinPath(this.paths.publicDir, ...paths); } - getPluginApi(): PluginApi { + async destory() { + if (this._server) { + await this._server.close(); + } + this._app.stopBuild(this.paths.appDir); + await this.callHook('destory'); + } + + private _getPluginApi(): PluginApi { if (!this._pluginApi) { this._pluginApi = createPluginApi(this); } @@ -315,12 +287,57 @@ class Api extends Hookable implements IApi { return this._pluginApi; } - async destory() { - if (this._server) { - await this._server.close(); + private _initPresetsAndPlugins() { + const config = this._config; + // init presets + this._presets = resolvePresets(config.presets || [], { + dir: config.rootDir + }); + for (const preset of this._presets) { + this._initPreset(preset); + } + + // init plugins + this._plugins = resolvePlugins(this._config.plugins || [], { + dir: this._config.rootDir + }); + const allPlugins = this._presetPlugins.concat(this._plugins); + for (const plugin of allPlugins) { + plugin.get().apply(this._getPluginApi()); + } + } + + private _initPreset(preset: IPreset) { + const { id, get: getPreset } = preset; + const { presets, plugins } = getPreset()(this._getPluginApi()); + + if (presets) { + invariant( + Array.isArray(presets), + `presets returned from preset ${id} must be Array.` + ); + + const resolvedPresets = resolvePresets(presets, { + dir: this._config.rootDir + }); + + for (const preset of resolvedPresets) { + this._initPreset(preset); + } + } + + if (plugins) { + invariant( + Array.isArray(plugins), + `presets returned from preset ${id} must be Array.` + ); + + this._presetPlugins.push( + ...resolvePlugins(plugins, { + dir: this._config.rootDir + }) + ); } - this._app.stopBuild(this.paths.appDir); - await this.callHook('destory'); } } diff --git a/packages/shuvi/src/api/plugin.ts b/packages/shuvi/src/api/plugin.ts index f3eff823a..8ffea6958 100644 --- a/packages/shuvi/src/api/plugin.ts +++ b/packages/shuvi/src/api/plugin.ts @@ -1,6 +1,6 @@ -import { IPluginConfig, IApi } from '@shuvi/types'; +import { IPluginConfig, IApi, IPresetConfig } from '@shuvi/types'; import resolve from '@shuvi/utils/lib/resolve'; -import { IPlugin, IPluginSpec } from './types'; +import { IPlugin, IPluginSpec, IPreset, IPresetSpec } from './types'; export interface ResolvePluginOptions { dir: string; @@ -71,9 +71,53 @@ function resolvePlugin( }; } +function resolvePreset( + presetConfig: IPresetConfig, + resolveOptions: ResolvePluginOptions +): IPreset { + let presetPath: string; + let options: any; + + if (Array.isArray(presetConfig)) { + presetPath = presetConfig[0]; + const nameOrOption = presetConfig[1]; + if (typeof nameOrOption === 'string') { + options = {}; + } else { + options = nameOrOption; + } + } else if (typeof presetConfig === 'string') { + presetPath = presetConfig; + options = {}; + } else { + throw new Error(`Plugin must be one of type [string, array, function]`); + } + + presetPath = resolve.sync(presetPath, { basedir: resolveOptions.dir }); + + const id = presetPath; + let preset = require(presetPath); + preset = preset.default || preset; + const presetFn: IPresetSpec = (api: IApi) => { + return preset(api, options); + }; + + return { + id, + get: () => presetFn + }; +} + export function resolvePlugins( plugins: IPluginConfig[], options: ResolvePluginOptions ): IPlugin[] { return plugins.map(plugin => resolvePlugin(plugin, options)); } + +export function resolvePresets( + presets: IPresetConfig[], + options: ResolvePluginOptions +): IPreset[] { + return presets.map(preset => resolvePreset(preset, options)); +} diff --git a/packages/shuvi/src/api/types.ts b/packages/shuvi/src/api/types.ts index f57c66911..faa4016eb 100644 --- a/packages/shuvi/src/api/types.ts +++ b/packages/shuvi/src/api/types.ts @@ -25,7 +25,19 @@ export interface IPluginSpec { apply(api: IApi): void; } +export interface IPresetSpec { + (api: IApi): { + presets?: IApiConfig['presets']; + plugins?: IApiConfig['plugins']; + }; +} + export interface IPlugin { id: string; get: () => IPluginSpec; } + +export interface IPreset { + id: string; + get: () => IPresetSpec; +} diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 3eefe5cd2..9d5f5f659 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -64,11 +64,15 @@ export type IPluginConfig = | string | [ string /* plugin module */, - any? /* plugin, options */, + any? /* plugin options */, string? /* identifier */ ] | ((api: IApi) => void); +export type IPresetConfig = + | string + | [string /* plugin module */, any? /* plugin options */]; + export type IRuntimeConfig = Record; export interface IApiConfig { @@ -85,6 +89,7 @@ export interface IApiConfig { runtimeConfig?: IRuntimeConfig; proxy?: IServerProxyConfig; plugins?: IPluginConfig[]; + presets?: IPresetConfig[]; analyze?: boolean; }