├── pnpm-workspace.yaml ├── packages ├── webpack-service │ ├── __tests__ │ │ └── fixtures │ │ │ ├── src │ │ │ └── app.js │ │ │ ├── build.json │ │ │ └── plugin.js │ ├── src │ │ ├── types.ts │ │ ├── utils │ │ │ ├── log.ts │ │ │ ├── webpackStats.ts │ │ │ ├── prepareURLs.ts │ │ │ └── formatWebpackMessages.ts │ │ ├── index.ts │ │ ├── build.ts │ │ ├── test.ts │ │ └── start.ts │ ├── tsconfig.json │ └── package.json ├── template-plugin │ ├── __tests__ │ │ ├── fixtures │ │ │ ├── basic-spa │ │ │ │ ├── src │ │ │ │ │ └── index.tsx │ │ │ │ └── build.json │ │ │ └── defaultConfig.ts │ │ ├── build.spec.tsa │ │ ├── index.spec.tsa │ │ └── start.spec.tsa │ ├── CHANGELOG.md │ ├── README.md │ ├── .gitignore │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── package.json └── build-scripts │ ├── __tests__ │ ├── fixtures │ │ ├── modeConfig │ │ │ ├── a.plugin.js │ │ │ ├── b.plugin.js │ │ │ └── build.json │ │ ├── configs │ │ │ ├── config.json │ │ │ ├── defineConfig.cjs │ │ │ ├── config.mjs │ │ │ ├── defineConfig.mjs │ │ │ ├── typeModule │ │ │ │ ├── defineConfig.mjs │ │ │ │ ├── config.cjs │ │ │ │ └── package.json │ │ │ ├── config.cjs │ │ │ ├── config-import.mjs │ │ │ ├── config-import.cjs │ │ │ ├── config.ts │ │ │ ├── config-import-cjs.ts │ │ │ └── config-import-mjs.ts │ │ ├── apis │ │ │ ├── build.json │ │ │ └── plugin.js │ │ ├── basic │ │ │ ├── build.json │ │ │ └── plugin.js │ │ ├── userConfig │ │ │ ├── build.json │ │ │ └── plugin.js │ │ ├── mixConfig │ │ │ ├── build.json │ │ │ ├── build.config.js │ │ │ └── plugin.js │ │ ├── jsConfig │ │ │ ├── build.config.cjs │ │ │ └── plugin.js │ │ └── tsConfig │ │ │ ├── plugin.js │ │ │ └── build.config.ts │ ├── createContext.test.ts │ ├── configFiles.test.ts │ └── loadConfig.test.ts │ ├── README.md │ ├── jest.config.js │ ├── src │ ├── index.ts │ ├── utils │ │ ├── dynamicImport.ts │ │ ├── loadPkg.ts │ │ ├── checkPlugin.ts │ │ ├── getCliOptions.ts │ │ ├── constant.ts │ │ ├── buildConfig.ts │ │ ├── logger.ts │ │ ├── resolvePlugins.ts │ │ └── loadConfig.ts │ ├── Service.ts │ ├── types.ts │ └── Context.ts │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── commitlint.config.js ├── .prettierignore ├── .prettierrc ├── examples ├── react-app-demo │ ├── build.json │ ├── plugin.js │ ├── index.mjs │ └── package.json └── plugin-react-app │ ├── README.md │ ├── package.json │ ├── tsconfig.json │ └── src │ └── index.ts ├── vitest.config.ts ├── .eslintignore ├── .travis.yml ├── lerna.json ├── .eslintrc.js ├── .editorconfig ├── tsconfig.json ├── .github └── workflows │ ├── auto-publisher.yml │ └── ci.yml ├── .gitignore ├── LICENSE ├── package.json └── README.md /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'examples/*' -------------------------------------------------------------------------------- /packages/webpack-service/__tests__/fixtures/src/app.js: -------------------------------------------------------------------------------- 1 | console.log('fsdfsf'); 2 | -------------------------------------------------------------------------------- /packages/template-plugin/__tests__/fixtures/basic-spa/src/index.tsx: -------------------------------------------------------------------------------- 1 | console.log('test'); -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/modeConfig/a.plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = () => {}; -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/modeConfig/b.plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = () => {}; -------------------------------------------------------------------------------- /packages/template-plugin/__tests__/fixtures/basic-spa/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [] 3 | } -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "entry": "src/index.js" 3 | } -------------------------------------------------------------------------------- /packages/webpack-service/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IRunOptions { 2 | eject?: boolean; 3 | } 4 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/defineConfig.cjs: -------------------------------------------------------------------------------- 1 | module.exports = (config) => config; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/test/**/*.ts 2 | **/test/**/*.js 3 | **/template/**/*.template 4 | **/template/**/*.tpl 5 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | entry: 'src/index.js', 3 | }; 4 | -------------------------------------------------------------------------------- /packages/webpack-service/__tests__/fixtures/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "./plugin.js" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/template-plugin/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.0 4 | 5 | - [feat] upgrade build-scripts: 0.x -> 1.x 6 | -------------------------------------------------------------------------------- /examples/react-app-demo/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "entry": "./index.js", 3 | "plugins": [ 4 | "./plugin.js" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/apis/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["./plugin.js"], 3 | "target": {}, 4 | "output" : true 5 | } -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/basic/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "entry": "src/index", 3 | "plugins": [ 4 | "./plugin.js" 5 | ] 6 | } -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/userConfig/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": {}, 3 | "output": "test", 4 | "plugins": ["./plugin"] 5 | } -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/defineConfig.mjs: -------------------------------------------------------------------------------- 1 | const defineConfig = (config) => config; 2 | 3 | export default defineConfig; 4 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/mixConfig/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "entry": "src/index.ts", 3 | "plugins": [ 4 | "./plugin.js" 5 | ] 6 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | // ... 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | coverage 5 | expected 6 | website 7 | gh-pages 8 | vendors 9 | examples 10 | __tests__/fixtures 11 | lib -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/typeModule/defineConfig.mjs: -------------------------------------------------------------------------------- 1 | const defineConfig = (config) => config; 2 | 3 | export default defineConfig; 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: node_js 3 | node_js: 4 | - '8' 5 | script: 6 | - npm run lint 7 | cache: 8 | directories: 9 | - node_modules 10 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: path.join('src', 'index.js'), 5 | }; 6 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/jsConfig/build.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: 'src/index', 3 | plugins: [ 4 | './plugin.js', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /examples/react-app-demo/plugin.js: -------------------------------------------------------------------------------- 1 | export default (api) => { 2 | registerUserConfig([ 3 | { 4 | name: 'entry', 5 | validation: 'string' 6 | } 7 | ]); 8 | } -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/mixConfig/build.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: 'src/index.js', 3 | plugins: [ 4 | './plugin.js', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "independent", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "packages": [ 6 | "packages/*" 7 | ], 8 | "command": { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config-import.mjs: -------------------------------------------------------------------------------- 1 | import defineConfig from './defineConfig.cjs'; 2 | 3 | export default defineConfig({ 4 | entry: 'src/index.js', 5 | }); 6 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/typeModule/config.cjs: -------------------------------------------------------------------------------- 1 | import defineConfig from './defineConfig.mjs'; 2 | 3 | export default defineConfig({ 4 | entry: 'src/index.js', 5 | }); 6 | -------------------------------------------------------------------------------- /packages/build-scripts/README.md: -------------------------------------------------------------------------------- 1 | # build-scripts 2 | 3 | Command line tools provide plugin system for developing front-end projects. 4 | Visit [github](https://github.com/ice-lab/build-scripts) for more information. -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config-import.cjs: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('./defineConfig.mjs'); 2 | 3 | module.exports = defineConfig( 4 | { 5 | entry: 'scr/index.js', 6 | }, 7 | ); 8 | -------------------------------------------------------------------------------- /examples/react-app-demo/index.mjs: -------------------------------------------------------------------------------- 1 | import { Context } from 'build-scripts'; 2 | 3 | const hello = new Context({ 4 | command: 'build', 5 | commandArgs: { 6 | // 7 | } 8 | }); 9 | 10 | console.log('hello', hello) -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/jsConfig/plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ registerUserConfig }) => { 2 | registerUserConfig([ 3 | { 4 | name: 'entry', 5 | validation: 'string', 6 | }, 7 | ]); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/tsConfig/plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ registerUserConfig }) => { 2 | registerUserConfig([ 3 | { 4 | name: 'entry', 5 | validation: 'string', 6 | }, 7 | ]); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/template-plugin/README.md: -------------------------------------------------------------------------------- 1 | # build-plugin-template 2 | 3 | plugin template for build-scripts plugin 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ npm init npm-template build-plugin-xxx build-plugin-template 9 | ``` 10 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/mixConfig/plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ registerUserConfig }) => { 2 | registerUserConfig([ 3 | { 4 | name: 'entry', 5 | validation: 'string', 6 | }, 7 | ]); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/template-plugin/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.dia~ 3 | .idea/ 4 | .DS_Store 5 | .eslintcache 6 | 7 | package-lock.json 8 | 9 | npm-debug.log 10 | yarn-error.log 11 | node_modules/ 12 | coverage/ 13 | 14 | lib/ 15 | 16 | build/ -------------------------------------------------------------------------------- /packages/build-scripts/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/?*.(spec|test).(j|t)s?(x)'], 3 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/build-scripts/src/index.ts: -------------------------------------------------------------------------------- 1 | import Service from './Service.js'; 2 | import Context, { createContext } from './Context.js'; 3 | 4 | export * from './types.js'; 5 | 6 | export { 7 | Service, 8 | Context, 9 | createContext, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | interface IConfig { 4 | entry: string; 5 | } 6 | 7 | const config: IConfig = { 8 | entry: path.join('src', 'index.js'), 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/modeConfig/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["./a.plugin.js"], 3 | "modeConfig": { 4 | "daily": { 5 | "entry": "src/test", 6 | "plugins": [["./a.plugin.js", { "name": "test" }], "./b.plugin.js"] 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /packages/template-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { IPlugin } from 'build-scripts'; 2 | 3 | const plugin: IPlugin = ({ onGetWebpackConfig }) => { 4 | onGetWebpackConfig((config) => { 5 | config.mode('production'); 6 | }); 7 | }; 8 | 9 | export default plugin; 10 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/tsConfig/build.config.ts: -------------------------------------------------------------------------------- 1 | interface Config { 2 | entry: string; 3 | plugins: string[]; 4 | } 5 | 6 | const config: Config = { 7 | entry: 'src/index', 8 | plugins: ['./plugin.js'], 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/userConfig/plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ registerUserConfig }) => { 2 | registerUserConfig({ 3 | name: 'targets', 4 | validation: 'object|string', 5 | }); 6 | 7 | registerUserConfig({ 8 | name: 'output', 9 | validation: 'string', 10 | }); 11 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { getESLintConfig } = require('@iceworks/spec'); 2 | 3 | module.exports = getESLintConfig('react-ts', { 4 | rules: { 5 | 'no-async-promise-executor': 'off', 6 | '@iceworks/best-practices/recommend-polyfill': 'off', 7 | '@typescript-eslint/no-invalid-void-type': 'off', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config-import-cjs.ts: -------------------------------------------------------------------------------- 1 | import defineConfig from './defineConfig.cjs'; 2 | import * as path from 'path'; 3 | 4 | interface IConfig { 5 | entry: string; 6 | } 7 | 8 | const config: IConfig = defineConfig({ 9 | entry: path.join('src', 'index.js'), 10 | }) 11 | 12 | export default config; -------------------------------------------------------------------------------- /packages/build-scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib", 6 | "types": ["node", "jest"], 7 | "typeRoots": ["./types", "./node_modules/@types"] 8 | }, 9 | "exclude": ["__tests__", "lib", "bin", "commands", "service"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/config-import-mjs.ts: -------------------------------------------------------------------------------- 1 | import defineConfig from './defineConfig.mjs'; 2 | import path from 'path'; 3 | 4 | interface IConfig { 5 | entry: string; 6 | } 7 | 8 | const config: IConfig = defineConfig({ 9 | entry: path.join('src', 'index.js'), 10 | }); 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/webpack-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib", 6 | "types": ["node", "jest"], 7 | "typeRoots": ["./types", "./node_modules/@types"] 8 | }, 9 | "exclude": ["__tests__", "lib", "bin", "commands", "service"] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | quote_type = single 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [makefile] 16 | indent_style = tab 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/configs/typeModule/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typecommonjs", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /packages/webpack-service/src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import npmlog from 'npmlog'; 2 | 3 | const envs = ['verbose', 'info', 'error', 'warn']; 4 | const logLevel = 5 | envs.indexOf(process.env.LOG_LEVEL) !== -1 ? process.env.LOG_LEVEL : 'info'; 6 | 7 | npmlog.level = logLevel; 8 | 9 | // LOG_LEVEL=verbose for debug 10 | // log.verbose 11 | // log.info 12 | // log.error 13 | export default npmlog; 14 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/basic/plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ 2 | registerUserConfig, 3 | registerCliOption, 4 | }) => { 5 | registerUserConfig([ 6 | { 7 | name: 'entry', 8 | validation: 'string', 9 | }, 10 | ]); 11 | 12 | registerCliOption([ 13 | { 14 | name: 'https', 15 | commands: ['start', 'build'], 16 | }, 17 | ]); 18 | }; 19 | -------------------------------------------------------------------------------- /examples/react-app-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-react", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "build-scripts build" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "build-scripts": "workspace: *", 13 | "build-plugin-react-demo": "workspace: *", 14 | "webpack": "^5.52.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/webpack-service/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Service } from 'build-scripts'; 2 | import start from './start'; 3 | import build from './build'; 4 | import WebpackChain from 'webpack-chain'; 5 | import webpack from 'webpack'; 6 | 7 | const webpackService = new Service>({ 8 | name: 'webpack', 9 | command: { 10 | start, 11 | build, 12 | }, 13 | extendsPluginAPI: { 14 | webpack, 15 | }, 16 | }); 17 | 18 | export default webpackService; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "moduleResolution": "node", 5 | "strict": true, 6 | "strictNullChecks": false, 7 | "jsx": "react", 8 | "target": "es6", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "declaration": true, 12 | "lib": ["dom", "es5", "es6", "es7", "scripthost", "es2018.promise"], 13 | "emitDecoratorMetadata": true, 14 | "preserveConstEnums": true, 15 | "allowSyntheticDefaultImports": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/template-plugin/__tests__/fixtures/defaultConfig.ts: -------------------------------------------------------------------------------- 1 | import path = require('path'); 2 | import Config = require('webpack-chain'); 3 | import { IPlugin } from 'build-scripts'; 4 | 5 | const plugin: IPlugin = ({ registerTask, context }) => { 6 | const { rootDir } = context; 7 | const config = new Config(); 8 | config.mode('development'); 9 | config.entry('index').add(path.join(rootDir, 'src/index.tsx')); 10 | config.output.path(path.join(rootDir, 'build')); 11 | config.merge({ 12 | devServer: { 13 | }, 14 | }); 15 | registerTask('web', config); 16 | }; 17 | 18 | export default plugin; 19 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/dynamicImport.ts: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from 'url'; 2 | import path from 'path'; 3 | 4 | export default async function dynamicImport(filePath: string, timestamp?: boolean) { 5 | const isWin32 = process.platform === 'win32'; 6 | let importPath = filePath; 7 | 8 | if (isWin32) { 9 | const importUrl = pathToFileURL(importPath.split(path.sep).join('/')); 10 | if (timestamp) { 11 | importUrl.search = `t=${Date.now()}`; 12 | } 13 | importPath = importUrl.toString(); 14 | } else if (timestamp) { 15 | importPath += `?t=${Date.now()}`; 16 | } 17 | return await import(importPath); 18 | } 19 | -------------------------------------------------------------------------------- /packages/template-plugin/__tests__/build.spec.tsa: -------------------------------------------------------------------------------- 1 | import fs = require('fs-extra'); 2 | import path = require('path'); 3 | import { build } from 'build-scripts'; 4 | 5 | describe('simple build test suite', () => { 6 | beforeAll(async () => { 7 | return await build({ 8 | args: {}, 9 | eject: false, 10 | rootDir: path.join(__dirname, 'fixtures/basic-spa/'), 11 | plugins: [path.join(__dirname, 'fixtures/defaultConfig.ts')], 12 | getBuiltInPlugins: () => [], 13 | }); 14 | }); 15 | test('check output source', () => { 16 | expect(fs.existsSync(path.join(__dirname, 'fixtures/basic-spa/build/index.js'))); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/createContext.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import path from 'path'; 3 | import { createContext } from '../src/Context'; 4 | 5 | describe('create-context', () => { 6 | it('basic', async () => { 7 | const context = await createContext({ 8 | commandArgs: { 9 | https: true, 10 | }, 11 | command: 'start', 12 | rootDir: path.join(__dirname, 'fixtures/basic/'), 13 | }); 14 | 15 | // 验证 registerUserConfig 16 | expect(context.userConfig.entry).toEqual('src/index'); 17 | 18 | // 验证 registerCliOption 19 | expect(context.commandArgs.https).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/loadPkg.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs-extra'; 3 | import type { CreateLoggerReturns } from './logger.js'; 4 | 5 | import type { Json } from '../types.js'; 6 | 7 | const loadPkg = (rootDir: string, logger: CreateLoggerReturns): Json => { 8 | const resolvedPath = path.resolve(rootDir, 'package.json'); 9 | let config = {}; 10 | if (fs.existsSync(resolvedPath)) { 11 | try { 12 | config = fs.readJsonSync(resolvedPath); 13 | } catch (err) { 14 | logger.info(`Fail to load config file ${resolvedPath}, use empty object`); 15 | } 16 | } 17 | 18 | return config; 19 | }; 20 | 21 | export default loadPkg; 22 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/checkPlugin.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import type { PluginList } from '../types.js'; 3 | 4 | const checkPluginValue = (plugins: PluginList): void => { 5 | let flag; 6 | if (!_.isArray(plugins)) { 7 | flag = false; 8 | } else { 9 | flag = plugins.every((v) => { 10 | let correct = _.isArray(v) || _.isString(v) || _.isFunction(v) || _.isObject(v); 11 | if (correct && _.isArray(v)) { 12 | correct = _.isString(v[0]); 13 | } 14 | return correct; 15 | }); 16 | } 17 | 18 | if (!flag) { 19 | throw new Error('plugins did not pass validation'); 20 | } 21 | }; 22 | 23 | export default checkPluginValue; 24 | -------------------------------------------------------------------------------- /examples/plugin-react-app/README.md: -------------------------------------------------------------------------------- 1 | # 自定义工程 2 | 3 | build-scripts 自定义工程示例 4 | 5 | ## 1. 开发基础插件 6 | 7 | 根据「插件开发」文档初始化一个插件,该插件为基础插件,管理核心的 webpack 任务以及基础的顶层配置。 8 | 9 | 在 `src/index.ts` 中核心做了两件事: 10 | 11 | 1. 通过 `registerTask` 注册了一个 webpack 配置,此处可以定义默认的配置 12 | 2. 通过 `registerUserConfig` 注册了两个用户选项 `entry` 和 `outputDir` 13 | 14 | ## 2. 扩展插件 15 | 16 | 可以按照功能拆分不同插件,通过 webpack-chain 新增/修改 webpack 配置,以实现各种各样的工程能力。 17 | 18 | ## 3. 项目使用 19 | 20 | [示例项目](../react-app-demo)中安装 `build-scripts` 以及对应插件,同时配置 build.json 内容,接下来即可通过 `build-scripts build` 进行构建: 21 | 22 | ```json 23 | { 24 | "entry": "./index.js", 25 | "outputDir": "dist", 26 | "plugins": [ 27 | "build-plugin-react-demo" 28 | ] 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /examples/plugin-react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-plugin-react-demo", 3 | "version": "1.0.1", 4 | "description": "build-scripts plugin template for developers", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "src", 8 | "tsconfig.json" 9 | ], 10 | "scripts": { 11 | "start": "tsc -w", 12 | "build": "rm -rf lib && tsc", 13 | "prepublishOnly": "npm run build" 14 | }, 15 | "author": "", 16 | "license": "MIT", 17 | "dependencies": { 18 | "webpack": "^5.52.0", 19 | "webpack-chain": "^6.5.1" 20 | }, 21 | "devDependencies": { 22 | "build-scripts": "workspace: *", 23 | "@types/webpack-chain": "^5.2.0", 24 | "typescript": "^4.0.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/template-plugin/__tests__/index.spec.tsa: -------------------------------------------------------------------------------- 1 | import path = require('path'); 2 | import Context from 'build-scripts/lib/core/Context'; 3 | 4 | describe('simple test suite', () => { 5 | const context = new Context({ 6 | args: {}, 7 | command: 'start', 8 | rootDir: path.join(__dirname, 'fixtures/basic-spa/'), 9 | plugins: [ 10 | path.join(__dirname, './fixtures/defaultConfig.ts'), 11 | path.join(__dirname, '../src'), 12 | ], 13 | }); 14 | 15 | test('test webpack chain', async () => { 16 | const configArr = await context.setUp(); 17 | const webpackConfig = configArr[0].chainConfig.toConfig(); 18 | expect(webpackConfig.mode).toBe('production'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /examples/plugin-react-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "strictNullChecks": false, 9 | "jsx": "react", 10 | "target": "esnext", 11 | "experimentalDecorators": true, 12 | "skipLibCheck": true, 13 | "declaration": true, 14 | "lib": [ 15 | "dom", 16 | "es5", 17 | "es6", 18 | "es7", 19 | "scripthost", 20 | "es2018.promise" 21 | ], 22 | "emitDecoratorMetadata": true, 23 | "preserveConstEnums": true, 24 | "allowSyntheticDefaultImports": true 25 | }, 26 | "include": [ 27 | "src/**/*" 28 | ] 29 | } -------------------------------------------------------------------------------- /packages/template-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "strictNullChecks": false, 9 | "jsx": "react", 10 | "target": "esnext", 11 | "experimentalDecorators": true, 12 | "skipLibCheck": true, 13 | "declaration": true, 14 | "lib": [ 15 | "dom", 16 | "es5", 17 | "es6", 18 | "es7", 19 | "scripthost", 20 | "es2018.promise" 21 | ], 22 | "emitDecoratorMetadata": true, 23 | "preserveConstEnums": true, 24 | "allowSyntheticDefaultImports": true 25 | }, 26 | "include": [ 27 | "src/**/*" 28 | ] 29 | } -------------------------------------------------------------------------------- /packages/webpack-service/__tests__/fixtures/plugin.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const getWebpackConfig = require('@builder/webpack-config'); 3 | 4 | module.exports = ({ registerUserConfig, registerTask, context }) => { 5 | const { rootDir } = context; 6 | registerUserConfig([ 7 | { 8 | name: 'entry', 9 | }, 10 | ]); 11 | 12 | const webpackConfig = getWebpackConfig.default('development'); 13 | 14 | webpackConfig.entry('index').add(path.join(rootDir, 'src/app.js')); 15 | webpackConfig.resolve.merge({ 16 | fallback: { 17 | // add events fallback for webpack/hot/emitter 18 | events: require.resolve('events'), 19 | }, 20 | }); 21 | 22 | registerTask('web', webpackConfig); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/getCliOptions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * get cli options by program 3 | */ 4 | import { Command, Option } from 'commander'; 5 | import camelCase from 'camelcase'; 6 | 7 | type CliOptions = Record; 8 | 9 | module.exports = (program: Command): CliOptions => { 10 | const cliOptions: CliOptions = {}; 11 | program.options.forEach((option: Option): void => { 12 | const key = camelCase(option.long, { 13 | pascalCase: false, 14 | }); 15 | 16 | // 不传参数时是 undefined,这里不判断的话,lib/build 里跟 default 参数 merge 会有问题 17 | // version等参数的类型为function,需要过滤掉 18 | if (program[key] !== undefined && typeof program[key] !== 'function') { 19 | cliOptions[key] = program[key]; 20 | } 21 | }); 22 | 23 | return cliOptions; 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/auto-publisher.yml: -------------------------------------------------------------------------------- 1 | name: Auto Publisher 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-and-publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 12 16 | registry-url: https://registry.npmjs.org/ 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v2 19 | with: 20 | version: 6 21 | - run: npm run setup 22 | - run: npm run publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 25 | ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }} 26 | ACCESS_KEY_SECRET: ${{ secrets.ACCESS_KEY_SECRET }} 27 | REGISTRY: https://registry.npmjs.org 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [14.x] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set branch name 14 | run: echo >>$GITHUB_ENV BRANCH_NAME=${GITHUB_REF#refs/heads/} 15 | 16 | - name: Echo branch name 17 | run: echo ${BRANCH_NAME} 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | registry-url: https://registry.npmjs.org/ 24 | 25 | - name: Install pnpm 26 | uses: pnpm/action-setup@v2 27 | with: 28 | version: 6 29 | 30 | - run: pnpm run setup 31 | - run: pnpm run test 32 | - run: pnpm run coverage -------------------------------------------------------------------------------- /examples/plugin-react-app/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { IPlugin } from 'build-scripts'; 3 | import * as Config from 'webpack-chain'; 4 | 5 | const plugin: IPlugin = ({ 6 | context, registerTask, onGetWebpackConfig, registerUserConfig 7 | }) => { 8 | const { rootDir } = context; 9 | 10 | registerTask('default', new Config()); 11 | 12 | registerUserConfig({ 13 | name: 'entry', 14 | validation: 'string', 15 | configWebpack: (config, value, context) => { 16 | config.entry('index') 17 | .add(value as string); 18 | }, 19 | }); 20 | 21 | registerUserConfig({ 22 | name: 'outputDir', 23 | validation: 'string', 24 | configWebpack: (config, value, context) => { 25 | config.output 26 | .path(path.join(rootDir, value as string)); 27 | }, 28 | }); 29 | }; 30 | 31 | export default plugin; 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | lerna-debug.log 4 | npm-debug.log 5 | mochawesome-report 6 | .happypack 7 | package-lock.json 8 | yarn.lock 9 | yarn-error.log 10 | tempDir 11 | coverage/ 12 | 13 | .eslintcache 14 | databases 15 | build 16 | .vscode/ 17 | .tmp/ 18 | 19 | npmrc 20 | 21 | *~ 22 | *.swp 23 | .DS_Store 24 | npm-debug.log 25 | lerna-debug.log 26 | npm-debug.log* 27 | # lib/ 28 | packages/build-scripts/lib 29 | packages/build-plugin-store/lib 30 | packages/build-plugin-ice-router/lib 31 | packages/build-plugin-ice-stark-child/lib 32 | packages/build-plugin-env-config/lib 33 | packages/build-plugin-webpack5/lib 34 | packages/build-plugin-rml/lib 35 | packages/build-plugin-stark-module/lib 36 | dist/ 37 | lib/ 38 | .idea/ 39 | examples/react-component/lib 40 | examples/react-component/es 41 | packages/template-component-demo/lib 42 | packages/template-component-demo/es 43 | 44 | .pnpm-debug.log 45 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/constant.ts: -------------------------------------------------------------------------------- 1 | export const USER_CONFIG_FILE = ['build.json', 'build.config.(js|ts|mjs|mts|cjs|cts)']; 2 | 3 | export const PLUGIN_CONTEXT_KEY = [ 4 | 'command' as const, 5 | 'commandArgs' as const, 6 | 'rootDir' as const, 7 | 'userConfig' as const, 8 | 'originalUserConfig' as const, 9 | 'pkg' as const, 10 | 'extendsPluginAPI' as const, 11 | 'configFilePath' as const, 12 | ]; 13 | 14 | export const VALIDATION_MAP = { 15 | string: 'isString' as const, 16 | number: 'isNumber' as const, 17 | array: 'isArray' as const, 18 | object: 'isObject' as const, 19 | boolean: 'isBoolean' as const, 20 | function: 'isFunction' as const, 21 | }; 22 | 23 | export const BUILTIN_CLI_OPTIONS = [ 24 | { name: 'port', commands: ['start'] }, 25 | { name: 'host', commands: ['start'] }, 26 | { name: 'disableAsk', commands: ['start'] }, 27 | { name: 'config', commands: ['start', 'build', 'test'] }, 28 | ]; 29 | 30 | export const IGNORED_USE_CONFIG_KEY = ['plugins']; 31 | -------------------------------------------------------------------------------- /packages/template-plugin/__tests__/start.spec.tsa: -------------------------------------------------------------------------------- 1 | import path = require('path'); 2 | import got from 'got'; 3 | import WebpackDevServer = require('webpack-dev-server'); 4 | import { start } from 'build-scripts'; 5 | 6 | describe('simple build test suite', () => { 7 | let server: WebpackDevServer = null; 8 | beforeAll(async () => { 9 | server = await start({ 10 | args: { 11 | port: 4444, 12 | }, 13 | eject: false, 14 | rootDir: path.join(__dirname, 'fixtures/basic-spa/'), 15 | plugins: [path.join(__dirname, 'fixtures/defaultConfig.ts')], 16 | getBuiltInPlugins: () => [], 17 | }) as WebpackDevServer; 18 | }); 19 | test('dev server', () => { 20 | expect(server).toBeTruthy(); 21 | }) 22 | 23 | test('access dev bundle', async () => { 24 | const ret = await got('http://127.0.0.1:4444/index.js'); 25 | expect(ret.statusCode).toBe(200); 26 | }); 27 | 28 | afterAll(() => { 29 | if (server) { 30 | server.close(); 31 | } 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/template-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-plugin-template", 3 | "version": "1.1.0", 4 | "description": "build-scripts plugin template for developers", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "src", 8 | "tests", 9 | "tsconfig.json", 10 | ".gitignore" 11 | ], 12 | "scripts": { 13 | "start": "tsc -w", 14 | "build": "rm -rf lib && tsc", 15 | "test": "jest", 16 | "coverage": "codecov" 17 | }, 18 | "author": "", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "build-scripts": "workspace: ^2.0.0", 22 | "@types/fs-extra": "^8.1.0", 23 | "@types/jest": "^25.2.1", 24 | "@types/webpack-chain": "^5.2.0", 25 | "@types/webpack-dev-server": "^4.0.0", 26 | "fs-extra": "^9.0.0", 27 | "got": "^11.0.2", 28 | "jest": "^25.4.0", 29 | "ts-jest": "^25.4.0", 30 | "typescript": "^3.8.3", 31 | "webpack": "^5.0.0", 32 | "webpack-chain": "^6.4.0", 33 | "webpack-dev-server": "^4.0.0" 34 | }, 35 | "jest": { 36 | "coverageDirectory": "./coverage/", 37 | "collectCoverage": true, 38 | "preset": "ts-jest" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2018-present Alibaba Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "scripts": { 7 | "preinstall": "npx only-allow pnpm", 8 | "clean": "rm -rf node_modules ./packages/*/node_modules", 9 | "setup": "npm run clean && pnpm i --registry=https://registry.npmmirror.com", 10 | "lint": "eslint --cache --quiet --ext .js,.ts ./", 11 | "lint-fix": "eslint --cache --ext .js,.ts ./ --fix", 12 | "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", 13 | "test": "vitest run", 14 | "coverage": "vitest run --coverage", 15 | "publish": "cd packages/build-scripts && npm run build && npm publish" 16 | }, 17 | "husky": { 18 | "hooks": { 19 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 20 | "pre-commit": "npm run lint" 21 | } 22 | }, 23 | "devDependencies": { 24 | "@commitlint/cli": "^7.5.2", 25 | "@commitlint/config-conventional": "^7.5.0", 26 | "@iceworks/spec": "^1.5.0", 27 | "build-scripts": "workspace: *", 28 | "eslint": "^7.31.0", 29 | "husky": "^1.3.1", 30 | "stylelint": "^10.1.0", 31 | "typescript": "^4", 32 | "vitest": "^0.7.4" 33 | }, 34 | "dependencies": { 35 | "c8": "^7.11.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/configFiles.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import path from 'path'; 3 | import { createContext } from '../src/Context'; 4 | 5 | describe('load js config', () => { 6 | it('combine basic config', async () => { 7 | const context = await createContext({ 8 | commandArgs: {}, 9 | command: 'start', 10 | rootDir: path.join(__dirname, 'fixtures/jsConfig/'), 11 | }); 12 | 13 | expect(context.userConfig.entry).toEqual('src/index'); 14 | }); 15 | }); 16 | 17 | describe('load ts config', () => { 18 | it('combine basic config', async () => { 19 | const context = await createContext({ 20 | commandArgs: {}, 21 | command: 'start', 22 | rootDir: path.join(__dirname, 'fixtures/tsConfig/'), 23 | }); 24 | expect(context.userConfig.entry).toEqual('src/index'); 25 | }); 26 | }); 27 | 28 | describe('load mix config', () => { 29 | it('combine basic config', async () => { 30 | const context = await createContext({ 31 | commandArgs: {}, 32 | command: 'start', 33 | rootDir: path.join(__dirname, 'fixtures/mixConfig/'), 34 | }); 35 | expect(context.userConfig.entry).toEqual('src/index.ts'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/webpack-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@builder/webpack-service", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "description": "Builtin webpack service for build-scripts", 6 | "main": "lib/index.js", 7 | "types": "lib", 8 | "files": [ 9 | "lib" 10 | ], 11 | "scripts": { 12 | "build": "tsc", 13 | "prepublishOnly": "npm run build", 14 | "start": "tsc -w", 15 | "test": "jest --runInBand" 16 | }, 17 | "repository": { 18 | "type": "http", 19 | "url": "https://github.com/ice-lab/build-scripts" 20 | }, 21 | "dependencies": { 22 | "address": "^1.1.0", 23 | "build-scripts": "workspace: ^2.0.0", 24 | "deepmerge": "^4.0.0", 25 | "fs-extra": "^8.1.0", 26 | "npmlog": "^4.1.2", 27 | "picocolors": "^1.0.0" 28 | }, 29 | "devDependencies": { 30 | "@builder/webpack-config": "^2.0.0", 31 | "@types/fs-extra": "^8.0.1", 32 | "@types/jest": "^27.0.6", 33 | "@types/npmlog": "^4.1.2", 34 | "@types/webpack": "^4.41.30", 35 | "@types/webpack-chain": "^5.2.0", 36 | "@types/webpack-dev-server": "^4.0.0", 37 | "jest": "^27.0.6", 38 | "ts-jest": "^27.0.3", 39 | "typescript": "^4", 40 | "webpack": "^5.0.0", 41 | "webpack-chain": "^6.4.0", 42 | "webpack-dev-server": "^4.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/build-scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-scripts", 3 | "version": "2.1.2", 4 | "license": "MIT", 5 | "description": "scripts core", 6 | "main": "lib/index.js", 7 | "type": "module", 8 | "types": "lib", 9 | "files": [ 10 | "lib", 11 | "bin" 12 | ], 13 | "exports": "./lib/index.js", 14 | "scripts": { 15 | "build": "tsc", 16 | "prepublishOnly": "npm run build", 17 | "start": "tsc -w" 18 | }, 19 | "engines": { 20 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 21 | }, 22 | "repository": { 23 | "type": "http", 24 | "url": "https://github.com/ice-lab/build-scripts" 25 | }, 26 | "dependencies": { 27 | "camelcase": "^5.3.1", 28 | "commander": "^2.19.0", 29 | "consola": "^2.15.3", 30 | "esbuild": "^0.17.16", 31 | "fast-glob": "^3.2.7", 32 | "fs-extra": "^8.1.0", 33 | "json5": "^2.1.3", 34 | "lodash": "^4.17.15", 35 | "npmlog": "^4.1.2", 36 | "picocolors": "^1.0.0", 37 | "semver": "^7.3.2" 38 | }, 39 | "devDependencies": { 40 | "@types/fs-extra": "^8.0.1", 41 | "@types/json5": "^0.0.30", 42 | "@types/lodash": "^4.14.147", 43 | "@types/npmlog": "^4.1.2", 44 | "@types/semver": "^6.2.0", 45 | "@jest/test-result": "27.5.1", 46 | "@jest/types": "27.5.1", 47 | "typescript": "^4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/build-scripts/src/Service.ts: -------------------------------------------------------------------------------- 1 | import Context, { createContext } from './Context.js'; 2 | import consola from 'consola'; 3 | import type { ContextOptions } from './types.js'; 4 | 5 | export interface ICommandFn { 6 | (ctx: Context): void | Promise | any; 7 | } 8 | 9 | export interface IServiceOptions { 10 | /** Name of service */ 11 | name: string; 12 | 13 | command: Partial>>; 14 | 15 | extendsPluginAPI?: U; 16 | } 17 | 18 | class Service { 19 | private serviceConfig: IServiceOptions; 20 | 21 | constructor(serviceConfig: IServiceOptions) { 22 | this.serviceConfig = serviceConfig; 23 | } 24 | 25 | run = async (options: ContextOptions): Promise => { 26 | const { command } = options; 27 | const ctx = await createContext({ 28 | extendsPluginAPI: this.serviceConfig.extendsPluginAPI, 29 | ...options, 30 | }); 31 | 32 | const hasCommandImplement = Object.keys(this.serviceConfig.command).includes(command); 33 | 34 | if (!hasCommandImplement) { 35 | const errMsg = `No command that corresponds to ${command}`; 36 | consola.error(errMsg); 37 | return Promise.reject(errMsg); 38 | } 39 | 40 | return this.serviceConfig.command[command](ctx); 41 | }; 42 | } 43 | 44 | export default Service; 45 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/fixtures/apis/plugin.js: -------------------------------------------------------------------------------- 1 | const Config = require('webpack-chain'); 2 | 3 | module.exports = ({ registerTask, registerUserConfig, registerCliOption, modifyConfigRegistration, modifyCliRegistration }) => { 4 | registerTask('taskApi', (new Config().name('task'))); 5 | registerUserConfig({ 6 | name: 'target', 7 | validation: 'string', 8 | configWebpack: (chain) => { 9 | chain.name('taskigore'); 10 | }, 11 | }); 12 | registerUserConfig({ 13 | name: 'output', 14 | validation: 'string', 15 | }); 16 | registerCliOption({ 17 | name: 'slient', 18 | commands: ['build'], 19 | }); 20 | registerCliOption({ 21 | name: 'disableLog', 22 | commands: ['build'], 23 | }); 24 | modifyConfigRegistration('target', (options) => { 25 | return { 26 | ...options, 27 | ignoreTasks: ['taskApi'], 28 | validation: 'object', 29 | }; 30 | }); 31 | modifyConfigRegistration((options) => { 32 | const outputRegistration = options.output; 33 | if (outputRegistration) { 34 | options.output = { 35 | ...outputRegistration, 36 | validation: 'boolean', 37 | }; 38 | } 39 | return options; 40 | }); 41 | modifyCliRegistration('slient', (options) => { 42 | return { 43 | ...options, 44 | commands: ['start'], 45 | }; 46 | }); 47 | modifyCliRegistration((options) => { 48 | const registration = options.disableLog; 49 | if (registration) { 50 | options.disableLog = { 51 | ...registration, 52 | commands: ['start'], 53 | }; 54 | } 55 | return options; 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/buildConfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { build as esbuild, Plugin } from 'esbuild'; 4 | 5 | const buildConfig = async (fileName: string, format: 'esm' | 'cjs' = 'esm'): Promise => { 6 | const pluginExternalDeps: Plugin = { 7 | name: 'plugin-external-deps', 8 | setup(build) { 9 | build.onResolve({ filter: /.*/ }, (args) => { 10 | const id = args.path; 11 | if (id[0] !== '.' && !path.isAbsolute(id)) { 12 | return { 13 | external: true, 14 | }; 15 | } 16 | }); 17 | }, 18 | }; 19 | const pluginReplaceImport: Plugin = { 20 | name: 'plugin-replace-import-meta', 21 | setup(build) { 22 | build.onLoad({ filter: /\.[jt]s$/ }, (args) => { 23 | const contents = fs.readFileSync(args.path, 'utf8'); 24 | return { 25 | loader: args.path.endsWith('.ts') ? 'ts' : 'js', 26 | contents: contents 27 | .replace( 28 | /\bimport\.meta\.url\b/g, 29 | JSON.stringify(`file://${args.path}`), 30 | ) 31 | .replace( 32 | /\b__dirname\b/g, 33 | JSON.stringify(path.dirname(args.path)), 34 | ) 35 | .replace(/\b__filename\b/g, JSON.stringify(args.path)), 36 | }; 37 | }); 38 | }, 39 | }; 40 | 41 | const result = await esbuild({ 42 | entryPoints: [fileName], 43 | outfile: 'out.js', 44 | write: false, 45 | platform: 'node', 46 | bundle: true, 47 | format, 48 | metafile: true, 49 | plugins: [pluginExternalDeps, pluginReplaceImport], 50 | }); 51 | const { text } = result.outputFiles[0]; 52 | 53 | return text; 54 | }; 55 | 56 | export default buildConfig; 57 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | import consola from 'consola'; 3 | import picocolors from 'picocolors'; 4 | 5 | // copy from consola 6 | enum LogLevel { 7 | Fatal= 0, 8 | Error= 0, 9 | Warn= 1, 10 | Log= 2, 11 | Info= 3, 12 | Success= 3, 13 | Debug= 4, 14 | Trace= 5, 15 | Silent= -Infinity, 16 | Verbose= Infinity, 17 | } 18 | 19 | const colorize = (type: LogLevel) => (msg: string) => { 20 | const color = 21 | type === LogLevel.Info 22 | ? 'blue' 23 | : type === LogLevel.Error 24 | ? 'red' 25 | : type === LogLevel.Warn 26 | ? 'yellow' 27 | : 'green'; 28 | return picocolors[color](msg); 29 | }; 30 | 31 | function colorizeNamespace( 32 | name?: string, 33 | type?: LogLevel, 34 | ) { 35 | return name ? `${picocolors.dim('[')}${colorize(type)(name.toUpperCase())}${picocolors.dim(']')} ` : ''; 36 | } 37 | 38 | /** 39 | * create logger 40 | * @param name 41 | * @returns 42 | */ 43 | export function createLogger(namespace?: string) { 44 | return { 45 | info(...args: string[]) { 46 | consola.info( 47 | colorizeNamespace(namespace, LogLevel.Info), 48 | ...args.map((item) => colorize(LogLevel.Info)(item)), 49 | ); 50 | }, 51 | 52 | error(...args: string[]) { 53 | consola.error( 54 | colorizeNamespace(namespace, LogLevel.Error), 55 | ...args.map((item) => colorize(LogLevel.Error)(item)), 56 | ); 57 | }, 58 | 59 | warn(...args: string[]) { 60 | consola.warn( 61 | colorizeNamespace(namespace, LogLevel.Warn), 62 | ...args.map((item) => colorize(LogLevel.Warn)(item)), 63 | ); 64 | }, 65 | 66 | debug(...args: string[]) { 67 | consola.debug( 68 | colorizeNamespace(namespace, LogLevel.Debug), 69 | ...args.map((item) => colorize(LogLevel.Debug)(item)), 70 | ); 71 | }, 72 | }; 73 | } 74 | 75 | export type CreateLoggerReturns = ReturnType; 76 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/resolvePlugins.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import _ from 'lodash'; 3 | import type { PluginList, PluginInfo } from '../types.js'; 4 | import type { CreateLoggerReturns } from './logger.js'; 5 | import { createRequire } from 'module'; 6 | import dynamicImport from './dynamicImport.js'; 7 | 8 | const require = createRequire(import.meta.url); 9 | 10 | const resolvePlugins = async (allPlugins: PluginList, { 11 | rootDir, 12 | logger, 13 | }: { 14 | rootDir: string; 15 | logger: CreateLoggerReturns; 16 | }): Promise>> => { 17 | const userPlugins = await Promise.all(allPlugins.map( 18 | async (pluginInfo): Promise> => { 19 | let pluginInstance; 20 | if (_.isFunction(pluginInfo)) { 21 | return { 22 | setup: pluginInfo, 23 | options: {}, 24 | }; 25 | } else if (typeof pluginInfo === 'object' && !Array.isArray(pluginInfo)) { 26 | return pluginInfo; 27 | } 28 | const plugins = Array.isArray(pluginInfo) 29 | ? pluginInfo 30 | : [pluginInfo, undefined]; 31 | const pluginResolveDir = process.env.EXTRA_PLUGIN_DIR 32 | ? [process.env.EXTRA_PLUGIN_DIR, rootDir] 33 | : [rootDir]; 34 | const pluginPath = path.isAbsolute(plugins[0]) 35 | ? plugins[0] 36 | : require.resolve(plugins[0], { paths: pluginResolveDir }); 37 | const options = plugins[1]; 38 | 39 | try { 40 | pluginInstance = await dynamicImport(pluginPath); 41 | } catch (err: unknown) { 42 | if (err instanceof Error) { 43 | logger.error(`Fail to load plugin ${pluginPath}`); 44 | logger.error(err.stack || err.toString()); 45 | process.exit(1); 46 | } 47 | } 48 | 49 | return { 50 | name: plugins[0], 51 | pluginPath, 52 | setup: pluginInstance.default || pluginInstance || ((): void => {}), 53 | options, 54 | }; 55 | }, 56 | )); 57 | 58 | return userPlugins; 59 | }; 60 | 61 | export default resolvePlugins; 62 | -------------------------------------------------------------------------------- /packages/webpack-service/src/utils/webpackStats.ts: -------------------------------------------------------------------------------- 1 | import picocolors from 'picocolors'; 2 | import { MultiStats, Stats } from 'webpack'; 3 | import formatWebpackMessages from './formatWebpackMessages'; 4 | import log from './log'; 5 | 6 | interface IUrls { 7 | lanUrlForTerminal: string; 8 | lanUrlForBrowser: string; 9 | localUrlForTerminal: string; 10 | localUrlForBrowser: string; 11 | } 12 | 13 | interface IWebpackStatsParams { 14 | stats: Stats | MultiStats; 15 | statsOptions?: Record; 16 | urls?: IUrls; 17 | isFirstCompile?: boolean; 18 | } 19 | 20 | interface IWebpackStats { 21 | (options: IWebpackStatsParams): boolean; 22 | } 23 | 24 | const defaultOptions = { 25 | // errors and warings will be logout by formatWebpackMessages 26 | errors: false, 27 | warnings: false, 28 | colors: true, 29 | assets: true, 30 | chunks: false, 31 | entrypoints: false, 32 | modules: false, 33 | }; 34 | 35 | const webpackStats: IWebpackStats = ({ 36 | urls, 37 | stats, 38 | statsOptions = defaultOptions, 39 | isFirstCompile, 40 | }) => { 41 | const statsJson = stats.toJson({ 42 | all: false, 43 | errors: true, 44 | warnings: true, 45 | timings: true, 46 | }); 47 | const messages = formatWebpackMessages(statsJson); 48 | const isSuccessful = !messages.errors.length; 49 | if (!process.env.DISABLE_STATS) { 50 | log.info('WEBPACK', stats.toString(statsOptions)); 51 | if (isSuccessful) { 52 | // @ts-ignore 53 | if (stats.stats) { 54 | log.info('WEBPACK', 'Compiled successfully'); 55 | } else { 56 | log.info( 57 | 'WEBPACK', 58 | `Compiled successfully in ${(statsJson.time / 1000).toFixed(1)}s!`, 59 | ); 60 | } 61 | if (isFirstCompile && urls) { 62 | console.log(); 63 | log.info('WEBPACK', picocolors.green('Starting the development server at:')); 64 | log.info( 65 | ' - Local : ', 66 | picocolors.underline(picocolors.white(urls.localUrlForBrowser)), 67 | ); 68 | log.info( 69 | ' - Network: ', 70 | picocolors.underline(picocolors.white(urls.lanUrlForTerminal)), 71 | ); 72 | console.log(); 73 | } 74 | } else if (messages.errors.length) { 75 | log.error('', messages.errors.join('\n\n')); 76 | } else if (messages.warnings.length) { 77 | log.warn('', messages.warnings.join('\n\n')); 78 | } 79 | } 80 | return isSuccessful; 81 | }; 82 | 83 | export default webpackStats; 84 | -------------------------------------------------------------------------------- /packages/webpack-service/src/utils/prepareURLs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2015-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file at 6 | * https://github.com/facebookincubator/create-react-app/blob/master/LICENSE 7 | */ 8 | 9 | import picocolors from 'picocolors'; 10 | 11 | import url from 'url'; 12 | import address from 'address'; 13 | 14 | interface IPrepareUrls { 15 | lanUrlForConfig: any; 16 | lanUrlForTerminal: string; 17 | lanUrlForBrowser: string; 18 | localUrlForTerminal: string; 19 | localUrlForBrowser: string; 20 | } 21 | 22 | export default function prepareUrls( 23 | protocol: string, 24 | host: string, 25 | port: number, 26 | pathname = '/', 27 | ): IPrepareUrls { 28 | const formatUrl = (hostname: string): string => 29 | url.format({ 30 | protocol, 31 | hostname, 32 | port, 33 | pathname, 34 | }); 35 | const prettyPrintUrl = (hostname: string): string => 36 | url.format({ 37 | protocol, 38 | hostname, 39 | port: picocolors.bold(port.toString()), 40 | pathname, 41 | }); 42 | 43 | const isUnspecifiedHost = host === '0.0.0.0' || host === '::'; 44 | let prettyHost; 45 | let lanUrlForConfig; 46 | let lanUrlForTerminal; 47 | let lanUrlForBrowser; 48 | if (isUnspecifiedHost) { 49 | prettyHost = 'localhost'; 50 | try { 51 | // This can only return an IPv4 address 52 | lanUrlForConfig = address.ip(); 53 | if (lanUrlForConfig) { 54 | // Check if the address is a private ip 55 | // https://en.wikipedia.org/wiki/Private_network#Private_IPv4_address_spaces 56 | if ( 57 | /^10[.]|^30[.]|^172[.](1[6-9]|2[0-9]|3[0-1])[.]|^192[.]168[.]/.test( 58 | lanUrlForConfig, 59 | ) || 60 | process.env.USE_PUBLIC_IP 61 | ) { 62 | // Address is private, format it for later use 63 | lanUrlForTerminal = prettyPrintUrl(lanUrlForConfig); 64 | lanUrlForBrowser = formatUrl(lanUrlForConfig); 65 | } else { 66 | // Address is not private, so we will discard it 67 | lanUrlForConfig = undefined; 68 | } 69 | } 70 | } catch (_e) { 71 | // ignored 72 | } 73 | } else { 74 | prettyHost = host; 75 | } 76 | const localUrlForTerminal = prettyPrintUrl(prettyHost); 77 | const localUrlForBrowser = formatUrl(prettyHost); 78 | return { 79 | lanUrlForConfig, 80 | lanUrlForTerminal, 81 | lanUrlForBrowser, 82 | localUrlForTerminal, 83 | localUrlForBrowser, 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /packages/webpack-service/src/build.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as path from 'path'; 3 | import { Context, ITaskConfig } from 'build-scripts'; 4 | import webpackStats from './utils/webpackStats'; 5 | import { IRunOptions } from './types'; 6 | 7 | import WebpackChain from 'webpack-chain'; 8 | import type webpack from 'webpack'; 9 | 10 | const build = async function (context: Context, options?: IRunOptions): Promise>> { 11 | const { eject } = options || {}; 12 | const configArr = context.getConfig(); 13 | const { command, commandArgs, applyHook, rootDir, extendsPluginAPI: { webpack: webpackInstance }, logger } = context; 14 | await applyHook(`before.${command}.load`, { args: commandArgs, webpackConfig: configArr }); 15 | // eject config 16 | if (eject) { 17 | return configArr; 18 | } 19 | 20 | if (!configArr.length) { 21 | const errorMsg = 'No webpack config found.'; 22 | logger.warn('CONFIG', errorMsg); 23 | await applyHook('error', { err: new Error(errorMsg) }); 24 | return; 25 | } 26 | // clear build directory 27 | const defaultPath = path.resolve(rootDir, 'build'); 28 | configArr.forEach((v) => { 29 | try { 30 | const userBuildPath = v.config.output.get('path'); 31 | const buildPath = path.resolve(rootDir, userBuildPath); 32 | fs.emptyDirSync(buildPath); 33 | } catch (e) { 34 | if (fs.existsSync(defaultPath)) { 35 | fs.emptyDirSync(defaultPath); 36 | } 37 | } 38 | }); 39 | 40 | const webpackConfig = configArr.map((v) => v.config.toConfig()); 41 | await applyHook(`before.${command}.run`, { 42 | args: commandArgs, 43 | config: webpackConfig, 44 | }); 45 | 46 | let compiler: webpack.MultiCompiler; 47 | try { 48 | compiler = webpackInstance(webpackConfig); 49 | } catch (err) { 50 | logger.error('CONFIG', 'Failed to load webpack config.'); 51 | await applyHook('error', { err }); 52 | throw err; 53 | } 54 | 55 | const result = await new Promise((resolve, reject): void => { 56 | // typeof(stats) is webpack.compilation.MultiStats 57 | compiler.run((err, stats) => { 58 | if (err) { 59 | logger.error('WEBPACK', err.stack || err.toString()); 60 | reject(err); 61 | return; 62 | } 63 | 64 | const isSuccessful = webpackStats({ 65 | stats, 66 | }); 67 | if (isSuccessful) { 68 | // https://github.com/webpack/webpack/issues/12345#issuecomment-755273757 69 | // run `compiler.close()` to start to store cache 70 | compiler?.close?.(() => {}); 71 | resolve({ 72 | stats, 73 | }); 74 | } else { 75 | reject(new Error('webpack compile error')); 76 | } 77 | }); 78 | }); 79 | 80 | await applyHook(`after.${command}.compile`, result); 81 | }; 82 | 83 | export default build; 84 | -------------------------------------------------------------------------------- /packages/build-scripts/__tests__/loadConfig.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import consola from 'consola'; 3 | import { describe, it, expect } from 'vitest'; 4 | import { loadConfig, getUserConfig } from '../src/utils/loadConfig'; 5 | import { createLogger } from '../src/utils/logger'; 6 | import { USER_CONFIG_FILE } from '../src/utils/constant'; 7 | 8 | const logger = createLogger('BUILD-SCRIPT'); 9 | 10 | interface IUserConfig { 11 | entry: string; 12 | } 13 | 14 | describe('parse-config-file', () => { 15 | it('json file', async () => { 16 | const userConfig = await loadConfig(path.join(__dirname, './fixtures/configs/config.json'), {}, logger); 17 | expect(userConfig.entry).toContain('src/index'); 18 | }); 19 | 20 | it('js file respect commonjs spec', async () => { 21 | const userConfig = await loadConfig(path.join(__dirname, './fixtures/configs/config.cjs'), {}, logger); 22 | expect(userConfig.entry).toContain('src/index'); 23 | }); 24 | 25 | /** 26 | * One cannot import esm module in commonjs module. 27 | */ 28 | it('js file respect commonjs spec, while import es module', async () => { 29 | let errMsg = ''; 30 | try { 31 | await loadConfig(path.join(__dirname, './fixtures/configs/config-import.cjs'), {}, logger); 32 | } catch (e) { 33 | errMsg = e?.message; 34 | } 35 | expect(errMsg).contain('Must use import to load ES Module'); 36 | }); 37 | 38 | it('js file respect es module spec', async () => { 39 | const config = await loadConfig(path.join(__dirname, './fixtures/configs/config.mjs'), {}, logger); 40 | 41 | expect(config.entry).toContain('src/index'); 42 | }); 43 | 44 | // Node is capable of handling commonjs module in es module 45 | it('js file respec es module spec, while import commonjs module', async () => { 46 | const config = await loadConfig(path.join(__dirname, './fixtures/configs/config-import.mjs'), {}, logger); 47 | 48 | expect(config.entry).toContain('src/index'); 49 | }); 50 | 51 | // Es module is required in typescript 52 | it('typescript file respect es module sepc', async () => { 53 | const userConfig = await loadConfig(path.join(__dirname, './fixtures/configs/config.ts'), {}, logger); 54 | expect(userConfig.entry).contain('src/index'); 55 | }); 56 | 57 | it('typescript files import commonjs module', async () => { 58 | const userConfig = await loadConfig(path.join(__dirname, './fixtures/configs/config-import-cjs.ts'), {}, logger); 59 | expect(userConfig.entry).contain('src/index'); 60 | }); 61 | 62 | // Relative files will be bundle, so it just works 63 | it('typescript files import es module spec', async () => { 64 | const userConfig = await loadConfig(path.join(__dirname, './fixtures/configs/config-import-cjs.ts'), {}, logger); 65 | expect(userConfig.entry).contain('src/index'); 66 | }); 67 | 68 | it('use import in commonjs package', async () => { 69 | let errMsg = ''; 70 | try { 71 | await loadConfig(path.join(__dirname, './fixtures/configs/typeModule/config.cjs'), {}, logger); 72 | } catch (e) { 73 | errMsg = e?.message; 74 | } 75 | expect(errMsg).contain('Cannot use import statement outside a module'); 76 | }); 77 | }); 78 | 79 | describe('get-user-config', () => { 80 | it('get-empty-user-config', async () => { 81 | const userConfig = await getUserConfig({ 82 | configFile: USER_CONFIG_FILE, 83 | rootDir: path.join(__dirname, './fixtures/projects/empty'), 84 | commandArgs: {}, 85 | logger, 86 | pkg: {}, 87 | }); 88 | 89 | consola.level = 4; 90 | 91 | expect(userConfig.plugins.length).toEqual(0); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/webpack-service/src/test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import fs from 'fs-extra'; 3 | import picocolors from 'picocolors'; 4 | import log from './utils/log'; 5 | import WebpackChain from 'webpack-chain'; 6 | 7 | import type { runCLI } from 'jest'; 8 | import type { Context, IJestResult } from 'build-scripts'; 9 | 10 | export = async function (context?: Context): Promise { 11 | const { command, commandArgs } = context; 12 | const { jestArgv = {} } = commandArgs || {}; 13 | const { config, regexForTestFiles, ...restArgv } = jestArgv; 14 | 15 | const { applyHook, rootDir: ctxRoot } = context; 16 | await applyHook(`before.${command}.load`, { args: commandArgs }); 17 | 18 | const configArr = context.getConfig(); 19 | 20 | // get user jest config 21 | const jestConfigPath = path.join(ctxRoot, config || 'jest.config.js'); 22 | 23 | let userJestConfig = { moduleNameMapper: {} }; 24 | if (fs.existsSync(jestConfigPath)) { 25 | userJestConfig = require(jestConfigPath); // eslint-disable-line 26 | } 27 | 28 | // get webpack.resolve.alias 29 | const alias: { [key: string]: string } = configArr.reduce( 30 | (acc, { config: webpackChainConfig }) => { 31 | const webpackConfig = webpackChainConfig.toConfig(); 32 | if (webpackConfig.resolve && webpackConfig.resolve.alias) { 33 | return { 34 | ...acc, 35 | ...webpackConfig.resolve.alias, 36 | }; 37 | } else { 38 | return acc; 39 | } 40 | }, 41 | {}, 42 | ); 43 | 44 | const aliasModuleNameMapper: { [key: string]: string } = {}; 45 | Object.keys(alias || {}).forEach((key) => { 46 | const aliasPath = alias[key]; 47 | // check path if it is a directory 48 | if (fs.existsSync(aliasPath) && fs.statSync(aliasPath).isDirectory()) { 49 | aliasModuleNameMapper[`^${key}/(.*)$`] = `${aliasPath}/$1`; 50 | } 51 | aliasModuleNameMapper[`^${key}$`] = aliasPath; 52 | }); 53 | 54 | // generate default jest config 55 | const jestConfig = context.runJestConfig({ 56 | rootDir: ctxRoot, 57 | ...userJestConfig, 58 | moduleNameMapper: { 59 | ...userJestConfig.moduleNameMapper, 60 | ...aliasModuleNameMapper, 61 | }, 62 | ...(regexForTestFiles ? { testMatch: regexForTestFiles } : {}), 63 | }); 64 | 65 | // disallow users to modify jest config 66 | Object.freeze(jestConfig); 67 | await applyHook(`before.${command}.run`, { 68 | args: commandArgs, 69 | config: jestConfig, 70 | }); 71 | 72 | let run: typeof runCLI; 73 | try { 74 | // eslint-disable-next-line @typescript-eslint/no-var-requires 75 | run = require('jest').runCLI; 76 | } catch (err) { 77 | const messages = [ 78 | 'Cannot find module: jest. Make sure this package is installed.', 79 | '', 80 | `You can install this package by running: ${picocolors.bold('npm install jest -D')}`, 81 | ]; 82 | console.log(messages.join('\n')); 83 | } 84 | if (run) { 85 | const result = await new Promise((resolve, reject): void => { 86 | (run as typeof runCLI)( 87 | { 88 | ...restArgv, 89 | config: JSON.stringify(jestConfig), 90 | }, 91 | [ctxRoot], 92 | ) 93 | .then((data) => { 94 | const { results } = data; 95 | if (results.success) { 96 | resolve(data); 97 | } else { 98 | reject(new Error('Jest failed')); 99 | } 100 | }) 101 | .catch((err: Error) => { 102 | log.error('JEST', err.stack || err.toString()); 103 | }); 104 | }); 105 | await applyHook(`after.${command}`, { result }); 106 | return result as IJestResult; 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /packages/webpack-service/src/start.ts: -------------------------------------------------------------------------------- 1 | import prepareURLs from './utils/prepareURLs'; 2 | import { Context, ITaskConfig } from 'build-scripts'; 3 | import webpackStats from './utils/webpackStats'; 4 | import deepmerge from 'deepmerge'; 5 | import WebpackChain from 'webpack-chain'; 6 | 7 | import type { IRunOptions } from './types'; 8 | import type WebpackDevServer from 'webpack-dev-server'; 9 | import type { WebpackOptionsNormalized, MultiStats } from 'webpack'; 10 | 11 | type DevServerConfig = Record; 12 | 13 | const start = async (context: Context, options?: IRunOptions): Promise> | WebpackDevServer> => { 16 | const { eject } = options || {}; 17 | const configArr = context.getConfig(); 18 | const { command, commandArgs, extendsPluginAPI, applyHook, logger } = context; 19 | await applyHook(`before.${command}.load`, { args: commandArgs, webpackConfig: configArr }); 20 | 21 | if (eject) { 22 | return configArr; 23 | } 24 | 25 | if (!configArr.length) { 26 | const errorMsg = 'No webpack config found.'; 27 | logger.warn('CONFIG', errorMsg); 28 | await applyHook('error', { err: new Error(errorMsg) }); 29 | return; 30 | } 31 | 32 | let serverUrl = ''; 33 | let devServerConfig: DevServerConfig = { 34 | port: commandArgs.port || 3333, 35 | host: commandArgs.host || '0.0.0.0', 36 | https: commandArgs.https || false, 37 | }; 38 | 39 | for (const item of configArr) { 40 | const { config: chainConfig } = item; 41 | const config = chainConfig.toConfig() as WebpackOptionsNormalized; 42 | if (config.devServer) { 43 | devServerConfig = deepmerge(devServerConfig, config.devServer); 44 | } 45 | // if --port or process.env.PORT has been set, overwrite option port 46 | if (process.env.USE_CLI_PORT) { 47 | devServerConfig.port = commandArgs.port; 48 | } 49 | } 50 | 51 | const webpackConfig = configArr.map((v) => v.config.toConfig()); 52 | await applyHook(`before.${command}.run`, { 53 | args: commandArgs, 54 | config: webpackConfig, 55 | }); 56 | 57 | let compiler; 58 | try { 59 | compiler = webpack(webpackConfig); 60 | } catch (err) { 61 | logger.error('CONFIG', 'Failed to load webpack config.'); 62 | await applyHook('error', { err }); 63 | throw err; 64 | } 65 | const protocol = devServerConfig.https ? 'https' : 'http'; 66 | const urls = prepareURLs( 67 | protocol, 68 | devServerConfig.host, 69 | devServerConfig.port, 70 | ); 71 | serverUrl = urls.localUrlForBrowser; 72 | 73 | let isFirstCompile = true; 74 | // typeof(stats) is webpack.compilation.MultiStats 75 | compiler.hooks.done.tap('compileHook', async (stats: MultiStats) => { 76 | const isSuccessful = webpackStats({ 77 | urls, 78 | stats, 79 | isFirstCompile, 80 | }); 81 | if (isSuccessful) { 82 | isFirstCompile = false; 83 | } 84 | await applyHook(`after.${command}.compile`, { 85 | url: serverUrl, 86 | urls, 87 | isFirstCompile, 88 | stats, 89 | }); 90 | }); 91 | 92 | let devServer: WebpackDevServer; 93 | // require webpack-dev-server after context setup 94 | // context may hijack webpack resolve 95 | // eslint-disable-next-line @typescript-eslint/no-var-requires 96 | const DevServer = require('webpack-dev-server'); 97 | 98 | // static method getFreePort in v4 99 | if (DevServer.getFreePort) { 100 | devServer = new DevServer(devServerConfig, compiler); 101 | } else { 102 | devServer = new DevServer(compiler, devServerConfig); 103 | } 104 | 105 | await applyHook(`before.${command}.devServer`, { 106 | url: serverUrl, 107 | urls, 108 | devServer, 109 | }); 110 | if (devServer.startCallback) { 111 | devServer.startCallback( 112 | () => { 113 | applyHook(`after.${command}.devServer`, { 114 | url: serverUrl, 115 | urls, 116 | devServer, 117 | }); 118 | }, 119 | ); 120 | } else { 121 | devServer.listen(devServerConfig.port, devServerConfig.host, async (err: Error) => { 122 | if (err) { 123 | logger.info('WEBPACK', '[ERR]: Failed to start webpack dev server'); 124 | logger.error('WEBPACK', (err.stack || err.toString())); 125 | } 126 | await applyHook(`after.${command}.devServer`, { 127 | url: serverUrl, 128 | urls, 129 | devServer, 130 | err, 131 | }); 132 | }); 133 | } 134 | 135 | return devServer; 136 | }; 137 | 138 | export default start; 139 | -------------------------------------------------------------------------------- /packages/build-scripts/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.1.0 4 | 5 | - [feat] update `esbuild` from 0.14 to 0.16 6 | - [feat] support `enforce` option for excute order of hooks 7 | 8 | ## 2.0.0 9 | 10 | BreakChange for build-scripts, Visit [Github](https://github.com/ice-lab/build-scripts#1x---2x) for details 11 | 12 | ## 1.3.0 13 | 14 | - [feat] update `esbuild` from 0.13 to 0.14 15 | 16 | ## 1.2.1 17 | 18 | - [fix] run `compiler.close()` for store cache after build 19 | 20 | ## 1.2.0 21 | 22 | - [feat] auto load config of `build.config.(js|ts)` 23 | - [fix] exit process when config is not found 24 | - [chore] upgrade version of esbuild (up to `^0.13.12`) 25 | - [chore] optimize message, add verbose message when modify user config 26 | 27 | ## 1.1.2 28 | 29 | - [fix] missing type of hasRegistration 30 | - [fix] missing dependency of inquirer 31 | 32 | ## 1.1.1 33 | 34 | - [fix] compatible with webpack-dev-server v3 35 | 36 | ## 1.1.0 37 | 38 | - [refactor] support custom command by extend Context 39 | - [feat] support config written with typescript and es module 40 | - [feat] enhance API modifyUserConfig while modify userConfig by config path `modifyUserConfig('output.path', 'dir')` 41 | - [feat] support deep merge of modifyUserConfig by options 42 | - [feat] enhance registerMethod API, make it possible to get plugin name when applyMethod 43 | - [feat] add `originalUserConfig` to plugin API 44 | - [feat] support `hasRegistration` api 45 | - [fix] move webpack-dev-server to peerDependencies and migrate webpack-dev-server to 4.0.0 46 | 47 | ## 1.0.1 48 | 49 | - [chore] bump version because of 1.0.0 has been previously published 50 | 51 | ## 1.0.0 52 | 53 | - [feat] remove dependency of webpack and jest #30 54 | - [feat] enhance config validation #31 55 | - [feat] support ignore task of plugin registration #32 56 | 57 | ## 0.1.31 58 | 59 | - [feat] keep same reference of userConfig after modifyUserConfig 60 | - [feat] hijack webpack resolve path 61 | - [fix] preserve previous build directory 62 | 63 | ## 0.1.30 64 | 65 | - [fix] jest import 66 | - [feat] support process.env.EXTRA_PLUGIN_DIR to resolve plugins 67 | - [feat] support plugin api `cancelTask` 68 | - [feat] support plugin api `hasMethod` 69 | - [feat] add hook params of `before.${command}.load` 70 | 71 | ## 0.1.29 72 | 73 | - [feat] add hook params 74 | 75 | ## 0.1.28 76 | 77 | - [feat] bump jest version 78 | 79 | ## 0.1.27 80 | 81 | - [fix] compatible with undefined modeConfig 82 | 83 | ## 0.1.26 84 | 85 | - [feat] support merge modeConfig with userConfig 86 | 87 | ## 0.1.25 88 | 89 | - [fix] error state when DISABLE_STATS 90 | 91 | ## 0.1.24 92 | 93 | - [fix] throw error when webpack compile stats with errors 94 | - [fix] check plugins after concat with built-in plugins 95 | 96 | ## 0.1.23 97 | 98 | - [feat] support custom webpack 99 | 100 | ## 0.1.22 101 | 102 | - [feat] support process.env.DISABLE_STATS to control webpack stats output 103 | 104 | ## 0.1.21 105 | 106 | - [feat] optimize webpack log information 107 | - [fix] ts declaration of command API 108 | 109 | ## 0.1.20 110 | 111 | - [feat] support inspect in start 112 | 113 | ## 0.1.19 114 | 115 | - [feat] support JSON5 116 | - [fix] log server url after compiler is done 117 | 118 | ## 0.1.18 119 | 120 | - [feat] support log public ip by set process.env.PUBLIC_IP 121 | 122 | ## 0.1.17 123 | 124 | - [fix] log ip url for terminal 125 | 126 | ## 0.1.16 127 | 128 | - [fix] strip dashed cli option for command test 129 | 130 | ## 0.1.15 131 | 132 | - [feat] support getBuiltInPlugins to setup built-in plugins 133 | 134 | ## 0.1.14 135 | 136 | - [feat] support cli option --disable-ask to disable inquire before server start 137 | 138 | ## 0.1.13 139 | 140 | - [feat] new plugin API: getAllPlugin 141 | - [feat] support options to config default plugins 142 | - [fix] --port is not effective when config devServer.port 143 | 144 | ## 0.1.12 145 | 146 | - [fix] remove fusion-collect from build-script 147 | 148 | ## 0.1.11 149 | 150 | - [feat] support process.env.DISABLE_COLLECT to disable pv collect 151 | - [fix] modify return type of applyMethod 152 | 153 | ## 0.1.10 154 | 155 | - [fix] plugin options support json values 156 | 157 | ## 0.1.9 158 | 159 | - [feat] collect data of command execution 160 | 161 | ## 0.1.8 162 | 163 | - [fix] parse process.argv to get cli options 164 | 165 | ## 0.1.7 166 | 167 | - [feat] support API onGetJestConfig to modify jest config 168 | 169 | ## 0.1.6 170 | 171 | - [refactor] command register for debug 172 | - [fix] compatible with empty webpack config 173 | - [fix] type of plugin options 174 | 175 | ## 0.1.5 176 | 177 | - [feat] refactor with typescript 178 | - [feat] new plugin API registerMethod, applyMethod and modifyUserConfig 179 | 180 | ## 0.1.4 181 | 182 | - [fix] add process.env.RESTART_DEV for mark restart dev process 183 | 184 | ## 0.1.3 185 | 186 | - [fix] timing of register modify webpack config functions. 187 | - [fix] change timing of the 'after.start.compile' hook. 188 | -------------------------------------------------------------------------------- /packages/webpack-service/src/utils/formatWebpackMessages.ts: -------------------------------------------------------------------------------- 1 | // fork from https://github.com/facebook/create-react-app/blob/main/packages/react-dev-utils/formatWebpackMessages.js 2 | import { StatsCompilation } from 'webpack'; 3 | 4 | const friendlySyntaxErrorLabel = 'Syntax error:'; 5 | 6 | type IsLikelyASyntaxError = (message: string) => boolean; 7 | type Message = string | { message: string } | Array<{ message: string }>; 8 | type FormatMessage = (message: Message) => string; 9 | type FormatWebpackMessages = ( 10 | json: StatsCompilation, 11 | ) => { errors: string[]; warnings: string[] }; 12 | 13 | const isLikelyASyntaxError: IsLikelyASyntaxError = (message) => { 14 | return message.indexOf(friendlySyntaxErrorLabel) !== -1; 15 | }; 16 | 17 | // Cleans up webpack error messages. 18 | const formatMessage: FormatMessage = (message) => { 19 | let formattedMessage = message; 20 | let lines: string[] = []; 21 | 22 | if (typeof formattedMessage === 'string') { 23 | lines = formattedMessage.split('\n'); 24 | } else if ('message' in formattedMessage) { 25 | lines = formattedMessage.message?.split('\n'); 26 | } else if (Array.isArray(formattedMessage)) { 27 | formattedMessage.forEach((messageData) => { 28 | if ('message' in messageData) { 29 | lines = messageData.message?.split('\n'); 30 | } 31 | }); 32 | } 33 | 34 | // Strip webpack-added headers off errors/warnings 35 | // https://github.com/webpack/webpack/blob/master/lib/ModuleError.js 36 | lines = lines.filter((line) => !/Module [A-z ]+\(from/.test(line)); 37 | 38 | // Transform parsing error into syntax error 39 | // TODO: move this to our ESLint formatter? 40 | lines = lines.map((line) => { 41 | const parsingError = /Line (\d+):(?:(\d+):)?\s*Parsing error: (.+)$/.exec( 42 | line, 43 | ); 44 | if (!parsingError) { 45 | return line; 46 | } 47 | const [, errorLine, errorColumn, errorMessage] = parsingError; 48 | return `${friendlySyntaxErrorLabel} ${errorMessage} (${errorLine}:${errorColumn})`; 49 | }); 50 | 51 | formattedMessage = lines.join('\n'); 52 | // Smoosh syntax errors (commonly found in CSS) 53 | formattedMessage = formattedMessage.replace( 54 | /SyntaxError\s+\((\d+):(\d+)\)\s*(.+?)\n/g, 55 | `${friendlySyntaxErrorLabel} $3 ($1:$2)\n`, 56 | ); 57 | // Clean up export errors 58 | formattedMessage = formattedMessage.replace( 59 | /^.*export '(.+?)' was not found in '(.+?)'.*$/gm, 60 | 'Attempted import error: \'$1\' is not exported from \'$2\'.', 61 | ); 62 | formattedMessage = formattedMessage.replace( 63 | /^.*export 'default' \(imported as '(.+?)'\) was not found in '(.+?)'.*$/gm, 64 | 'Attempted import error: \'$2\' does not contain a default export (imported as \'$1\').', 65 | ); 66 | formattedMessage = formattedMessage.replace( 67 | /^.*export '(.+?)' \(imported as '(.+?)'\) was not found in '(.+?)'.*$/gm, 68 | 'Attempted import error: \'$1\' is not exported from \'$3\' (imported as \'$2\').', 69 | ); 70 | lines = formattedMessage.split('\n'); 71 | 72 | // Remove leading newline 73 | if (lines.length > 2 && lines[1].trim() === '') { 74 | lines.splice(1, 1); 75 | } 76 | // Clean up file name 77 | lines[0] = lines[0].replace(/^(.*) \d+:\d+-\d+$/, '$1'); 78 | 79 | // Cleans up verbose "module not found" messages for files and packages. 80 | if (lines[1] && lines[1].indexOf('Module not found: ') === 0) { 81 | lines = [ 82 | lines[0], 83 | lines[1] 84 | .replace('Error: ', '') 85 | .replace('Module not found: Cannot find file:', 'Cannot find file:'), 86 | ]; 87 | } 88 | 89 | // Add helpful message for users trying to use Sass for the first time 90 | if (lines[1] && lines[1].match(/Cannot find module.+sass/)) { 91 | lines[1] = 'To import Sass files, you first need to install sass.\n'; 92 | lines[1] += 93 | 'Run `npm install sass` or `yarn add sass` inside your workspace.'; 94 | } 95 | 96 | formattedMessage = lines.join('\n'); 97 | // Internal stacks are generally useless so we strip them... with the 98 | // exception of stacks containing `webpack:` because they're normally 99 | // from user code generated by webpack. For more information see 100 | // https://github.com/facebook/create-react-app/pull/1050 101 | formattedMessage = formattedMessage.replace( 102 | /^\s*at\s((?!webpack:).)*:\d+:\d+[\s)]*(\n|$)/gm, 103 | '', 104 | ); 105 | // at ... ...:x:y 106 | formattedMessage = formattedMessage.replace( 107 | /^\s*at\s(\n|$)/gm, 108 | '', 109 | ); // at 110 | lines = formattedMessage.split('\n'); 111 | 112 | // Remove duplicated newlines 113 | lines = lines.filter( 114 | (line, index, arr) => 115 | index === 0 || 116 | line.trim() !== '' || 117 | line.trim() !== arr[index - 1].trim(), 118 | ); 119 | 120 | // Reassemble the message 121 | formattedMessage = lines.join('\n'); 122 | return formattedMessage.trim(); 123 | }; 124 | 125 | const formatWebpackMessages: FormatWebpackMessages = (json) => { 126 | const formattedErrors = json.errors.map(formatMessage); 127 | const formattedWarnings = json.warnings.map(formatMessage); 128 | const result = { errors: formattedErrors, warnings: formattedWarnings }; 129 | if (result.errors.some(isLikelyASyntaxError)) { 130 | // If there are any syntax errors, show just them. 131 | result.errors = result.errors.filter(isLikelyASyntaxError); 132 | } 133 | return result; 134 | }; 135 | 136 | export default formatWebpackMessages; 137 | -------------------------------------------------------------------------------- /packages/build-scripts/src/utils/loadConfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import fg from 'fast-glob'; 4 | import JSON5 from 'json5'; 5 | import { createRequire } from 'module'; 6 | import buildConfig from './buildConfig.js'; 7 | import dynamicImport from './dynamicImport.js'; 8 | 9 | import type { UserConfig, ModeConfig, CommandArgs, EmptyObject, PluginList, Json } from '../types.js'; 10 | import type { CreateLoggerReturns } from './logger.js'; 11 | 12 | const require = createRequire(import.meta.url); 13 | 14 | export const mergeModeConfig = (mode: string, userConfig: UserConfig): UserConfig => { 15 | // modify userConfig by userConfig.modeConfig 16 | if ( 17 | userConfig.modeConfig && 18 | mode && 19 | (userConfig.modeConfig as ModeConfig)[mode] 20 | ) { 21 | const { 22 | plugins, 23 | ...basicConfig 24 | } = (userConfig.modeConfig as ModeConfig)[mode] as UserConfig; 25 | const userPlugins = [...userConfig.plugins]; 26 | if (Array.isArray(plugins)) { 27 | const pluginKeys = userPlugins.map((pluginInfo) => { 28 | return Array.isArray(pluginInfo) ? pluginInfo[0] : pluginInfo; 29 | }); 30 | plugins.forEach((pluginInfo) => { 31 | const [pluginName] = Array.isArray(pluginInfo) 32 | ? pluginInfo 33 | : [pluginInfo]; 34 | const pluginIndex = pluginKeys.indexOf(pluginName); 35 | if (pluginIndex > -1) { 36 | // overwrite plugin info by modeConfig 37 | userPlugins[pluginIndex] = pluginInfo; 38 | } else { 39 | // push new plugin added by modeConfig 40 | userPlugins.push(pluginInfo); 41 | } 42 | }); 43 | } 44 | return { ...userConfig, ...basicConfig, plugins: userPlugins }; 45 | } 46 | return userConfig; 47 | }; 48 | 49 | export const resolveConfigFile = async (configFile: string | string[], commandArgs: CommandArgs, rootDir: string) => { 50 | const { config } = commandArgs; 51 | let configPath = ''; 52 | if (config) { 53 | configPath = path.isAbsolute(config) 54 | ? config 55 | : path.resolve(rootDir, config); 56 | } else { 57 | const [defaultUserConfig] = await fg(configFile, { cwd: rootDir, absolute: true }); 58 | configPath = defaultUserConfig; 59 | } 60 | return configPath; 61 | } 62 | 63 | export const getUserConfig = async ({ 64 | rootDir, 65 | commandArgs, 66 | logger, 67 | pkg, 68 | configFilePath, 69 | }: { 70 | rootDir: string; 71 | commandArgs: CommandArgs; 72 | pkg: Json; 73 | logger: CreateLoggerReturns; 74 | configFilePath: string; 75 | }): Promise> => { 76 | let userConfig = { 77 | plugins: [] as PluginList, 78 | }; 79 | if (configFilePath && fs.existsSync(configFilePath)) { 80 | try { 81 | userConfig = await loadConfig(configFilePath, pkg, logger); 82 | } catch (err) { 83 | logger.warn(`Fail to load config file ${configFilePath}`); 84 | 85 | if (err instanceof Error) { 86 | logger.error(err.stack); 87 | } else { 88 | logger.error(err.toString()); 89 | } 90 | 91 | process.exit(1); 92 | } 93 | } else if (configFilePath) { 94 | // If path was not found 95 | logger.error(`config file${`(${configFilePath})` || ''} is not exist`); 96 | process.exit(1); 97 | } else { 98 | logger.debug( 99 | 'It\'s most likely you don\'t have a config file in root directory!\n' + 100 | 'Just ignore this message if you know what you do; Otherwise, check it by yourself.', 101 | ); 102 | } 103 | 104 | return mergeModeConfig(commandArgs.mode, userConfig as UserConfig); 105 | }; 106 | 107 | export async function loadConfig(filePath: string, pkg: Json, logger: CreateLoggerReturns): Promise { 108 | const start = Date.now(); 109 | const isTypeModule = pkg?.type === 'module'; 110 | const isJson = filePath.endsWith('.json'); 111 | 112 | // The extname of files may `.mts|.ts` 113 | const isTs = filePath.endsWith('ts'); 114 | const isJs = filePath.endsWith('js'); 115 | 116 | const isESM = ['mjs', 'mts'].some((type) => filePath.endsWith(type)) 117 | || (isTypeModule && ['js', 'ts'].some((type) => filePath.endsWith(type))); 118 | 119 | let userConfig: T | undefined; 120 | 121 | if (isJson) { 122 | return JSON5.parse(fs.readFileSync(filePath, 'utf8')); 123 | } 124 | 125 | // If config file respect ES module spec. 126 | if (isESM && isJs) { 127 | userConfig = (await dynamicImport(filePath, true))?.default; 128 | } 129 | 130 | // Config file respect CommonJS spec. 131 | if (!isESM && isJs) { 132 | userConfig = require(filePath); 133 | } 134 | 135 | if (isTs) { 136 | const code = await buildConfig(filePath, isESM ? 'esm' : 'cjs'); 137 | userConfig = await executeTypescriptModule(code, filePath, isESM); 138 | logger.debug(`bundled module file loaded in ${Date.now() - start}m`); 139 | } 140 | 141 | 142 | return userConfig; 143 | } 144 | 145 | async function executeTypescriptModule(code: string, filePath: string, isEsm = true) { 146 | const tempFile = `${filePath}.${isEsm ? 'm' : 'c'}js`; 147 | let userConfig = null; 148 | 149 | fs.writeFileSync(tempFile, code); 150 | 151 | delete require.cache[require.resolve(tempFile)]; 152 | 153 | try { 154 | const raw = isEsm ? (await dynamicImport(tempFile, true)) : require(tempFile); 155 | userConfig = raw?.default ?? raw; 156 | } catch (err) { 157 | fs.unlinkSync(tempFile); 158 | 159 | // Hijack error message 160 | if (err instanceof Error) { 161 | err.message = err.message.replace(tempFile, filePath); 162 | err.stack = err.stack.replace(tempFile, filePath); 163 | } 164 | 165 | throw err; 166 | } 167 | 168 | fs.unlinkSync(tempFile); 169 | 170 | return userConfig; 171 | } 172 | -------------------------------------------------------------------------------- /packages/build-scripts/src/types.ts: -------------------------------------------------------------------------------- 1 | import { GlobalConfig } from '@jest/types/build/Config'; 2 | import { PLUGIN_CONTEXT_KEY, VALIDATION_MAP } from './utils/constant.js'; 3 | 4 | import type { Context } from '.'; 5 | import type { AggregatedResult } from '@jest/test-result'; 6 | import type { Config } from '@jest/types'; 7 | 8 | export interface Hash { 9 | [name: string]: T; 10 | } 11 | 12 | export type Json = Hash; 13 | 14 | export type JsonArray = Array; 15 | 16 | export type MaybeArray = T | T[]; 17 | 18 | export type MaybePromise = T | Promise; 19 | 20 | export type GetValue = (name: string) => T; 21 | 22 | export type SetValue = (name: string, value: T) => void; 23 | 24 | export interface DefaultPluginAPI { 25 | context: PluginContext; 26 | registerTask: RegisterTask; 27 | getAllTask: () => string[]; 28 | getAllPlugin: GetAllPlugin; 29 | cancelTask: CancelTask; 30 | onGetConfig: OnGetConfig; 31 | onGetJestConfig: OnGetJestConfig; 32 | onHook: OnHook; 33 | setValue: SetValue; 34 | getValue: GetValue; 35 | registerUserConfig: (args: MaybeArray>) => void; 36 | hasRegistration: (name: string, type?: 'cliOption' | 'userConfig') => boolean; 37 | registerCliOption: (args: MaybeArray>) => void; 38 | registerMethod: RegisterMethod; 39 | applyMethod: ApplyMethodAPI; 40 | hasMethod: HasMethod; 41 | modifyUserConfig: ModifyUserConfig; 42 | modifyConfigRegistration: ModifyConfigRegistration; 43 | modifyCliRegistration: ModifyCliRegistration; 44 | } 45 | 46 | export type PropType = TObj[TProp]; 47 | 48 | export type PluginContext = Pick; 49 | 50 | export type UserConfigContext = PluginContext & { 51 | taskName: T; 52 | }; 53 | 54 | export type ValidationKey = keyof typeof VALIDATION_MAP; 55 | 56 | export interface JestResult { 57 | results: AggregatedResult; 58 | globalConfig: GlobalConfig; 59 | } 60 | 61 | export interface OnHookCallbackArg { 62 | err?: Error; 63 | args?: CommandArgs; 64 | stats?: any; 65 | url?: string; 66 | devServer?: any; 67 | config?: any; 68 | result?: JestResult; 69 | [other: string]: unknown; 70 | } 71 | 72 | export interface OnHookCallback { 73 | (arg?: OnHookCallbackArg): MaybePromise; 74 | } 75 | 76 | export interface HookOptions { 77 | enforce?: 'pre' | 'post'; 78 | } 79 | 80 | export interface OnHook { 81 | (eventName: string, callback: OnHookCallback, options?: HookOptions): void; 82 | } 83 | 84 | export interface PluginConfig { 85 | (config: T): Promise | void | T; 86 | } 87 | 88 | export interface SetConfig { 89 | (config: T, value: any, context: UserConfigContext): Promise | void | T; 90 | } 91 | 92 | export interface Validation { 93 | (value: any): boolean; 94 | } 95 | 96 | export interface UserConfigArgs { 97 | name: string; 98 | setConfig?: SetConfig; 99 | defaultValue?: any; 100 | validation?: string | Validation; 101 | ignoreTasks?: string[]; 102 | } 103 | 104 | export interface CliOptionArgs { 105 | name: string; 106 | setConfig?: SetConfig; 107 | commands?: string[]; 108 | ignoreTasks?: string[]; 109 | } 110 | 111 | export interface OnGetConfig { 112 | (name: string, fn: PluginConfig): void; 113 | (fn: PluginConfig): void; 114 | } 115 | 116 | export interface OnGetJestConfig { 117 | (fn: JestConfigFunction): void; 118 | } 119 | 120 | export interface RegisterTask { 121 | (name: string, config: T): void; 122 | } 123 | 124 | export interface CancelTask { 125 | (name: string): void; 126 | } 127 | 128 | export interface MethodRegistration { 129 | (args?: any): void; 130 | } 131 | 132 | export interface MethodCurry { 133 | (data?: any): MethodRegistration; 134 | } 135 | 136 | export type MethodFunction = MethodRegistration | MethodCurry; 137 | 138 | export interface MethodOptions { 139 | pluginName?: boolean; 140 | } 141 | 142 | export interface RegisterMethod { 143 | (name: string, fn: MethodFunction, options?: MethodOptions): void; 144 | } 145 | 146 | type Method = [string, string] | string; 147 | 148 | export interface ApplyMethod { 149 | (config: Method, ...args: any[]): any; 150 | } 151 | 152 | export interface ApplyMethodAPI { 153 | (name: string, ...args: any[]): any; 154 | } 155 | 156 | export interface HasMethod { 157 | (name: string): boolean; 158 | } 159 | 160 | export interface ModifyConfig { 161 | (userConfig: UserConfig): Omit; 162 | } 163 | 164 | export interface ModifyUserConfig { 165 | (configKey: string | ModifyConfig, value?: any, options?: { deepmerge: boolean }): void; 166 | } 167 | 168 | export interface GetAllPlugin { 169 | (dataKeys?: string[]): Array>>; 170 | } 171 | 172 | export interface PluginInfo extends Partial<_Plugin> { 173 | pluginPath?: string; 174 | options?: K; 175 | } 176 | 177 | export interface _Plugin { 178 | name?: string; 179 | setup: PluginSetup; 180 | } 181 | 182 | export interface PluginSetup { 183 | (api: PluginAPI, options?: K): MaybePromise; 184 | } 185 | 186 | export type PluginLegacy = string | [string, Json] | PluginSetup; 187 | 188 | export type Plugin = _Plugin | PluginLegacy; 189 | 190 | export type PluginAPI = 191 | Omit, 'onHook' | 'setValue' | 'getValue'> & Omit 192 | & { context: PluginContext & ('context' extends keyof U ? U['context'] : {}) } 193 | & Pick, 'onHook' | 'setValue' | 'getValue'>; 194 | 195 | export type CommandName = 'start' | 'build' | 'test' | string; 196 | 197 | export type CommandArgs = Record; 198 | 199 | export type PluginList = Array | Plugin>; 200 | 201 | export type GetBuiltInPlugins = (userConfig: UserConfig) => PluginList; 202 | 203 | export type CommandModule = (context: Context, options: any) => Promise; 204 | 205 | export type RegisterCommandModules = (key: string, module: CommandModule) => void; 206 | 207 | export interface ContextOptions { 208 | command: CommandName; 209 | rootDir?: string; 210 | configFile?: string | string[]; 211 | commandArgs: CommandArgs; 212 | plugins?: PluginList; 213 | getBuiltInPlugins?: GetBuiltInPlugins; 214 | extendsPluginAPI?: U; 215 | } 216 | 217 | export interface TaskConfig { 218 | name: U; 219 | config: T; 220 | modifyFunctions: Array>; 221 | } 222 | 223 | export type UserConfig = K & { 224 | plugins: PluginList; 225 | [key: string]: any; 226 | }; 227 | 228 | export interface ModeConfig { 229 | [name: string]: UserConfig; 230 | } 231 | 232 | export interface JestConfigFunction { 233 | (JestConfig: Config.InitialOptions): Config.InitialOptions; 234 | } 235 | 236 | export interface ModifyRegisteredConfigCallbacks { 237 | (configArgs: T): T; 238 | } 239 | 240 | export type UserConfigRegistration = Record>; 241 | export type CliOptionRegistration = Record>; 242 | 243 | export interface ModifyConfigRegistration { 244 | (configFunc: ModifyRegisteredConfigCallbacks>): void; 245 | ( 246 | configName: string, 247 | configFunc: ModifyRegisteredConfigCallbacks>, 248 | ): void; 249 | } 250 | 251 | export interface ModifyCliRegistration { 252 | (configFunc: ModifyRegisteredConfigCallbacks>): void; 253 | ( 254 | configName: string, 255 | configFunc: ModifyRegisteredConfigCallbacks>, 256 | ): void; 257 | } 258 | 259 | export type ModifyRegisteredConfigArgs = 260 | | [string, ModifyRegisteredConfigCallbacks>] 261 | | [ModifyRegisteredConfigCallbacks>]; 262 | export type ModifyRegisteredCliArgs = 263 | | [string, ModifyRegisteredConfigCallbacks>] 264 | | [ModifyRegisteredConfigCallbacks>]; 265 | 266 | export type OnGetConfigArgs = 267 | | [string, PluginConfig] 268 | | [PluginConfig]; 269 | 270 | export type RegistrationKey = 271 | | 'modifyConfigRegistrationCallbacks' 272 | | 'modifyCliRegistrationCallbacks'; 273 | 274 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 275 | export type EmptyObject = {}; 276 | 277 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2.x | [1.x](https://github.com/ice-lab/build-scripts/blob/stable/1.x) | [0.x](https://github.com/ice-lab/build-scripts/blob/stable/0.x) 2 | 3 | # build-scripts 4 | 5 | [![NPM version](https://img.shields.io/npm/v/build-scripts.svg?style=flat)](https://npmjs.org/package/build-scripts) 6 | [![NPM downloads](https://img.shields.io/npm/dm/@alib/build-scripts.svg?style=flat)](https://npmjs.org/package/@alib/build-scripts) 7 | 8 | 基于 Webpack 的插件化工程构建工具,支持快速建设一套开箱即用的工程方案。 9 | 10 | ## 目录 11 | 12 | - [特性](#特性) 13 | - [常见问题](#常见问题) 14 | - [使用场景](#使用场景) 15 | - [能力介绍](#能力介绍) 16 | - [配置文件](#配置文件) 17 | - [配置插件](#配置插件) 18 | - [本地自定义配置](#本地自定义配置) 19 | - [插件开发](#插件开发) 20 | - [插件 API](#插件-API) 21 | - [插件间通信](#插件间通信) 22 | - [版本升级](#版本升级) 23 | 24 | ## 特性 25 | 26 | - 完善灵活的插件能力,帮助扩展不同工程构建的场景 27 | - 提供多构建任务机制,支持同时构建多份产物 28 | - 标准化构建&调试的完整流程,同时提供 Hook 能力进行定制 29 | - 已支持多种场景: 30 | - React 项目开发 31 | - Rax 项目开发 32 | - NPM 包开发 33 | - 天马模块开发 34 | 35 | ## 常见问题 36 | 37 | ### NPM 包名是 `build-scripts` 还是 `@alib/build-scripts`? 38 | 39 | 1.x 以及未来都以 `build-scripts` 为准,0.x 版本当时因为命名被占用只能使用 `@alib/build-scripts` 这个包名。 40 | 41 | ### 2.x 相比 1.x 有什么变化? 42 | 43 | 参考 [版本升级](#版本升级) 章节。 44 | 45 | ### 何时使用 build-scripts? 46 | 47 | 多个项目共享配置以及其他工程能力,同时支持插件扩展&修改配置。 48 | 49 | ### 使用 build-scripts 的项目如何修改工程配置? 50 | 51 | build-scripts 核心是提供一套完善的工程插件设计,本身不耦合任何工程配置,也不具备任何工程调试构建能力,具体的工程配置都是由插件提供的,因此此类问题建议查阅下方对应场景的文档。 52 | 53 | ## 使用场景 54 | 55 | 基于 build-scripts 目前已支持多种场景,覆盖大多数的研发场景,当然你可以完全自定义一套工程能力。 56 | 57 | ### React 项目开发 58 | 59 | - 方案:icejs 60 | - 文档:https://ice.work/docs/guide/about 61 | - 代码:https://github.com/alibaba/ice 62 | 63 | ### 天马模块 64 | 65 | 私有方案 66 | 67 | ### NPM 包开发 68 | 69 | - 方案:ICE PKG 70 | - 文档:https://pkg.ice.work/ 71 | - 代码:https://github.com/ice-lab/icepkg 72 | 73 | ### 自定义工程 74 | 75 | 如果不想使用上述官方提供的解决方案,也可以基于 build-scripts 自定义完整的工程能力,具体请参考 [example](/examples/plugin-react-app/README.md) 。 76 | 77 | ## 方案设计 78 | 79 | ![image](https://nazha-image-store.oss-cn-shanghai.aliyuncs.com/frontends/build-scripts-arch.png) 80 | 81 | - `build-scripts` 提供核心配置、插件机制、构建生命周期等能力 82 | - `build-scripts` 本身不在耦合具体构建工具的设计,具体实现由上层工具决定 83 | 84 | ## 能力介绍 85 | 86 | build-scripts 2.0 本身不耦合任何构建工具,以下能力均以官方实现的 [webpack-service](/packages/webpack-service/) 为基础 87 | 88 | > webpack-service 是集合 build-scripts 和 webpack 提供的基础构建服务,能力上对标 build-scripts 1.x 版本 89 | 90 | ### 配置文件 91 | 92 | build-scripts 默认将 `build.json` 作为工程配置文件,运行 build-scripts 命令时会默认读取当前目录的 `build.json` 文件。 93 | 94 | 配置方式: 95 | 96 | ```json 97 | { 98 | "externals": { 99 | "react": "React" 100 | }, 101 | "plugins": [ 102 | "build-plugin-component", 103 | "./build.plugin.js" 104 | ] 105 | } 106 | ``` 107 | 108 | build.json 中核心包括两部分内容: 109 | 110 | - 基础配置:比如示例的 `externals` 字段,**默认情况下不支持任何字段,由基础插件通过 `registerUserConfig` API 注册扩展** 111 | - 插件配置:二三方插件,以及针对单个项目通过本地插件实现 webpack config 的更改 112 | 113 | 除了 json 类型以外,build-scripts 也支持 ts 类型的配置文件: 114 | 115 | ```js 116 | // build.plugin.ts 117 | 118 | export default { 119 | plugins: [], 120 | } 121 | ``` 122 | 123 | ### 配置插件 124 | 125 | 通过 `build.json` 中提供的 plugins 字段可配置插件列表,插件数组项每一项代表一个插件,build-scripts 将按顺序执行插件列表,插件配置形式如下: 126 | 127 | ```json 128 | { 129 | "plugins": [ 130 | // 数组第一项为插件名,第二项为插件参数 131 | ["build-plugin-fusion", { 132 | "themePackage": "@icedesign/theme" 133 | }] 134 | ] 135 | } 136 | ``` 137 | 138 | ### 本地自定义配置 139 | 140 | 如果基础配置和已有插件都无法支持业务需求,可以通过本地插件自定义配置来实现,新建 `build.plugin.js` 文件作为一个自定义插件,然后写入以下代码: 141 | 142 | ```js 143 | module.exports = ({ context, onGetConfig }) => { 144 | // 这里面可以写哪些,具体请查看插件开发章节 145 | onGetConfig((config) => { 146 | }); 147 | } 148 | ``` 149 | 150 | 最后在 `build.json` 里引入自定义插件即可: 151 | 152 | ```json 153 | 154 | { 155 | "plugins": [ 156 | "build-plugin-app", 157 | "./build.plugin.js" 158 | ] 159 | } 160 | ``` 161 | 162 | ## 插件开发 163 | 164 | 通过命令创建一个插件 npm 包: 165 | 166 | ```bash 167 | $ npm init npm-template build-plugin-template 168 | $ cd 169 | ``` 170 | 171 | 插件本质上是一个 Node.js 模块,入口如下: 172 | 173 | ```js 174 | module.exports = ({ context, onGetConfig, onHook, ...rest }, options) => { 175 | // 第一项参数为插件 API 提供的能力 176 | // options:插件自定义参数 177 | }; 178 | ``` 179 | 180 | 插件方法会收到两个参数,第一个参数是插件提供的 API 接口和能力,推荐解构方式按需使用 API,第二个参数 options 是插件自定义的参数,由插件开发者决定提供哪些选项给用户自定义。 181 | 182 | ### 插件 API 183 | 184 | 插件可以方便扩展和自定义工程能力,这一切都基于 build-scripts 提供的插件 API。 185 | 186 | #### context 187 | 188 | context 参数包含运行时的各种环境信息: 189 | 190 | - `command` 当前运行命令 `start|build|test` 191 | - `commandArgs` script 命令执行时接受到的参数 192 | - `rootDir` 项目根目录 193 | - `originalUserConfig` 用户在 build.json 中配置的原始内容 194 | - `userConfig` 用户配置,包含被 modifyUserConfig 修改后的结果 195 | - `pkg` 项目 package.json 的内容 196 | 197 | ```js 198 | module.exports = ({ context }) => { 199 | const { userConfig, command, webpack } = context; 200 | console.log('userConfig', userConfig); 201 | console.log('command', command); 202 | }; 203 | ``` 204 | 205 | #### onGetConfig 206 | 207 | 通过 `onGetConfig` 获取通过 [registerTask](#registerTask) 注册的配置,并对配置进行自定义修改: 208 | 209 | ```js 210 | // 场景一:修改所有 webpack 配置 211 | module.exports = ({ onGetWebpackConfig }) => { 212 | onGetWebpackConfig((config) => { 213 | config.entry('src/index'); 214 | }); 215 | } 216 | 217 | // 场景二:多 webpack 任务情况下,修改指定任务配置 218 | module.exports = ({onGetConfig, registerTask}) => { 219 | registerTask('web', webpackConfigWeb); 220 | registerTask('weex', webpackConfigWeex); 221 | 222 | onGetConfig('web',(config) => { 223 | config.entry('src/index'); 224 | }); 225 | 226 | onGetConfig('weex',(config) => { 227 | config.entry('src/app'); 228 | }); 229 | } 230 | ``` 231 | 232 | #### onHook 233 | 234 | 通过 onHook 监听命令运行时事件,onHook 注册的函数执行完成后才会执行后续操作,可以用于在命令运行中途插入插件想做的操作: 235 | 236 | ```js 237 | module.exports = ({ onHook }) => { 238 | onHook('before.start.load', () => { 239 | // do something before dev 240 | }); 241 | onHook('after.build.compile', stats => { 242 | // do something after build 243 | }); 244 | }; 245 | ``` 246 | 247 | 目前的命令执行生命周期如下: 248 | 249 | start 命令: 250 | 251 | | 生命周期 | 参数 | 调用时机 | 252 | | ---------------------- | -------------------------------------------------------------- | --------------------------------------------- | 253 | | before.start.load | { args: CommandArgs; webpackConfig: WebpackConfig[] } | 获取 webpack 配置之前 | 254 | | before.start.run | { args: CommandArgs; webpackConfig: WebpackConfig[] } | webpack 执行构建之前 | 255 | | after.start.compile | { url: string; stats: WebpackAssets; isFirstCompile: boolean } | 编译结束,每次重新编译都会执行 | 256 | | before.start.devServer | { url: string; devServer: WebpackDevServer } | server 中间件加载后,webpack devServer 启动前 | 257 | | after.start.devServer | { url: string; devServer: WebpackDevServer } | webpack devServer 启动后 | 258 | 259 | build 命令: 260 | 261 | | 生命周期 | 参数 | 调用时机 | 262 | | ------------------- | ----------------------------------------------------- | --------------------- | 263 | | before.build.load | { args: CommandArgs; webpackConfig: WebpackConfig[] } | 获取 webpack 配置之前 | 264 | | before.build.run | { args: CommandArgs; webpackConfig: WebpackConfig[] } | webpack 执行构建之前 | 265 | | after.build.compile | { url: string; stats: WebpackAssets; isFirstCompile } | 编译结束 | 266 | 267 | test 命令: 268 | 269 | | 生命周期 | 参数 | 调用时机 | 270 | | ---------------- | ----------------------------------------------------- | ------------------ | 271 | | before.test.load | { args: CommandArgs; webpackConfig: WebpackConfig[] } | 获取 jest 配置之前 | 272 | | before.test.run | { args: CommandArgs; config: JestConfig } | jest 执行构建之前 | 273 | | after.test | { result: JestResult } | 测试结束 | 274 | 275 | #### registerUserConfig 276 | 277 | 通过 registerUserConfig 注册 build.json 中的顶层配置字段,注册是可以进行用户字段校验,支持传入单个配置对象或者包含多个配置对象的数组。 278 | 279 | 方法生效的生命周期,在 registerTask 和 onGetConfig 之间。 280 | 281 | 配置对象字段如下: 282 | 283 | - name (string) 284 | 285 | 字段名称,唯一标识,多个插件无法注册相同的字段 286 | 保留字段:plugins 287 | 288 | - validation(string|function) 289 | 290 | 字段校验,支持 string 快速校验,string|boolean|number,也可以自定义函数,根据 return 值判断校验结果 291 | 292 | - ignoreTasks(string[]) 293 | 294 | 配置忽略指定 webpack 任务 295 | 296 | - setConfig(function) 297 | 298 | 字段效果,具体作用到 webpack 配置上,接收参数: 299 | 300 | - config:通过 registerTask 注册的配置 301 | - value: build.json 中的字段值 302 | - context:与外部 context 相同,新增字段 taskName 表现当前正在修改的 task 303 | 304 | ```js 305 | module.exports = ({ registerUserConfig }) => { 306 | registerUserConfig({ 307 | name: 'entry', 308 | // validation: 'string', 309 | validation: value => { 310 | return typeof value === 'string'; 311 | }, 312 | config: (config, value, context) => { 313 | config.mode(value); 314 | }, 315 | }); 316 | }; 317 | ``` 318 | 319 | #### registerTask 320 | 321 | 用于注册多 webpack 任务,比如 build-plugin-react-app 上已完整支持 React 链路开发,大部分情况下在默认 webpack 任务上拓展即可,无需额外注册. 322 | 323 | ```js 324 | // 注册的 config 必须是以 webpack-chain 形式组织 325 | module.exports = ({ registerTask }) => { 326 | registerTask('web', webpackConfigWeb); 327 | registerTask('component', webpackConfigComponent); 328 | }; 329 | ``` 330 | 331 | #### cancelTask 332 | 333 | 用于取消已注册任务 334 | 335 | ```js 336 | module.exports = ({ cancelTask }) => { 337 | cancelTask('web'); 338 | }; 339 | ``` 340 | 341 | #### hasRegistration 342 | 343 | 判断 build.json 中的顶层配置字段或者 cli 参数是否已经注册: 344 | 345 | ```js 346 | module.exports = ({ hasRegistration }) => { 347 | // 判断 build.json 顶层配置字段 entry 是否已配置 348 | const hasEntryRegistered = hasRegistration('entry'); 349 | 350 | // 判断 cli --https 参数是否已被注册 351 | const hasHttpsRegistered = hasRegistration('https', 'cliOption'); 352 | ... 353 | } 354 | ``` 355 | 356 | #### modifyConfigRegistration 357 | 358 | 用于修改已注册用户配置的行为: 359 | 360 | ```js 361 | module.exports = ({ modifyConfigRegistration }) => { 362 | modifyConfigRegistration('name', configRegistration => { 363 | return { 364 | ...configRegistration, 365 | // 修正验证字段 366 | validation: 'string', 367 | }; 368 | }); 369 | }; 370 | ``` 371 | 372 | #### modifyUserConfig 373 | 374 | 通过 modifyUserConfig 可以修改通过 registerUserConfig 注册的基础配置,在插件中快速复用基础配置的处理逻辑: 375 | 376 | ```js 377 | module.exports = ({ modifyUserConfig }) => { 378 | modifyUserConfig(originConfig => { 379 | // 通过函数返回批量修改 380 | return { ...originConfig, define: { target: 'xxxx' } }; 381 | }); 382 | }; 383 | ``` 384 | 385 | 通过指定具体修改的基础配置,快速完成配置的修改: 386 | 387 | ```js 388 | module.exports = ({ modifyUserConfig }) => { 389 | modifyUserConfig('entry', 'src/app'); 390 | 391 | // 通过对象路径修改,比如修改对象 { outputAssetsPath: { js: 'js-dist'} } 可通过以下方式 392 | modifyUserConfig('outputAssetsPath.js', 'js'); 393 | 394 | // 支持深合并,默认情况下 modifyUserConfig 将覆盖原有配置,通过配置参数支持配置的合并 395 | modifyUserConfig('outputAssetsPath', { 396 | js: 'js-output' 397 | }, { deepmerge: true }); 398 | }; 399 | ``` 400 | 401 | > API 执行的生命周期:所有插件对于修改配置函数将保存至 modifyConfigRegistration 中,在 runUserConfig 执行前完成对当前 userConfig 内容的修改 402 | 403 | #### registerCliOption 404 | 405 | 注册各命令上支持的 cli 参数,比如 npm start --https 来开启 https: 406 | 407 | ```js 408 | module.exports = ({ registerCliOption }) => { 409 | registerCliOption({ 410 | name: 'https', // 注册的 cli 参数名称, 411 | commands: ['start'], // 支持的命令,如果为空默认任何命令都将执行注册方法 412 | config: (config, value, context) => { 413 | // 对应命令链路上的需要执行的相关操作 414 | }, 415 | }); 416 | }; 417 | ``` 418 | 419 | > 注册函数执行周期,在 userConfig 相关注册函数执行之后。 420 | 421 | #### modifyCliRegistration 422 | 423 | 用于修改已注册 cli 配置的行为: 424 | 425 | ```js 426 | module.exports = ({ modifyConfigRegistration }) => { 427 | modifyCliRegistration('https', cliRegistration => { 428 | return { 429 | ...cliRegistration, 430 | // 修正 commands 字段 431 | commands: ['start'], 432 | }; 433 | }); 434 | }; 435 | ``` 436 | 437 | #### getAllTask 438 | 439 | 用于获取所有注入任务的名称: 440 | 441 | ```js 442 | module.exports = ({ getAllTask }) => { 443 | const taskNames = getAllTask(); 444 | // ['web', 'miniapp'] 445 | }; 446 | ``` 447 | 448 | ### 插件间通信 449 | 450 | 在一些业务场景下,插件间需要进行通信: 451 | 452 | 1. 不同插件之间需要知道彼此的存在来确定是否执行相应的逻辑 453 | 2. 多个插件共有的配置信息可以抽出来,在某个插件中进行配置 454 | 3. 上层插件的执行,需要依赖基础插件提供的方法 455 | 456 | 基于上述的诉求,API 层面提供 `setValue` 和 `getValue` 来用于数据的存取,`registerMethod` 和 `applyMethod` 来解决方法的复用。 457 | 458 | #### setValue 459 | 460 | 用来在 context 中注册变量,以供插件之间的通信。 461 | 462 | ```js 463 | module.exports = ({ setValue }) => { 464 | setValue('key', 123); 465 | }; 466 | ``` 467 | 468 | #### getValue 469 | 470 | 用来获取 context 中注册的变量。 471 | 472 | ```js 473 | module.exports = ({ getValue }) => { 474 | const value = getValue('key'); // 123 475 | }; 476 | ``` 477 | 478 | #### registerMethod 479 | 480 | 向工程核心注册相关方法,方便其他插件进行复用: 481 | 482 | ```js 483 | module.exports = ({ registerMethod }) => { 484 | // 注册方法 485 | registerMethod('pipeAppRouterBefore', content => { 486 | // 执行相关注册逻辑,可以返回相应的值 487 | return true; 488 | }); 489 | }; 490 | ``` 491 | 492 | registerMethod 注册方式时,通过参数指定可以获取调用该方法的具体插件名: 493 | 494 | ```js 495 | module.exports = ({ registerMethod }) => { 496 | // 注册方法 497 | registerMethod('pipeAppRouterBefore', (pluginName) => (content) => { 498 | console.log('plugin name', pluginName); 499 | console.log('content', content); 500 | // 执行相关注册逻辑,可以返回相应的值 501 | return true; 502 | }, { pluginName: true }); 503 | }; 504 | ``` 505 | 506 | #### applyMethod 507 | 508 | 调用其他插件的注册方法 509 | 510 | ```js 511 | module.exports = ({ applyMethod }) => { 512 | // 使用其他差价注册方法的方式,如果插件未注册,将返回一个 error 类型的错误 513 | // 类似 new Error(`apply unkown method ${name}`) 514 | const result = applyMethod('pipeAppRouterBefore', 'content'); 515 | }; 516 | ``` 517 | 518 | ## 版本升级 519 | 520 | ### 1.x -> 2.x 521 | 522 | 2.x 的核心变化: 523 | 524 | - 与 webpack 整体解耦,沉淀为插件开发服务 525 | - 修改与 webpack 耦合的相关 API 526 | 527 | 具体的 API 变化如下: 528 | 529 | #### onGetWebpackConfig 移除 530 | 531 | `onGetWebpackConfig` 变更为 `onGetConfig`,使用方式保持与之前不变,对于调用 `onGetConfig` 获取的 config 配置内容由具体的框架决定。 532 | 比如在 ICE PKG 下使用 API `onGetConfig` 获取的配置内容为基于 rollup 配置抽象的[配置对象](https://pkg.ice.work/reference/plugins-development) 533 | 534 | #### registerTask 变化 535 | 536 | `registerTask` 原先要求注册的任务配置必须是 `webpack-chain` 形式的配置,基于 build-scripts 2.0,其注册的内容由上层框架决定。 537 | 比如在 ICE PKG 下,任务配置为一个对象,详解[具体配置项]((https://pkg.ice.work/reference/plugins-development)) 538 | 539 | #### registerUserConfig 变化 540 | 541 | `registerUserConfig` 的参数 `configWebpack` 变更为 `setConfig`,`setConfig` 具体配置由上层框架决定: 542 | 543 | ```js 544 | module.exports = ({ registerUserConfig }) => { 545 | registerUserConfig({ 546 | name: 'custom-key', 547 | validation: 'boolean' // 可选,支持类型有 string, number, array, object, boolean 548 | setConfig: (config) => { 549 | // config 内容由具体框架决定 550 | }, 551 | }); 552 | }; 553 | ``` 554 | 555 | #### registerCliOption 变化 556 | 557 | `registerCliOption` 变化同 `registerUserConfig` 558 | 559 | ```js 560 | module.exports = ({ registerCliOption }) => { 561 | registerCliOption({ 562 | name: 'custom-options', 563 | setConfig: (config) => { 564 | // config 内容由具体框架决定 565 | }, 566 | }); 567 | }; 568 | ``` 569 | 570 | ### 0.x -> 1.x 571 | 572 | 1.x 核心变化: 573 | 574 | - 包名由 `@alib/build-scripts` 切换为 `build-scripts` 575 | - 不再依赖 webpack&jest&webpack-dev-server,建议由基础插件或项目自身依赖 576 | - 插件上下文 context 增加 originalUserConfig 字段,用于读取用户原始配置 577 | - userConfig 类型校验增强,支持 `string | object | array` 校验 578 | 579 | 除了前两点属于不兼容改动,其他能力都保持向前兼容。 580 | 581 | #### 自定义工程 582 | 583 | 在 package.json 中增加依赖: 584 | 585 | ```diff 586 | { 587 | "devDependencies": { 588 | + "jest": "^26.4.2", 589 | + "webpack": "^4.27.1", 590 | + "webpack-dev-server": "^4.0.0", 591 | - "@alib/build-scripts": "^0.1.0", 592 | + "build-scripts": "^1.0.0", 593 | } 594 | } 595 | ``` 596 | 597 | 其中 jest 可按需判断是否需要安装,webpack 版本按需选择。修改完成后重装依赖然后重启即可。 598 | 599 | > build-scripts 暂时不支持直接从 1.x 升级为 2.x,2.x 的升级必须搭配上层 service 实现,比如 build-scripts 1.x + build-plugin-component 的组件开发模式将会由 [ICE PKG](https://github.com/ice-lab/icepkg) 支持,ICE PKG 即是一个基于 build-scripts 2.x 实现的包开发方案 600 | 601 | #### React 项目(icejs) 602 | 603 | 升级 icejs 2.0 即可。 604 | 605 | #### Rax 项目(rax-app) 606 | 607 | rax-app 3.8.0 以上已升级。 608 | 609 | #### 业务组件(build-plugin-component) 610 | 611 | 在 package.json 中升级依赖: 612 | 613 | ```diff 614 | { 615 | "devDependencies": { 616 | - "@alib/build-scripts": "^0.1.0", 617 | + "build-scripts": "^1.0.0", 618 | - "build-plugin-component": "^1.0.0", 619 | + "build-plugin-component": "^1.6.5", 620 | } 621 | } 622 | ``` 623 | 624 | > build-plugin-component 从 1.6.5 开始同时兼容 build-scripts 0.x 和 1.x 两个版本 625 | 626 | #### 天马模块(@ali/build-plugin-pegasus-base) 627 | 628 | 待支持 629 | 630 | ## License 631 | 632 | [MIT](LICENSE) 633 | -------------------------------------------------------------------------------- /packages/build-scripts/src/Context.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-lines */ 2 | import camelCase from 'camelcase'; 3 | import assert from 'assert'; 4 | import _ from 'lodash'; 5 | import type { 6 | Json, 7 | MaybeArray, 8 | CommandName, 9 | CommandArgs, 10 | UserConfig, 11 | PluginInfo, 12 | ContextOptions, 13 | TaskConfig, 14 | OnGetConfigArgs, 15 | JestConfigFunction, 16 | ModifyRegisteredConfigArgs, 17 | OnHookCallback, 18 | UserConfigRegistration, 19 | CliOptionRegistration, 20 | MethodFunction, 21 | UserConfigArgs, 22 | CliOptionArgs, 23 | SetConfig, 24 | UserConfigContext, 25 | OnHook, 26 | HookOptions, 27 | PluginList, 28 | RegistrationKey, 29 | GetAllPlugin, 30 | PluginConfig, 31 | ApplyMethod, 32 | ModifyConfigRegistration, 33 | ValidationKey, 34 | ApplyMethodAPI, 35 | ModifyRegisteredConfigCallbacks, 36 | RegisterTask, 37 | CancelTask, 38 | RegisterMethod, 39 | MethodCurry, 40 | ModifyUserConfig, 41 | HasMethod, 42 | OnGetConfig, 43 | MethodRegistration, 44 | MethodOptions, 45 | OnGetJestConfig, 46 | ModifyCliRegistration, 47 | ModifyRegisteredCliArgs, 48 | EmptyObject, 49 | } from './types.js'; 50 | import type { Config } from '@jest/types'; 51 | import { getUserConfig, resolveConfigFile } from './utils/loadConfig.js'; 52 | import loadPkg from './utils/loadPkg.js'; 53 | import { createLogger } from './utils/logger.js'; 54 | import resolvePlugins from './utils/resolvePlugins.js'; 55 | import checkPlugin from './utils/checkPlugin.js'; 56 | import { PLUGIN_CONTEXT_KEY, VALIDATION_MAP, BUILTIN_CLI_OPTIONS, IGNORED_USE_CONFIG_KEY, USER_CONFIG_FILE } from './utils/constant.js'; 57 | 58 | const mergeConfig = (currentValue: T, newValue: T): T => { 59 | // only merge when currentValue and newValue is object and array 60 | const isBothArray = Array.isArray(currentValue) && Array.isArray(newValue); 61 | const isBothObject = _.isPlainObject(currentValue) && _.isPlainObject(newValue); 62 | if (isBothArray || isBothObject) { 63 | return _.merge(currentValue, newValue); 64 | } else { 65 | return newValue; 66 | } 67 | }; 68 | 69 | /** 70 | * Build Scripts Context 71 | * 72 | * @class Context 73 | * @template T Task Config 74 | * @template U Type of extendsPluginAPI 75 | * @template K User Config 76 | */ 77 | class Context { 78 | command: CommandName; 79 | 80 | commandArgs: CommandArgs; 81 | 82 | extendsPluginAPI: U; 83 | 84 | rootDir: string; 85 | 86 | pkg: Json; 87 | 88 | userConfig: UserConfig; 89 | 90 | originalUserConfig: UserConfig; 91 | 92 | plugins: Array>; 93 | 94 | logger = createLogger('BUILD-SCRIPTS'); 95 | 96 | configFile: string | string[]; 97 | 98 | configFilePath: string; 99 | 100 | private options: ContextOptions; 101 | 102 | // 存放 config 配置的数组 103 | private configArr: Array> = []; 104 | 105 | private modifyConfigFns: Array> = []; 106 | 107 | private modifyJestConfig: JestConfigFunction[] = []; 108 | 109 | private modifyConfigRegistrationCallbacks: Array> = []; 110 | 111 | private modifyCliRegistrationCallbacks: Array> = []; 112 | 113 | private eventHooks: { 114 | [name: string]: [OnHookCallback, HookOptions][]; 115 | } = {}; 116 | 117 | private internalValue: Record = {}; 118 | 119 | private userConfigRegistration: UserConfigRegistration = {}; 120 | 121 | private cliOptionRegistration: CliOptionRegistration = {}; 122 | 123 | private methodRegistration: { [name: string]: [MethodFunction, any] } = {}; 124 | 125 | private cancelTaskNames: string[] = []; 126 | 127 | constructor(options: ContextOptions) { 128 | const { 129 | command, 130 | configFile = USER_CONFIG_FILE, 131 | rootDir = process.cwd(), 132 | commandArgs = {}, 133 | extendsPluginAPI, 134 | } = options || {}; 135 | 136 | this.options = options; 137 | this.command = command; 138 | 139 | this.commandArgs = commandArgs; 140 | this.rootDir = rootDir; 141 | 142 | this.extendsPluginAPI = extendsPluginAPI; 143 | 144 | this.pkg = loadPkg(rootDir, this.logger); 145 | this.configFile = configFile; 146 | 147 | // Register built-in command 148 | this.registerCliOption(BUILTIN_CLI_OPTIONS); 149 | } 150 | 151 | runJestConfig = (jestConfig: Config.InitialOptions): Config.InitialOptions => { 152 | let result = jestConfig; 153 | for (const fn of this.modifyJestConfig) { 154 | result = fn(result); 155 | } 156 | return result; 157 | }; 158 | 159 | getTaskConfig = (): Array> => { 160 | return this.configArr; 161 | }; 162 | 163 | setup = async (): Promise>> => { 164 | await this.resolveUserConfig(); 165 | await this.resolvePlugins(); 166 | await this.runPlugins(); 167 | await this.runConfigModification(); 168 | await this.validateUserConfig(); 169 | await this.runOnGetConfigFn(); 170 | await this.runCliOption(); 171 | // filter webpack config by cancelTaskNames 172 | this.configArr = this.configArr.filter( 173 | (config) => !this.cancelTaskNames.includes(config.name), 174 | ); 175 | return this.configArr; 176 | }; 177 | 178 | getAllTask = (): string[] => { 179 | return this.configArr.map((v) => v.name); 180 | }; 181 | 182 | getAllPlugin: GetAllPlugin = ( 183 | dataKeys = ['pluginPath', 'options', 'name'], 184 | ) => { 185 | return this.plugins.map( 186 | (pluginInfo): Partial> => { 187 | // filter fn to avoid loop 188 | return _.pick(pluginInfo, dataKeys); 189 | }, 190 | ); 191 | }; 192 | 193 | resolveUserConfig = async (): Promise> => { 194 | if (!this.userConfig) { 195 | this.configFilePath = await resolveConfigFile(this.configFile, this.commandArgs, this.rootDir); 196 | this.userConfig = await getUserConfig({ 197 | rootDir: this.rootDir, 198 | commandArgs: this.commandArgs, 199 | pkg: this.pkg, 200 | logger: this.logger, 201 | configFilePath: this.configFilePath, 202 | }); 203 | } 204 | return this.userConfig; 205 | }; 206 | 207 | resolvePlugins = async (): Promise>> => { 208 | if (!this.plugins) { 209 | // shallow copy of userConfig while userConfig may be modified 210 | this.originalUserConfig = { ...this.userConfig }; 211 | const { plugins = [], getBuiltInPlugins = () => [] } = this.options; 212 | // run getBuiltInPlugins before resolve webpack while getBuiltInPlugins may add require hook for webpack 213 | const builtInPlugins: PluginList = [ 214 | ...plugins, 215 | ...getBuiltInPlugins(this.userConfig), 216 | ]; 217 | 218 | checkPlugin(builtInPlugins); // check plugins property 219 | this.plugins = await resolvePlugins( 220 | [ 221 | ...builtInPlugins, 222 | ...(this.userConfig.plugins || []), 223 | ], 224 | { 225 | rootDir: this.rootDir, 226 | logger: this.logger, 227 | }, 228 | ); 229 | } 230 | return this.plugins; 231 | }; 232 | 233 | applyHook = async (key: string, opts = {}): Promise => { 234 | const hooks = this.eventHooks[key] || []; 235 | const preHooks: OnHookCallback[] = []; 236 | const normalHooks: OnHookCallback[] = []; 237 | const postHooks: OnHookCallback[] = []; 238 | 239 | hooks.forEach(([fn, options]) => { 240 | if (options?.enforce === 'pre') { 241 | preHooks.push(fn); 242 | } else if (options?.enforce === 'post') { 243 | postHooks.push(fn); 244 | } else { 245 | normalHooks.push(fn); 246 | } 247 | }); 248 | 249 | for (const fn of [...preHooks, ...normalHooks, ...postHooks]) { 250 | // eslint-disable-next-line no-await-in-loop 251 | await fn(opts); 252 | } 253 | }; 254 | 255 | registerTask: RegisterTask = (name, config) => { 256 | const exist = this.configArr.find((v): boolean => v.name === name); 257 | if (!exist) { 258 | this.configArr.push({ 259 | name, 260 | config, 261 | modifyFunctions: [], 262 | }); 263 | } else { 264 | throw new Error(`[Error] config '${name}' already exists!`); 265 | } 266 | }; 267 | 268 | registerConfig = ( 269 | type: string, 270 | args: MaybeArray> | MaybeArray>, 271 | parseName?: (name: string) => string, 272 | ): void => { 273 | const registerKey = `${type}Registration` as 274 | | 'userConfigRegistration' 275 | | 'cliOptionRegistration'; 276 | if (!this[registerKey]) { 277 | throw new Error( 278 | `unknown register type: ${type}, use available types (userConfig or cliOption) instead`, 279 | ); 280 | } 281 | const configArr = _.isArray(args) ? args : [args]; 282 | configArr.forEach((conf): void => { 283 | const confName = parseName ? parseName(conf.name) : conf.name; 284 | if (this[registerKey][confName]) { 285 | throw new Error(`${conf.name} already registered in ${type}`); 286 | } 287 | 288 | this[registerKey][confName] = conf; 289 | 290 | // set default userConfig 291 | if ( 292 | type === 'userConfig' && 293 | _.isUndefined(this.userConfig[confName]) && 294 | Object.prototype.hasOwnProperty.call(conf, 'defaultValue') 295 | ) { 296 | this.userConfig = { 297 | ...this.userConfig, 298 | [confName]: (conf as UserConfigArgs).defaultValue, 299 | }; 300 | } 301 | }); 302 | }; 303 | 304 | private async runSetConfig( 305 | fn: SetConfig, 306 | configValue: UserConfig[keyof UserConfig], 307 | ignoreTasks: string[] | null, 308 | ): Promise { 309 | for (const configInfo of this.configArr) { 310 | const taskName = configInfo.name; 311 | let ignoreConfig = false; 312 | if (Array.isArray(ignoreTasks)) { 313 | ignoreConfig = ignoreTasks.some((ignoreTask) => 314 | new RegExp(ignoreTask).exec(taskName)); 315 | } 316 | if (!ignoreConfig) { 317 | const userConfigContext: UserConfigContext = { 318 | ..._.pick(this, PLUGIN_CONTEXT_KEY), 319 | taskName, 320 | }; 321 | // eslint-disable-next-line no-await-in-loop 322 | const maybeConfig = await fn(configInfo.config, configValue, userConfigContext); 323 | if (maybeConfig) { 324 | configInfo.config = maybeConfig; 325 | } 326 | } 327 | } 328 | } 329 | 330 | private onHook: OnHook = (key, fn, options) => { 331 | if (!Array.isArray(this.eventHooks[key])) { 332 | this.eventHooks[key] = []; 333 | } 334 | this.eventHooks[key].push([fn, options]); 335 | }; 336 | 337 | private runPlugins = async (): Promise => { 338 | for (const pluginInfo of this.plugins) { 339 | const { setup, options, name: pluginName } = pluginInfo; 340 | 341 | const pluginContext = _.pick(this, PLUGIN_CONTEXT_KEY); 342 | const applyMethod: ApplyMethodAPI = (methodName, ...args) => { 343 | return this.applyMethod([methodName, pluginName], ...args); 344 | }; 345 | const pluginAPI = _.merge({ 346 | context: pluginContext, 347 | registerTask: this.registerTask, 348 | getAllTask: this.getAllTask, 349 | getAllPlugin: this.getAllPlugin, 350 | cancelTask: this.cancelTask, 351 | onGetConfig: this.onGetConfig, 352 | onGetJestConfig: this.onGetJestConfig, 353 | onHook: this.onHook, 354 | setValue: this.setValue, 355 | getValue: this.getValue, 356 | registerUserConfig: this.registerUserConfig, 357 | hasRegistration: this.hasRegistration, 358 | registerCliOption: this.registerCliOption, 359 | registerMethod: this.registerMethod, 360 | applyMethod, 361 | hasMethod: this.hasMethod, 362 | modifyUserConfig: this.modifyUserConfig, 363 | modifyConfigRegistration: this.modifyConfigRegistration, 364 | modifyCliRegistration: this.modifyCliRegistration, 365 | }, this.extendsPluginAPI || {}); 366 | 367 | // eslint-disable-next-line no-await-in-loop 368 | await setup(pluginAPI as any, options); 369 | } 370 | }; 371 | 372 | private runConfigModification = async (): Promise => { 373 | const callbackRegistrations = [ 374 | 'modifyConfigRegistrationCallbacks', 375 | 'modifyCliRegistrationCallbacks', 376 | ]; 377 | callbackRegistrations.forEach((registrationKey) => { 378 | const registrations = this[registrationKey as RegistrationKey] as Array<| ModifyRegisteredConfigArgs 379 | | ModifyRegisteredConfigArgs>; 380 | registrations.forEach(([name, callback]) => { 381 | const modifyAll = _.isFunction(name); 382 | const configRegistrations = this[ 383 | registrationKey === 'modifyConfigRegistrationCallbacks' 384 | ? 'userConfigRegistration' 385 | : 'cliOptionRegistration' 386 | ]; 387 | if (modifyAll) { 388 | const modifyFunction = name as ModifyRegisteredConfigCallbacks>; 389 | const modifiedResult = modifyFunction(configRegistrations); 390 | Object.keys(modifiedResult).forEach((configKey) => { 391 | configRegistrations[configKey] = { 392 | ...(configRegistrations[configKey] || {}), 393 | ...modifiedResult[configKey], 394 | }; 395 | }); 396 | } else if (typeof name === 'string') { 397 | if (!configRegistrations[name]) { 398 | throw new Error(`Config key '${name}' is not registered`); 399 | } 400 | const configRegistration = configRegistrations[name]; 401 | configRegistrations[name] = { 402 | ...configRegistration, 403 | ...callback(configRegistration), 404 | }; 405 | } 406 | }); 407 | }); 408 | }; 409 | 410 | private validateUserConfig = async (): Promise => { 411 | for (const configInfoKey in this.userConfig) { 412 | if (IGNORED_USE_CONFIG_KEY.includes(configInfoKey)) { 413 | continue; 414 | } 415 | 416 | const configInfo = this.userConfigRegistration[configInfoKey]; 417 | 418 | if (!configInfo) { 419 | throw new Error( 420 | `[Config File] Config key '${configInfoKey}' is not supported`, 421 | ); 422 | } 423 | 424 | const { name, validation, ignoreTasks, setConfig } = configInfo; 425 | const configValue = this.userConfig[name]; 426 | 427 | if (validation) { 428 | let validationInfo; 429 | if (_.isString(validation)) { 430 | // split validation string 431 | const supportTypes = validation.split('|') as ValidationKey[]; 432 | const validateResult = supportTypes.some((supportType) => { 433 | const fnName = VALIDATION_MAP[supportType]; 434 | if (!fnName) { 435 | throw new Error(`validation does not support ${supportType}`); 436 | } 437 | return _[fnName](configValue); 438 | }); 439 | assert( 440 | validateResult, 441 | `Config ${name} should be ${validation}, but got ${configValue}`, 442 | ); 443 | } else { 444 | // eslint-disable-next-line no-await-in-loop 445 | validationInfo = await validation(configValue); 446 | assert( 447 | validationInfo, 448 | `${name} did not pass validation, result: ${validationInfo}`, 449 | ); 450 | } 451 | } 452 | 453 | if (setConfig) { 454 | // eslint-disable-next-line no-await-in-loop 455 | await this.runSetConfig( 456 | setConfig, 457 | configValue, 458 | ignoreTasks, 459 | ); 460 | } 461 | } 462 | }; 463 | 464 | private runCliOption = async (): Promise => { 465 | for (const cliOpt in this.commandArgs) { 466 | // allow all jest option when run command test 467 | if (this.command !== 'test' || cliOpt !== 'jestArgv') { 468 | const { commands, name, setConfig, ignoreTasks } = 469 | this.cliOptionRegistration[cliOpt] || {}; 470 | if (!name || !(commands || []).includes(this.command)) { 471 | throw new Error( 472 | `cli option '${cliOpt}' is not supported when run command '${this.command}'`, 473 | ); 474 | } 475 | 476 | if (setConfig) { 477 | // eslint-disable-next-line no-await-in-loop 478 | await this.runSetConfig( 479 | setConfig, 480 | this.commandArgs[cliOpt], 481 | ignoreTasks, 482 | ); 483 | } 484 | } 485 | } 486 | }; 487 | 488 | private runOnGetConfigFn = async (): Promise => { 489 | this.modifyConfigFns.forEach(([name, func]) => { 490 | const isAll = _.isFunction(name); 491 | if (isAll) { 492 | // modify all 493 | this.configArr.forEach((config) => { 494 | config.modifyFunctions.push(name as PluginConfig); 495 | }); 496 | } else { 497 | // modify named config 498 | this.configArr.forEach((config) => { 499 | if (config.name === name) { 500 | config.modifyFunctions.push(func); 501 | } 502 | }); 503 | } 504 | }); 505 | 506 | for (const configInfo of this.configArr) { 507 | for (const func of configInfo.modifyFunctions) { 508 | // eslint-disable-next-line no-await-in-loop 509 | const maybeConfig = await func(configInfo.config); 510 | if (maybeConfig) { 511 | configInfo.config = maybeConfig; 512 | } 513 | } 514 | } 515 | }; 516 | 517 | private cancelTask: CancelTask = (name) => { 518 | if (this.cancelTaskNames.includes(name)) { 519 | this.logger.info(`task ${name} has already been canceled`); 520 | } else { 521 | this.cancelTaskNames.push(name); 522 | } 523 | }; 524 | 525 | private registerMethod: RegisterMethod = (name, fn, options) => { 526 | if (this.methodRegistration[name]) { 527 | throw new Error(`[Error] method '${name}' already registered`); 528 | } else { 529 | const registration = [fn, options] as [MethodFunction, MethodOptions]; 530 | this.methodRegistration[name] = registration; 531 | } 532 | }; 533 | 534 | private applyMethod: ApplyMethod = (config, ...args) => { 535 | const [methodName, pluginName] = Array.isArray(config) ? config : [config]; 536 | if (this.methodRegistration[methodName]) { 537 | const [registerMethod, methodOptions] = this.methodRegistration[ 538 | methodName 539 | ]; 540 | if (methodOptions?.pluginName) { 541 | return (registerMethod as MethodCurry)(pluginName)(...args); 542 | } else { 543 | return (registerMethod as MethodRegistration)(...args); 544 | } 545 | } else { 546 | throw new Error(`apply unknown method ${methodName}`); 547 | } 548 | }; 549 | 550 | private hasMethod: HasMethod = (name) => { 551 | return !!this.methodRegistration[name]; 552 | }; 553 | 554 | private modifyUserConfig: ModifyUserConfig = (configKey, value, options) => { 555 | const errorMsg = 'config plugins is not support to be modified'; 556 | const { deepmerge: mergeInDeep } = options || {}; 557 | if (typeof configKey === 'string') { 558 | if (configKey === 'plugins') { 559 | throw new Error(errorMsg); 560 | } 561 | const configPath = configKey.split('.'); 562 | const originalValue = _.get(this.userConfig, configPath); 563 | const newValue = typeof value !== 'function' ? value : value(originalValue); 564 | _.set(this.userConfig, configPath, mergeInDeep ? mergeConfig>(originalValue, newValue) : newValue); 565 | } else if (typeof configKey === 'function') { 566 | const modifiedValue = configKey(this.userConfig); 567 | if (_.isPlainObject(modifiedValue)) { 568 | if (Object.prototype.hasOwnProperty.call(modifiedValue, 'plugins')) { 569 | // remove plugins while it is not support to be modified 570 | this.logger.info('delete plugins of user config while it is not support to be modified'); 571 | delete modifiedValue.plugins; 572 | } 573 | Object.keys(modifiedValue).forEach((modifiedConfigKey) => { 574 | const originalValue = this.userConfig[modifiedConfigKey]; 575 | 576 | this.userConfig = { 577 | ...this.userConfig, 578 | [modifiedConfigKey]: mergeInDeep 579 | ? mergeConfig>(originalValue, modifiedValue[modifiedConfigKey]) 580 | : modifiedValue[modifiedConfigKey], 581 | }; 582 | }); 583 | } else { 584 | throw new Error('modifyUserConfig must return a plain object'); 585 | } 586 | } 587 | }; 588 | 589 | private modifyConfigRegistration: ModifyConfigRegistration = ( 590 | ...args: ModifyRegisteredConfigArgs 591 | ) => { 592 | this.modifyConfigRegistrationCallbacks.push(args); 593 | }; 594 | 595 | private modifyCliRegistration: ModifyCliRegistration = ( 596 | ...args: ModifyRegisteredCliArgs 597 | ) => { 598 | this.modifyCliRegistrationCallbacks.push(args); 599 | }; 600 | 601 | private onGetConfig: OnGetConfig = ( 602 | ...args: OnGetConfigArgs 603 | ) => { 604 | this.modifyConfigFns.push(args); 605 | }; 606 | 607 | private onGetJestConfig: OnGetJestConfig = (fn: JestConfigFunction) => { 608 | this.modifyJestConfig.push(fn); 609 | }; 610 | 611 | private setValue = (key: string | number, value: any): void => { 612 | this.internalValue[key] = value; 613 | }; 614 | 615 | private getValue = (key: string | number): any => { 616 | return this.internalValue[key]; 617 | }; 618 | 619 | private registerUserConfig = (args: MaybeArray>): void => { 620 | this.registerConfig('userConfig', args); 621 | }; 622 | 623 | private hasRegistration = (name: string, type: 'cliOption' | 'userConfig' = 'userConfig'): boolean => { 624 | const mappedType = type === 'cliOption' ? 'cliOptionRegistration' : 'userConfigRegistration'; 625 | return Object.keys(this[mappedType] || {}).includes(name); 626 | }; 627 | 628 | private registerCliOption = (args: MaybeArray>): void => { 629 | this.registerConfig('cliOption', args, (name) => { 630 | return camelCase(name, { pascalCase: false }); 631 | }); 632 | }; 633 | } 634 | 635 | export default Context; 636 | 637 | export const createContext = async (args: ContextOptions): Promise> => { 638 | const ctx = new Context(args); 639 | 640 | await ctx.setup(); 641 | 642 | return ctx; 643 | }; 644 | --------------------------------------------------------------------------------