UNPKG

@bolt/build-tools

Version:

Curated collection of front-end build tools in the Bolt Design System.

560 lines (517 loc) 17.6 kB
const path = require('path'); const webpack = require('webpack'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const HardSourceWebpackPlugin = require('hard-source-webpack-plugin-patch'); const TerserPlugin = require('terser-webpack-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const autoprefixer = require('autoprefixer'); const postcssDiscardDuplicates = require('postcss-discard-duplicates'); const ManifestPlugin = require('webpack-manifest-plugin'); const fs = require('fs'); const deepmerge = require('deepmerge'); const resolve = require('resolve'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const npmSass = require('npm-sass'); const merge = require('webpack-merge'); const SassDocPlugin = require('@bolt/sassdoc-webpack-plugin'); const { getConfig } = require('@bolt/build-utils/config-store'); const { boltWebpackProgress } = require('@bolt/build-utils/webpack-helpers'); const SpriteLoaderPlugin = require('svg-sprite-loader/plugin'); const { webpackStats, statsPreset, } = require('@bolt/build-utils/webpack-verbosity'); const babelConfig = require('@bolt/babel-preset-bolt'); const { getBoltManifest, mapComponentNameToTwigNamespace, } = require('@bolt/build-utils/manifest'); const log = require('@bolt/build-utils/log'); // Store set of webpack configs used in multiple builds let webpackConfigs = []; async function createWebpackConfig(buildConfig) { const config = buildConfig; // The publicPath config sets the client-side base path for all built / asynchronously loaded assets. By default the loader script will automatically figure out the relative path to load your components, but uses publicPath as a fallback. It's recommended to have it start with a `/`. Note: this ONLY sets the base path the browser requests -- it does not set where files are saved during build. To change where files are saved at build time, use the buildDir config. // Must start and end with `/` // conditional is temp workaround for when servers are disabled via absence of `config.wwwDir` const publicPath = config.publicPath ? config.publicPath : config.wwwDir ? `/${path.relative(config.wwwDir, config.buildDir)}/` : config.buildDir; // @todo Ensure ends with `/` or we can get `distfonts/` instead of `dist/fonts/` // @TODO: move this setting to .boltrc config const sassExportData = require('@bolt/sass-export-data')({ path: config.dataDir, }); // map out Twig namespaces with the NPM package name // filename suffix to tack on based on lang being compiled for let langSuffix = `${config.lang ? '-' + config.lang : ''}`; /** * Build WebPack config's `entry` object * @link https://webpack.js.org/configuration/entry-context/#entry * @returns {object} entry - WebPack config `entry` */ async function buildWebpackEntry() { const { components } = await getBoltManifest(); const entry = {}; const globalEntryName = 'bolt-global'; if (components.global) { entry[globalEntryName] = ['@bolt/core-v3.x/styles/main.scss']; components.global.forEach(component => { if (component.assets.style) { entry[globalEntryName].push(component.assets.style); } if (component.assets.main) { entry[globalEntryName].push(component.assets.main); } }); } if (components.individual) { components.individual.forEach(component => { const files = []; if (component.assets.style) files.push(component.assets.style); if (component.assets.main) files.push(component.assets.main); if (files) { entry[component.basicName] = files; } }); } if (config.verbosity > 4) { log.info('WebPack `entry`:'); console.log(entry); } return entry; } function getSassLoaders() { // Default global Sass data defined let globalSassData = [ `$bolt-namespace: ${config.namespace};`, // output $bolt-lang variable in Sass even if not specified so things fall back accordingly. `${config.lang ? `$bolt-lang: ${config.lang};` : '$bolt-lang: null;'}`, ]; // Merge together global Sass data overrides specified in a .boltrc config if (config.globalData.scss && config.globalData.scss.length !== 0) { const overrideItems = []; config.globalData.scss.forEach(item => { try { const file = fs.readFileSync(item, 'utf8'); file .split('\n') .filter(x => x) .forEach(x => overrideItems.push(x)); } catch (err) { log.errorAndExit(`Could not find ${item}`, err); } }); globalSassData = [...globalSassData, ...overrideItems]; } return [ { loader: 'css-loader', options: { sourceMap: config.sourceMaps, modules: false, // needed for JS referencing classNames directly, such as critical fonts }, }, { loader: 'postcss-loader', options: { sourceMap: config.sourceMaps, plugins: () => [ postcssDiscardDuplicates, autoprefixer({ grid: true, }), ], }, }, { loader: 'clean-css-loader', options: { level: config.prod ? 1 : 0, format: config.prod ? false : 'beautify', inline: ['remote'], }, }, { loader: 'resolve-url-loader', }, { loader: 'sass-loader', options: { sourceMap: config.sourceMaps, prependData: globalSassData.join('\n'), sassOptions: { outputStyle: 'nested', importer: [npmSass.importer], functions: sassExportData, precision: 3, }, }, }, ]; } let sharedWebpackConfig = { target: 'web', resolve: { extensions: [ '.js', '.jsx', '.mjs', '.json', '.svg', '.scss', '.ts', '.tsx', '.jpg', ], alias: { react: 'preact/compat', 'react-dom/test-utils': 'preact/test-utils', 'react-dom': 'preact/compat', }, }, module: { rules: [ { test: /\.(ts|tsx)$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, experimentalWatchApi: true, }, }, ], }, { test: /\.(woff|woff2)$/, use: [ { loader: 'url-loader', options: { limit: 500, name: 'fonts/[name].[ext]', }, }, ], }, { test: /\.svg$/, oneOf: [ { issuer: /\.scss$/, use: [ { loader: 'file-loader', options: { name: '[name].[ext]', }, }, { loader: 'svgo-loader', options: { plugins: require('./svgo-plugins'), }, }, ], }, { use: [ { loader: 'babel-loader', options: { babelrc: false, presets: [babelConfig], }, }, { loader: 'svg-sprite-loader', options: { spriteFilename: svgPath => `bolt-svg-sprite${svgPath.substr(-4)}`, }, }, { loader: '@bolt/file-passthrough-loader', options: { name: 'icons/[name].[ext]', }, }, '@bolt/svg-transform-loader', { loader: 'svgo-loader', options: { plugins: require('./svgo-plugins'), }, }, ], }, ], }, { test: /\.(cur|png|jpg)$/, use: [ { loader: 'file-loader', options: { name: '[name].[ext]', }, }, ], }, { test: [/\.yml$/, /\.yaml$/], use: ['json-loader', 'yaml-loader'], }, { test: [/\.html$/], loader: 'raw-loader', // file as string }, ], }, mode: config.prod ? 'production' : 'development', optimization: { sideEffects: true, usedExports: true, minimizer: config.prod ? [ new TerserPlugin({ test: /\.m?js(\?.*)?$/i, sourceMap: config.sourceMaps, cache: true, parallel: true, terserOptions: { safari10: true, }, }), ] : [], }, plugins: [ new SpriteLoaderPlugin({ plainSprite: true, spriteAttrs: { id: '__SVG_SPRITE_NODE__', style: 'position: absolute; width: 0; height: 0', }, }), new webpack.ProgressPlugin(boltWebpackProgress), // Ties together the Bolt custom Webpack messages + % complete new webpack.NoEmitOnErrorsPlugin(), ], }; if (config.prod) { // https://webpack.js.org/plugins/module-concatenation-plugin/ sharedWebpackConfig.plugins.push( new webpack.optimize.ModuleConcatenationPlugin(), ); // Optimize CSS - https://github.com/NMFR/optimize-css-assets-webpack-plugin sharedWebpackConfig.plugins.push( new OptimizeCssAssetsPlugin({ canPrint: config.verbosity > 2, cssProcessor: require('cssnano'), cssProcessorPluginOptions: { preset: [ 'default', { discardComments: { removeAll: true }, mergeLonghand: false, // don't merge longhand values -- required for CSS Vars theming, etc. zindex: false, // don't alter `z-index` values mergeRules: false, // this MUST be disabled - otherwise certain selectors (ex. ::slotted(*), which IE 11 can't parse) break reduceTransforms: false, // this will convert translate3d(0,0,0) to tranlateZ(0) which breaks animation transitions calc: false, // don't optimize calc, can change calculations in unexpected ways, especially when CSS vars are involved }, ], }, }), ); // @todo evaluate best source map approach for production builds -- particularly source-map vs hidden-source-map sharedWebpackConfig.devtool = config.sourceMaps === false ? '' : 'hidden-source-map'; } else { // not prod // @todo fix source maps sharedWebpackConfig.devtool = config.sourceMaps === false ? '' : 'eval-source-map'; } // Simple Configuration // The easiest way to tweak the Bolt webpack config is by providing an object to the configureWebpack option in the `.boltrc.js` config: // // .boltrc.js // module.exports = { // configureWebpack: { // plugins: [ // new MyAwesomeWebpackPlugin() // ] // } // } // The object will be merged into the final webpack config using webpack-merge. if (config.configureWebpack) { sharedWebpackConfig = merge(sharedWebpackConfig, config.configureWebpack); } // Generate global JS data based on if the build is for ES Module-supporting browsers or not function getGlobalJSData() { let globalJsData = { 'process.env.NODE_ENV': config.prod ? JSON.stringify('production') : JSON.stringify('development'), bolt: { publicPath: JSON.stringify(publicPath), mode: JSON.stringify(config.mode), isClient: config.mode === 'client', isServer: config.mode === 'server', namespace: JSON.stringify(config.namespace), config: { prod: config.prod, lang: JSON.stringify(config.lang), env: JSON.stringify(config.env), }, }, }; // Merge together any global JS data overrides if (config.globalData.js && config.globalData.js.length !== 0) { const overrideJsItems = []; config.globalData.js.forEach(item => { try { const overrideFile = require(path.resolve(process.cwd(), item)); overrideJsItems.push(overrideFile); } catch (err) { log.errorAndExit(`Could not find ${item} file`, err); } }); globalJsData = deepmerge(globalJsData, ...overrideJsItems); } return globalJsData; } const webpackConfig = merge(sharedWebpackConfig, { entry: await buildWebpackEntry(true), resolve: { mainFields: ['esnext', 'jsnext:main', 'browser', 'module', 'main'], }, output: { futureEmitAssets: true, path: path.resolve(process.cwd(), config.buildDir), // @todo: switch this to output .client.js and .server.js file prefixes when we hit Bolt v3.0 filename: `[name]${langSuffix}${ config.mode !== 'client' ? `.${config.mode}` : '' }.js`, chunkFilename: `[name]-bundle${langSuffix}-[chunkhash].js`, publicPath, }, plugins: [ new webpack.DefinePlugin(getGlobalJSData(true)), new CopyWebpackPlugin(config.copy ? config.copy : []), new MiniCssExtractPlugin({ filename: `[name]${langSuffix}.css`, chunkFilename: `[id]${langSuffix}.css`, }), // @todo This needs to be in `config.dataDir` new ManifestPlugin({ fileName: `bolt-webpack-manifest${langSuffix}${ config.mode === 'client' ? '' : `.${config.mode}` }.json`, publicPath, writeToFileEmit: true, seed: { name: 'Bolt Modern Manifest', }, }), ], module: { rules: [ { test: /\.(js|jsx|tsx|mjs)$/, // Exclude `node_modules` except `@bolt`. When this webpack config is used outside // of the monorepo `node_modules/@bolt/*` dependencies must use babel-loader. exclude: /node_modules\/(?!(@bolt)\/).*/, use: [ { loader: 'babel-loader', options: { babelrc: false, presets: [babelConfig], }, }, ], }, { test: /\.scss$/, oneOf: [ { issuer: /\.js$/, use: [getSassLoaders(true)].reduce( (acc, val) => acc.concat(val), [], ), }, { // no issuer here as it has a bug when its an entry point - https://github.com/webpack/webpack/issues/5906 use: [ // 'css-hot-loader', MiniCssExtractPlugin.loader, getSassLoaders(true), ].reduce((acc, val) => acc.concat(val), []), }, ], }, ], }, }); // cache mode significantly speeds up subsequent build times if (config.enableCache) { webpackConfig.plugins.push( new HardSourceWebpackPlugin({ info: { level: 'warn', }, cacheDirectory: path.join(process.cwd(), `./cache/webpack`), // Clean up large, old caches automatically. cachePrune: { // Caches younger than `maxAge` are not considered for deletion. They must // be at least this (default: 2 days) old in milliseconds. maxAge: 2 * 24 * 60 * 60 * 1000, // All caches together must be larger than `sizeThreshold` before any // caches will be deleted. Together they must be at least 300MB in size sizeThreshold: 300 * 1024 * 1024, }, }), ); } let outputConfig = []; outputConfig.push(webpackConfig); return outputConfig; } // Helper function to associate each unique language in the build config with a separate Webpack build instance (making filenames, etc unique); async function assignLangToWebpackConfig(config, lang) { let langSpecificConfig = config; if (lang) { langSpecificConfig.lang = lang; // Make sure only ONE language config is set per Webpack build instance. } let langSpecificWebpackConfigs = await createWebpackConfig( langSpecificConfig, ); langSpecificWebpackConfigs.forEach(langSpecificWebpackConfig => { webpackConfigs.push(langSpecificWebpackConfig); }); } module.exports = async function() { const config = await getConfig(); return new Promise(async (resolve, reject) => { const langs = config.lang; const promises = []; // update the array of Webpack configs so each config is assigned to only one language (used in the filename's suffix when bundling language-tailed CSS and JS) if (Array.isArray(langs)) { for (const lang of langs) { /* eslint-disable no-await-in-loop */ promises.push(await assignLangToWebpackConfig(config, lang)); } } else if (langs === 'en') { promises.push(await assignLangToWebpackConfig(config, null)); } else { promises.push(await assignLangToWebpackConfig(config, config.lang)); } await Promise.all(promises).then(() => { return resolve(webpackConfigs); }); }); };