UNPKG

12.6 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.configPath
195 ? path.dirname(this.configPath)
196 : this.resolveCwd()
197 const cliPlugins = normalizePlugins(
198 this.args.get('plugin') || this.args.get('plugins'),
199 cwd
200 )
201 const configPlugins = normalizePlugins(this.config.plugins, cwd)
202
203 this.plugins = [
204 { resolve: require.resolve('./plugins/command-options') },
205 { resolve: require.resolve('./plugins/config-babel') },
206 { resolve: require.resolve('./plugins/config-vue') },
207 { resolve: require.resolve('./plugins/config-css') },
208 { resolve: require.resolve('./plugins/config-font') },
209 { resolve: require.resolve('./plugins/config-image') },
210 { resolve: require.resolve('./plugins/config-eval') },
211 { resolve: require.resolve('./plugins/config-html') },
212 { resolve: require.resolve('./plugins/config-electron') },
213 { resolve: require.resolve('./plugins/config-misc-loaders') },
214 { resolve: require.resolve('./plugins/config-reason') },
215 { resolve: require.resolve('./plugins/config-yarn-pnp') },
216 { resolve: require.resolve('./plugins/watch') },
217 { resolve: require.resolve('./plugins/serve') },
218 { resolve: require.resolve('./plugins/eject-html') },
219 { resolve: require.resolve('@poi/plugin-html-entry') }
220 ]
221 .concat(mergePlugins(configPlugins, cliPlugins))
222 .map(plugin => {
223 if (typeof plugin.resolve === 'string') {
224 plugin._resolve = plugin.resolve
225 plugin.resolve = require(plugin.resolve)
226 }
227 return plugin
228 })
229 }
230
231 extendCLI() {
232 for (const plugin of this.plugins) {
233 if (plugin.resolve.cli) {
234 plugin.resolve.cli(this, plugin.options)
235 }
236 }
237 }
238
239 /**
240 * @private
241 * @returns {void}
242 */
243 applyPlugins() {
244 let plugins = this.plugins.filter(plugin => {
245 return !plugin.resolve.when || plugin.resolve.when(this)
246 })
247
248 // Run plugin's `filterPlugins` method
249 for (const plugin of plugins) {
250 if (plugin.resolve.filterPlugins) {
251 plugins = plugin.resolve.filterPlugins(this.plugins, plugin.options)
252 }
253 }
254
255 // Run plugin's `apply` method
256 for (const plugin of plugins) {
257 if (plugin.resolve.apply) {
258 logger.debug(`Apply plugin: \`${chalk.bold(plugin.resolve.name)}\``)
259 if (plugin._resolve) {
260 logger.debug(`location: ${plugin._resolve}`)
261 }
262 plugin.resolve.apply(this, plugin.options)
263 }
264 }
265 }
266
267 hasPlugin(name) {
268 return (
269 this.plugins &&
270 this.plugins.find(plugin => {
271 return plugin.resolve.name === name
272 })
273 )
274 }
275
276 mergeConfig() {
277 const cliConfig = this.createConfigFromCLIOptions()
278 logger.debug(`Config from command options`, cliConfig)
279
280 this.config = validateConfig(this, merge({}, this.config, cliConfig))
281 }
282
283 hook(name, fn) {
284 this.hooks.add(name, fn)
285 return this
286 }
287
288 registerTestRunner(name, runner) {
289 if (this.testRunners.has(name)) {
290 throw new PoiError({
291 message: `Test runner "${name}" has already been registered!`
292 })
293 }
294 this.testRunners.set(name, runner)
295 return this
296 }
297
298 resolveCwd(...args) {
299 return path.resolve(this.cwd, ...args)
300 }
301
302 resolveOutDir(...args) {
303 return this.resolveCwd(this.config.output.dir, ...args)
304 }
305
306 async run() {
307 await this.hooks.invokePromise('beforeRun')
308
309 await this.cli.runMatchedCommand()
310
311 await this.hooks.invokePromise('afterRun')
312 }
313
314 createConfigFromCLIOptions() {
315 const {
316 minimize,
317 sourceMap,
318 format,
319 moduleName,
320 outDir,
321 publicUrl,
322 target,
323 clean,
324 parallel,
325 cache,
326 jsx,
327 extractCss,
328 hot,
329 host,
330 port,
331 open,
332 proxy,
333 fileNames,
334 html,
335 publicFolder
336 } = this.cli.options
337 return {
338 entry: this.cli.args.length > 0 ? this.cli.args : undefined,
339 output: {
340 minimize,
341 sourceMap,
342 format,
343 moduleName,
344 dir: outDir,
345 publicUrl,
346 target,
347 clean,
348 fileNames,
349 html
350 },
351 parallel,
352 cache,
353 publicFolder,
354 babel: {
355 jsx
356 },
357 css: {
358 extract: extractCss
359 },
360 devServer: {
361 hot,
362 host,
363 port,
364 open,
365 proxy
366 }
367 }
368 }
369
370 createWebpackChain(opts) {
371 const WebpackChain = require('./utils/WebpackChain')
372
373 opts = Object.assign({ type: 'client', mode: this.mode }, opts)
374
375 const config = new WebpackChain({
376 configureWebpack: this.config.configureWebpack,
377 opts
378 })
379
380 require('./webpack/webpack.config')(config, this)
381
382 this.hooks.invoke('createWebpackChain', config, opts)
383
384 if (this.config.chainWebpack) {
385 this.config.chainWebpack(config, opts)
386 }
387
388 if (this.cli.options.inspectWebpack) {
389 const inspect = () => {
390 const id = Math.random()
391 .toString(36)
392 .substring(7)
393 const outFile = path.join(
394 os.tmpdir(),
395 `poi-inspect-webpack-config-${id}.js`
396 )
397 const configString = `// ${JSON.stringify(
398 opts
399 )}\nvar config = ${config.toString()}\n\n`
400 fs.writeFileSync(outFile, configString, 'utf8')
401 require('@poi/dev-utils/open')(outFile, {
402 wait: false
403 })
404 }
405
406 config.plugin('inspect-webpack').use(
407 class InspectWebpack {
408 apply(compiler) {
409 compiler.hooks.afterEnvironment.tap('inspect-webpack', inspect)
410 }
411 }
412 )
413 }
414
415 return config
416 }
417
418 runCompiler(compiler, watch) {
419 return new Promise((resolve, reject) => {
420 if (watch) {
421 compiler.watch({}, err => {
422 if (err) return reject(err)
423 resolve()
424 })
425 } else {
426 compiler.run((err, stats) => {
427 if (err) return reject(err)
428 resolve(stats)
429 })
430 }
431 })
432 }
433
434 createWebpackCompiler(config) {
435 const compiler = require('webpack')(config)
436
437 // Override the .watch method so we can handle error here instead of letting WDM handle it
438 // And in fact we disabled WDM logger so errors will never show displayed there
439 const originalWatch = compiler.watch.bind(compiler)
440 compiler.watch = (options, cb) => {
441 return originalWatch(options, (err, stats) => {
442 if (err) {
443 throw err
444 }
445 cb(null, stats)
446 })
447 }
448
449 return compiler
450 }
451
452 getCacheConfig(dir, keys, files) {
453 let content = ''
454 if (files) {
455 const file = this.configLoader.resolve({ files: [].concat(files) })
456 if (file) {
457 content = fs.readFileSync(file, 'utf8')
458 }
459 }
460
461 return {
462 cacheDirectory: this.resolveCwd('node_modules/.cache', dir),
463 cacheIdentifier: `${JSON.stringify(keys)}::${content}`
464 }
465 }
466
467 localResolve(name, cwd = this.cwd) {
468 return resolveFrom.silent(cwd, name)
469 }
470
471 localRequire(name, cwd) {
472 const resolved = this.localResolve(name, cwd)
473 return resolved ? require(resolved) : null
474 }
475}