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 this.hooks.invoke('createConfig', this.config)
125 }
126
127 get isProd() {
128 return this.mode === 'production'
129 }
130
131 initCLI() {
132 const cli = (this.cli = cac())
133 this.command = cli
134 .command('[...entries]', 'Entry files to start bundling', {
135 ignoreOptionDefaultValue: true
136 })
137 .usage('[...entries] [options]')
138 .action(async () => {
139 logger.debug(`Using default handler`)
140 const chain = this.createWebpackChain()
141 const compiler = this.createWebpackCompiler(chain.toConfig())
142 await this.runCompiler(compiler)
143 })
144
145 this.extendCLI()
146
147 // Global options
148 cli
149 .option('--mode <mode>', 'Set mode', 'development')
150 .option('--prod, --production', 'Alias for --mode production')
151 .option('--test', 'Alias for --mode test')
152 .option('--no-config', 'Disable config file')
153 .option('--config <path>', 'Set the path to config file')
154 .option(
155 '--plugin, --plugins <plugin>',
156 'Add a plugin (can be used for multiple times)'
157 )
158 .option('--debug', 'Show debug logs')
159 .option('--inspect-webpack', 'Inspect webpack config in your editor')
160 .version(require('../package').version)
161 .help(sections => {
162 for (const section of sections) {
163 if (section.title && section.title.includes('For more info')) {
164 const body = section.body.split('\n')
165 body.shift()
166 body.unshift(
167 ` $ ${cli.name} --help`,
168 ` $ ${cli.name} --serve --help`,
169 ` $ ${cli.name} --prod --help`
170 )
171 section.body = body.join('\n')
172 }
173 }
174 })
175
176 this.cli.parse(this.rawArgs, { run: false })
177
178 logger.debug('Command args', this.cli.args)
179 logger.debug('Command options', this.cli.options)
180 }
181
182 hasDependency(name) {
183 return [
184 ...Object.keys(this.pkg.data.dependencies || {}),
185 ...Object.keys(this.pkg.data.devDependencies || {})
186 ].includes(name)
187 }
188
189 /**
190 * @private
191 * @returns {void}
192 */
193 initPlugins() {
194 const cwd = this.resolveCwd()
195 const cliPlugins = normalizePlugins(
196 this.args.get('plugin') || this.args.get('plugins'),
197 cwd
198 )
199 const configPlugins = normalizePlugins(this.config.plugins, cwd)
200
201 this.plugins = [
202 { resolve: require.resolve('./plugins/command-options') },
203 { resolve: require.resolve('./plugins/config-babel') },
204 { resolve: require.resolve('./plugins/config-vue') },
205 { resolve: require.resolve('./plugins/config-css') },
206 { resolve: require.resolve('./plugins/config-font') },
207 { resolve: require.resolve('./plugins/config-image') },
208 { resolve: require.resolve('./plugins/config-eval') },
209 { resolve: require.resolve('./plugins/config-html') },
210 { resolve: require.resolve('./plugins/config-electron') },
211 { resolve: require.resolve('./plugins/config-misc-loaders') },
212 { resolve: require.resolve('./plugins/config-reason') },
213 { resolve: require.resolve('./plugins/config-yarn-pnp') },
214 { resolve: require.resolve('./plugins/watch') },
215 { resolve: require.resolve('./plugins/serve') },
216 { resolve: require.resolve('./plugins/eject-html') },
217 { resolve: require.resolve('@poi/plugin-html-entry') }
218 ]
219 .concat(mergePlugins(configPlugins, cliPlugins))
220 .map(plugin => {
221 if (typeof plugin.resolve === 'string') {
222 plugin._resolve = plugin.resolve
223 plugin.resolve = require(plugin.resolve)
224 }
225 return plugin
226 })
227 }
228
229 extendCLI() {
230 for (const plugin of this.plugins) {
231 if (plugin.resolve.cli) {
232 plugin.resolve.cli(this, plugin.options)
233 }
234 }
235 }
236
237 /**
238 * @private
239 * @returns {void}
240 */
241 applyPlugins() {
242 let plugins = this.plugins.filter(plugin => {
243 return !plugin.resolve.when || plugin.resolve.when(this)
244 })
245
246 // Run plugin's `filterPlugins` method
247 for (const plugin of plugins) {
248 if (plugin.resolve.filterPlugins) {
249 plugins = plugin.resolve.filterPlugins(this.plugins, plugin.options)
250 }
251 }
252
253 // Run plugin's `apply` method
254 for (const plugin of plugins) {
255 if (plugin.resolve.apply) {
256 logger.debug(`Apply plugin: \`${chalk.bold(plugin.resolve.name)}\``)
257 if (plugin._resolve) {
258 logger.debug(`location: ${plugin._resolve}`)
259 }
260 plugin.resolve.apply(this, plugin.options)
261 }
262 }
263 }
264
265 hasPlugin(name) {
266 return (
267 this.plugins &&
268 this.plugins.find(plugin => {
269 return plugin.resolve.name === name
270 })
271 )
272 }
273
274 mergeConfig() {
275 const cliConfig = this.createConfigFromCLIOptions()
276 logger.debug(`Config from command options`, cliConfig)
277
278 this.config = validateConfig(this, merge({}, this.config, cliConfig))
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}