import type { Compiler, Configuration, RuleSetRule, SwcLoaderOptions } from '@rspack/core' import { RsdoctorRspackPlugin } from '@rsdoctor/rspack-plugin' import { rspack } from '@rspack/core' import ReactRefreshPlugin from '@rspack/plugin-react-refresh' import chalk from 'chalk' import fs from 'fs-extra' import HtmlWebpackPlugin from 'html-webpack-plugin' import { createRequire } from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' import { resolveModule } from '@/utils/resolveModule' import type { Configuration as CustomConfiguration } from '../utils/defineConfig' import appConfig from '../utils/appConfig' import paths from './paths' import ChunkLoadRetryPlugin from './plugins/chunk-load-retry' import PostcssSafeAreaPlugin from './plugins/postcss-safe-area' const require = createRequire(import.meta.url) const appPkg = fs.readJsonSync(paths.package) as { name: string; version: string } const hasJsxRuntime = (() => { try { require.resolve('react/jsx-runtime') return appConfig.single } catch { return false } })() const jsMainPath = appConfig.grayscale ? `${appPkg.name}/beta/${appPkg.name}` : `${appPkg.name}/${appPkg.name}` const assetPath = appConfig.grayscale ? `${appPkg.name}/beta/${appPkg.version}` : `${appPkg.name}/${appPkg.version}` const cssRegex = /\.css$/ const cssModuleRegex = /\.module\.css$/ const lessRegex = /\.less$/ const lessModuleRegex = /\.module\.less$/ const imageInlineSizeLimit = 10 * 1024 interface QseCDN { isUseCommon: boolean isUseAxios: boolean isUseMoment: boolean isUseAntd: boolean isUseQsbAntd: boolean isUseQsbSchemeRender: boolean } const qseCDN: QseCDN = (() => { const contents = paths.indexHTML .map((url) => fs.readFileSync(url, 'utf-8')) .map((content) => { // 删除注释,避免被误判 return content.replace(//g, '') }) function include(pattern: string | RegExp): boolean { const regexp = new RegExp(pattern) return contents.some((content) => regexp.test(content)) } return { isUseCommon: include(/react16.14.*_common31?.js|react-dev-preset.js/), isUseAxios: include(/react16.14.*_axios0.21.1|react-dev-preset.js/), isUseMoment: include('moment2.29.1.js'), isUseAntd: include('antd3.26.20.js'), isUseQsbAntd: include('qsb-antd.min.js'), isUseQsbSchemeRender: include('qsb-scheme-render.min.js'), } })() interface CSSLoaderOptions { importLoaders: number sourceMap: boolean modules: { mode: 'global' | 'local' localIdentName: string } } interface DllManifest { name: string content: Record } function getPnpmCompatibleDllManifest(manifestPath: string): DllManifest { const manifest = fs.readJsonSync(manifestPath) as DllManifest const normalizedContent = { ...manifest.content } for (const [key, value] of Object.entries(manifest.content)) { if (!key.startsWith('./node_modules/')) continue const request = key.replace('./node_modules/', '') try { const resolvedPath = require.resolve(request) const relativePath = `./${path.relative(process.cwd(), resolvedPath).replace(/\\/g, '/')}` if (!normalizedContent[relativePath]) { normalizedContent[relativePath] = value } } catch {} } return { ...manifest, content: normalizedContent } } export default function getWebpackConfig(args: any, override: CustomConfiguration): Configuration { const isDev = process.env.NODE_ENV === 'development' const isProd = process.env.NODE_ENV === 'production' // common function to get style loaders const getStyleLoaders = (cssOptions: CSSLoaderOptions, preProcessor?: string) => { const loaders = [ { loader: require.resolve('style-loader'), options: { attributes: { 'data-module': appPkg.name, 'data-version': appPkg.version } }, }, { loader: require.resolve('css-loader'), options: cssOptions, }, { // Options for PostCSS as we reference these options twice // Adds vendor prefixing based on your specified browser support in // package.json loader: require.resolve('postcss-loader'), options: { postcssOptions: { // Necessary for external CSS imports to work // https://github.com/facebook/create-react-app/issues/2677 ident: 'postcss', config: false, plugins: [ isProd && require('cssnano')({ preset: 'default' }), fs.existsSync(paths.tailwind) && require.resolve('tailwindcss'), require.resolve('postcss-flexbugs-fixes'), [ require.resolve('postcss-preset-env'), { autoprefixer: { flexbox: 'no-2009', }, // https://preset-env.cssdb.org/features/#stage-2 }, ], isProd && PostcssSafeAreaPlugin(), isProd && [require.resolve('postcss-momentum-scrolling'), ['scroll', 'auto']], require.resolve('postcss-normalize'), ...(override.extraPostCSSPlugins || []), ].filter(Boolean), }, sourceMap: isDev, }, }, ] if (preProcessor === 'less-loader') { loaders.push({ loader: require.resolve('less-loader'), options: { lessOptions: { javascriptEnabled: true, modifyVars: fs.existsSync(paths.theme) ? resolveModule(require(paths.theme)) : undefined, }, sourceMap: true, }, } as any) } return loaders } const config: Configuration = { context: process.cwd(), mode: process.env.NODE_ENV as 'development' | 'production', entry: './src/index', target: 'browserslist', output: { filename: appConfig.single ? `js/${jsMainPath}_${appPkg.version}.[contenthash:6].js` : `js/${jsMainPath}_${appPkg.version}.js`, chunkFilename: `js/${assetPath}/[name].[chunkhash:8].js`, assetModuleFilename: `images/${assetPath}/[name].[hash:6][ext]`, uniqueName: appPkg.name, publicPath: '', }, name: appPkg.name, externals: Object.assign( {}, qseCDN.isUseCommon && { react: 'React', 'react-dom': 'ReactDOM', 'natty-fetch': 'nattyFetch', 'natty-storage': 'nattyStorage', 'common-utils': 'CommonUtils', '@qse/common-utils': 'CommonUtils', }, qseCDN.isUseAxios && { axios: 'axios' }, qseCDN.isUseMoment && { moment: 'moment' }, qseCDN.isUseAntd && Object.assign( { react: 'React', 'react-dom': 'ReactDOM', moment: 'moment', antd: 'antd', }, qseCDN.isUseQsbAntd && { '@qse/antd': 'qsbAntd', '@qsb/antd': 'qsbAntd', }, qseCDN.isUseQsbSchemeRender && { '@qse/scheme-render': 'qsbSchemeRender', '@qsb/scheme-render': 'qsbSchemeRender', } ), // 教育工程这些一定都需要 external !appConfig.single && Object.assign( { react: 'React', 'react-dom': 'ReactDOM', 'natty-fetch': 'nattyFetch', 'natty-storage': 'nattyStorage', 'common-utils': 'CommonUtils', '@qse/common-utils': 'CommonUtils', moment: 'moment', antd: 'antd', }, isProd && { '@qse/antd': 'qsbAntd', '@qsb/antd': 'qsbAntd', '@qse/scheme-render': 'qsbSchemeRender', '@qsb/scheme-render': 'qsbSchemeRender', } ), override.externals ), resolve: { alias: { '@': paths.src, '@swc/helpers': path.dirname(require.resolve('@swc/helpers/package.json')), ...override.alias, }, extensions: ['.web.js', '.web.mjs', '.js', '.mjs', '.jsx', '.ts', '.tsx', '.json', '.wasm'], tsConfig: fs.existsSync(paths.tsconfig) ? { configFile: paths.tsconfig, references: 'auto' } : undefined, }, stats: false, devtool: isDev ? 'cheap-module-source-map' : false, module: { rules: [ { oneOf: [ { resourceQuery: /raw/, type: 'asset/source', }, { test: /\.[cm]?[jt]sx?$/, exclude: /node_modules/, use: [ { loader: 'builtin:swc-loader', options: { env: { targets: process.env.BROWSERSLIST }, rspackExperiments: { import: [ { libraryName: 'lodash', libraryDirectory: '', camelToDashComponentName: false, }, ...(override.import || []), ], }, isModule: 'unknown', jsc: { parser: { syntax: 'typescript', tsx: true, decorators: override.decorators, }, externalHelpers: true, transform: { legacyDecorator: override.decorators, react: { runtime: hasJsxRuntime ? 'automatic' : 'classic', development: isDev, refresh: isDev, }, }, }, } satisfies SwcLoaderOptions, }, ], }, { test: /\.[cm]?jsx?$/, exclude: /node_modules[\\/](core-js|@swc[\\/]helpers)([\\/]|$)/, use: [ { loader: 'builtin:swc-loader', options: { env: { targets: process.env.BROWSERSLIST }, isModule: 'unknown', jsc: { parser: { syntax: 'ecmascript', }, externalHelpers: true, }, }, }, ], }, { test: cssRegex, exclude: cssModuleRegex, use: getStyleLoaders({ importLoaders: 1, sourceMap: isDev, modules: { mode: 'global', localIdentName: '[local]--[hash:base64:6]', }, }), sideEffects: true, }, { test: cssModuleRegex, use: getStyleLoaders({ importLoaders: 1, sourceMap: isDev, modules: { mode: 'local', localIdentName: '[local]--[hash:base64:6]', }, }), }, { test: lessRegex, exclude: lessModuleRegex, use: getStyleLoaders( { importLoaders: 2, sourceMap: isDev, modules: { mode: 'global', localIdentName: '[local]--[hash:base64:6]', }, }, 'less-loader' ), sideEffects: true, }, { test: lessModuleRegex, use: getStyleLoaders( { importLoaders: 2, sourceMap: isDev, modules: { mode: 'local', localIdentName: '[local]--[hash:base64:6]', }, }, 'less-loader' ), }, { test: /\.(bmp|png|jpe?g|gif|webp)$/, type: 'asset', parser: { dataUrlCondition: { maxSize: imageInlineSizeLimit, // 10kb }, }, }, { test: /\.svg$/, type: 'asset', parser: { dataUrlCondition: { maxSize: imageInlineSizeLimit, // 10kb }, }, issuer: { and: [/\.(css|less)$/], }, }, { test: /\.svg$/, use: [ { loader: require.resolve('@svgr/webpack'), options: { prettier: false, svgo: false, svgoConfig: { plugins: [{ removeViewBox: false }], }, titleProp: true, ref: false, }, }, { loader: require.resolve('url-loader'), options: { limit: imageInlineSizeLimit, }, }, ], issuer: { and: [/\.(ts|tsx|js|jsx|md|mdx)$/], }, }, { test: /\.md$/, type: 'asset/source', }, { // Exclude `js` files to keep "css" loader working as it injects // its runtime that would otherwise be processed through "file" loader. // Also exclude `html` and `json` extensions so they get processed // by webpacks internal loaders. test: /\.(?!(?:js|mjs|jsx|ts|tsx|html|json)$)[^.]+$/, type: 'asset/resource', }, ].filter(Boolean) as RuleSetRule[], }, ], }, plugins: [ qseCDN.isUseCommon && new rspack.DllReferencePlugin({ manifest: getPnpmCompatibleDllManifest( paths.resolveOwn('asset', 'dll', 'libcommon3-manifest.json') ), }), new rspack.NormalModuleReplacementPlugin( /@rspack\/dev-server\/client\/index\.js/, (resource) => { const myClientPath = paths.resolveOwn('asset', 'rspack-dev-server-client.js') resource.request = resource.request.replace( /.*dev-server\/client\/index\.js/, myClientPath ) } ), new rspack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/, }), new rspack.DefinePlugin({ 'process.env.APP_NAME': JSON.stringify(appPkg.name), 'process.env.APP_VERSION': JSON.stringify(appPkg.version), 'process.env.BABEL_ENV': JSON.stringify(process.env.BABEL_ENV), 'process.env.BROWSERSLIST': JSON.stringify(process.env.BROWSERSLIST), ...override.define, }), new rspack.ProgressPlugin(), new ChunkLoadRetryPlugin(), isDev && new rspack.CaseSensitivePlugin(), isDev && new ReactRefreshPlugin({ overlay: false }), ...(isDev || process.env.OUTPUT_HTML || appConfig.single || appConfig.mainProject ? paths.indexHTML.map( (template) => new HtmlWebpackPlugin({ template: template, filename: template.split('/').pop(), inject: false, minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }, }) ) : []), process.env.ANALYZE && isProd && new RsdoctorRspackPlugin(), isDev && ((compiler: Compiler) => { let isFirst = true compiler.hooks.afterDone.tap('edu-scripts-startup', (stats) => { if (!isFirst) console.clear() isFirst = false if (override.startup) { const logger = compiler.getInfrastructureLogger('edu-scripts') override.startup({ logger, chalk, compiler }) } console.log(stats.toString({ preset: 'errors-warnings', timings: true, colors: true })) }) }), ].filter(Boolean), optimization: { minimize: isProd && override.minify !== false, minimizer: [ new rspack.SwcJsMinimizerRspackPlugin({ minimizerOptions: { ecma: 5, compress: { pure_funcs: override.pure_funcs, drop_debugger: true, ecma: 5, // Disabled because of an issue with Uglify breaking seemingly valid code: // https://github.com/facebook/create-react-app/issues/2376 // Pending further investigation: // https://github.com/mishoo/UglifyJS2/issues/2011 comparisons: false, // Disabled because of an issue with Terser breaking valid code: // https://github.com/facebook/create-react-app/issues/5250 // Pending further investigation: // https://github.com/terser-js/terser/issues/120 inline: 2, }, }, }), ], splitChunks: { minChunks: 2, }, }, performance: { maxEntrypointSize: appConfig.single ? 1024 * 1024 : 30 * 1024, maxAssetSize: 2 * 1024 * 1024, }, } if (override.cache) { config.cache = true config.experiments = { ...config.experiments, cache: { type: 'persistent', buildDependencies: paths.resolveExistPaths( fileURLToPath(import.meta.url), paths.package, paths.tsconfig, paths.override ), }, } } return config }