UNPKG

12.5 kBJavaScriptView Raw
1const os = require('os')
2const path = require('path')
3const fs = require('fs-extra')
4const resolveFrom = require('resolve-from')
5const cac = require('cac')
6const chalk = require('chalk')
7const merge = require('lodash.merge')
8const logger = require('@poi/logger')
9const Hooks = require('./Hooks')
10const WebpackUtils = require('./WebpackUtils')
11const createConfigLoader = require('./utils/createConfigLoader')
12const loadEnvs = require('./utils/loadEnvs')
13const parseArgs = require('./utils/parseArgs')
14const PoiError = require('./utils/PoiError')
15const spinner = require('./utils/spinner')
16const validateConfig = require('./utils/validateConfig')
17const { normalizePlugins, mergePlugins } = require('./utils/plugins')
18
19module.exports = class PoiCore {
20 constructor(
21 rawArgs = process.argv,
22 {
23 defaultConfigFiles = [
24 'poi.config.js',
25 'package.json',
26 '.poirc',
27 '.poirc.json',
28 '.poirc.js'
29 ],
30 extendConfigLoader,
31 config: externalConfig
32 } = {}
33 ) {
34 this.rawArgs = rawArgs
35 this.logger = logger
36 this.spinner = spinner
37 this.PoiError = PoiError
38 // For plugins, it's only used in plugin.cli export
39 this.args = parseArgs(rawArgs)
40 this.hooks = new Hooks()
41 this.testRunners = new Map()
42
43 if (this.args.has('debug')) {
44 logger.setOptions({ debug: true })
45 }
46
47 this.mode = this.args.get('mode')
48 if (!this.mode) {
49 this.mode = 'development'
50 }
51
52 if (this.args.has('prod') || this.args.has('production')) {
53 this.mode = 'production'
54 }
55
56 if (this.args.has('test')) {
57 this.mode = 'test'
58 }
59
60 if (this.args.args[0] && /^test(:|$)/.test(this.args.args[0])) {
61 this.mode = 'test'
62 }
63
64 logger.debug(`Running in ${this.mode} mode`)
65
66 this.cwd = this.args.get('cwd')
67 if (!this.cwd) {
68 this.cwd = process.cwd()
69 }
70
71 this.configLoader = createConfigLoader(this.cwd)
72
73 // For other tools that use Poi under the hood
74 if (extendConfigLoader) {
75 extendConfigLoader(this.configLoader)
76 }
77
78 // Load .env files
79 loadEnvs(this.mode, this.resolveCwd('.env'))
80
81 if (!process.env.NODE_ENV) {
82 process.env.NODE_ENV = this.mode
83 }
84
85 this.webpackUtils = new WebpackUtils(this)
86
87 // Try to load config file
88 if (externalConfig || this.args.get('config') === false) {
89 logger.debug('Poi config file was disabled')
90 this.config = externalConfig || {}
91 } else {
92 const configFiles =
93 typeof this.args.get('config') === 'string'
94 ? [this.args.get('config')]
95 : defaultConfigFiles
96 const { path: configPath, data: configFn } = this.configLoader.load({
97 files: configFiles,
98 packageKey: 'poi'
99 })
100 if (configPath) {
101 logger.debug(`Using Poi config file:`, configPath)
102 } else {
103 logger.debug(`Not using any Poi config file`)
104 }
105 this.configPath = configPath
106 this.config =
107 typeof configFn === 'function' ? configFn(this.args.options) : configFn
108 this.config = this.config || {}
109 }
110
111 this.pkg = this.configLoader.load({
112 files: ['package.json']
113 })
114 this.pkg.data = this.pkg.data || {}
115
116 // Initialize plugins
117 this.initPlugins()
118 // Init CLI instance, call plugin.cli, parse CLI args
119 this.initCLI()
120 // Merge cli config with config file
121 this.mergeConfig()
122 // Call plugin.apply
123 this.applyPlugins()
124 }
125
126 get isProd() {
127 return this.mode === 'production'
128 }
129
130 initCLI() {
131 const cli = (this.cli = cac())
132 this.command = cli
133 .command('[...entries]', 'Entry files to start bundling', {
134 ignoreOptionDefaultValue: true
135 })
136 .usage('[...entries] [options]')
137 .action(async () => {
138 logger.debug(`Using default handler`)
139 const chain = this.createWebpackChain()
140 const compiler = this.createWebpackCompiler(chain.toConfig())
141 await this.runCompiler(compiler)
142 })
143
144 this.extendCLI()
145
146 // Global options
147 cli
148 .option('--mode <mode>', 'Set mode', 'development')
149 .option('--prod, --production', 'Alias for --mode production')
150 .option('--test', 'Alias for --mode test')
151 .option('--no-config', 'Disable config file')
152 .option('--config <path>', 'Set the path to config file')
153 .option(
154 '--plugin, --plugins <plugin>',
155 'Add a plugin (can be used for multiple times)'
156 )
157 .option('--debug', 'Show debug logs')
158 .option('--inspect-webpack', 'Inspect webpack config in your editor')
159 .version(require('../package').version)
160 .help(sections => {
161 for (const section of sections) {
162 if (section.title && section.title.includes('For more info')) {
163 const body = section.body.split('\n')
164 body.shift()
165 body.unshift(
166 ` $ ${cli.name} --help`,
167 ` $ ${cli.name} --serve --help`,
168 ` $ ${cli.name} --prod --help`
169 )
170 section.body = body.join('\n')
171 }
172 }
173 })
174
175 this.cli.parse(this.rawArgs, { run: false })
176
177 logger.debug('Command args', this.cli.args)
178 logger.debug('Command options', this.cli.options)
179 }
180
181 hasDependency(name) {
182 return [
183 ...Object.keys(this.pkg.data.dependencies || {}),
184 ...Object.keys(this.pkg.data.devDependencies || {})
185 ].includes(name)
186 }
187
188 /**
189 * @private
190 * @returns {void}
191 */
192 initPlugins() {
193 const cwd = this.resolveCwd()
194 const cliPlugins = normalizePlugins(
195 this.args.get('plugin') || this.args.get('plugins'),
196 cwd
197 )
198 const configPlugins = normalizePlugins(this.config.plugins, cwd)
199
200 this.plugins = [
201 { resolve: require.resolve('./plugins/command-options') },
202 { resolve: require.resolve('./plugins/config-babel') },
203 { resolve: require.resolve('./plugins/config-vue') },
204 { resolve: require.resolve('./plugins/config-css') },
205 { resolve: require.resolve('./plugins/config-font') },
206 { resolve: require.resolve('./plugins/config-image') },
207 { resolve: require.resolve('./plugins/config-eval') },
208 { resolve: require.resolve('./plugins/config-html') },
209 { resolve: require.resolve('./plugins/config-electron') },
210 { resolve: require.resolve('./plugins/config-misc-loaders') },
211 { resolve: require.resolve('./plugins/config-reason') },
212 { resolve: require.resolve('./plugins/config-yarn-pnp') },
213 { resolve: require.resolve('./plugins/watch') },
214 { resolve: require.resolve('./plugins/serve') },
215 { resolve: require.resolve('./plugins/eject-html') }
216 ]
217 .concat(mergePlugins(configPlugins, cliPlugins))
218 .map(plugin => {
219 if (typeof plugin.resolve === 'string') {
220 plugin._resolve = plugin.resolve
221 plugin.resolve = require(plugin.resolve)
222 }
223 return plugin
224 })
225 }
226
227 extendCLI() {
228 for (const plugin of this.plugins) {
229 if (plugin.resolve.cli) {
230 plugin.resolve.cli(this, plugin.options)
231 }
232 }
233 }
234
235 /**
236 * @private
237 * @returns {void}
238 */
239 applyPlugins() {
240 let plugins = this.plugins.filter(plugin => {
241 return !plugin.resolve.when || plugin.resolve.when(this)
242 })
243
244 // Run plugin's `filterPlugins` method
245 for (const plugin of plugins) {
246 if (plugin.resolve.filterPlugins) {
247 plugins = plugin.resolve.filterPlugins(this.plugins, plugin.options)
248 }
249 }
250
251 // Run plugin's `apply` method
252 for (const plugin of plugins) {
253 if (plugin.resolve.apply) {
254 logger.debug(`Apply plugin: \`${chalk.bold(plugin.resolve.name)}\``)
255 if (plugin._resolve) {
256 logger.debug(`location: ${plugin._resolve}`)
257 }
258 plugin.resolve.apply(this, plugin.options)
259 }
260 }
261 }
262
263 hasPlugin(name) {
264 return (
265 this.plugins &&
266 this.plugins.find(plugin => {
267 return plugin.resolve.name === name
268 })
269 )
270 }
271
272 mergeConfig() {
273 const cliConfig = this.createConfigFromCLIOptions()
274 logger.debug(`Config from command options`, cliConfig)
275
276 this.config = validateConfig(this, merge({}, this.config, cliConfig))
277
278 this.hooks.invoke('createConfig', this.config)
279 }
280
281 hook(name, fn) {
282 this.hooks.add(name, fn)
283 return this
284 }
285
286 registerTestRunner(name, runner) {
287 if (this.testRunners.has(name)) {
288 throw new PoiError({
289 message: `Test runner "${name}" has already been registered!`
290 })
291 }
292 this.testRunners.set(name, runner)
293 return this
294 }
295
296 resolveCwd(...args) {
297 return path.resolve(this.cwd, ...args)
298 }
299
300 resolveOutDir(...args) {
301 return this.resolveCwd(this.config.output.dir, ...args)
302 }
303
304 async run() {
305 await this.hooks.invokePromise('beforeRun')
306
307 await this.cli.runMatchedCommand()
308
309 await this.hooks.invokePromise('afterRun')
310 }
311
312 createConfigFromCLIOptions() {
313 const {
314 minimize,
315 sourceMap,
316 format,
317 moduleName,
318 outDir,
319 publicUrl,
320 target,
321 clean,
322 parallel,
323 cache,
324 jsx,
325 extractCss,
326 hot,
327 host,
328 port,
329 open,
330 proxy,
331 fileNames,
332 html,
333 publicFolder
334 } = this.cli.options
335 return {
336 entry: this.cli.args.length > 0 ? this.cli.args : undefined,
337 output: {
338 minimize,
339 sourceMap,
340 format,
341 moduleName,
342 dir: outDir,
343 publicUrl,
344 target,
345 clean,
346 fileNames,
347 html
348 },
349 parallel,
350 cache,
351 publicFolder,
352 babel: {
353 jsx
354 },
355 css: {
356 extract: extractCss
357 },
358 devServer: {
359 hot,
360 host,
361 port,
362 open,
363 proxy
364 }
365 }
366 }
367
368 createWebpackChain(opts) {
369 const WebpackChain = require('./utils/WebpackChain')
370
371 opts = Object.assign({ type: 'client', mode: this.mode }, opts)
372
373 const config = new WebpackChain({
374 configureWebpack: this.config.configureWebpack,
375 opts
376 })
377
378 require('./webpack/webpack.config')(config, this)
379
380 this.hooks.invoke('createWebpackChain', config, opts)
381
382 if (this.config.chainWebpack) {
383 this.config.chainWebpack(config, opts)
384 }
385
386 if (this.cli.options.inspectWebpack) {
387 const inspect = () => {
388 const id = Math.random()
389 .toString(36)
390 .substring(7)
391 const outFile = path.join(
392 os.tmpdir(),
393 `poi-inspect-webpack-config-${id}.js`
394 )
395 const configString = `// ${JSON.stringify(
396 opts
397 )}\nvar config = ${config.toString()}\n\n`
398 fs.writeFileSync(outFile, configString, 'utf8')
399 require('@poi/dev-utils/open')(outFile, {
400 wait: false
401 })
402 }
403
404 config.plugin('inspect-webpack').use(
405 class InspectWebpack {
406 apply(compiler) {
407 compiler.hooks.afterEnvironment.tap('inspect-webpack', inspect)
408 }
409 }
410 )
411 }
412
413 return config
414 }
415
416 runCompiler(compiler, watch) {
417 return new Promise((resolve, reject) => {
418 if (watch) {
419 compiler.watch({}, err => {
420 if (err) return reject(err)
421 resolve()
422 })
423 } else {
424 compiler.run((err, stats) => {
425 if (err) return reject(err)
426 resolve(stats)
427 })
428 }
429 })
430 }
431
432 createWebpackCompiler(config) {
433 const compiler = require('webpack')(config)
434
435 // Override the .watch method so we can handle error here instead of letting WDM handle it
436 // And in fact we disabled WDM logger so errors will never show displayed there
437 const originalWatch = compiler.watch.bind(compiler)
438 compiler.watch = (options, cb) => {
439 return originalWatch(options, (err, stats) => {
440 if (err) {
441 throw err
442 }
443 cb(null, stats)
444 })
445 }
446
447 return compiler
448 }
449
450 getCacheConfig(dir, keys, files) {
451 let content = ''
452 if (files) {
453 const file = this.configLoader.resolve({ files: [].concat(files) })
454 if (file) {
455 content = fs.readFileSync(file, 'utf8')
456 }
457 }
458
459 return {
460 cacheDirectory: this.resolveCwd('node_modules/.cache', dir),
461 cacheIdentifier: `${JSON.stringify(keys)}::${content}`
462 }
463 }
464
465 localResolve(name, cwd = this.cwd) {
466 return resolveFrom.silent(cwd, name)
467 }
468
469 localRequire(name, cwd) {
470 const resolved = this.localResolve(name, cwd)
471 return resolved ? require(resolved) : null
472 }
473}