@nlabs/lex
Version:
708 lines (668 loc) • 21.5 kB
JavaScript
/**
* Copyright (c) 2018-Present, Nitrogen Labs, Inc.
* Copyrights licensed under the MIT License. See the accompanying LICENSE file for terms.
*/
import {StaticSitePlugin} from '@nlabs/webpack-plugin-static-site';
import tailwindcss from '@tailwindcss/postcss';
import autoprefixer from 'autoprefixer';
import CompressionWebpackPlugin from 'compression-webpack-plugin';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import cssnano from 'cssnano';
import DotenvPlugin from 'dotenv-webpack';
import {EsbuildPlugin} from 'esbuild-loader';
import FaviconsWebpackPlugin from 'favicons-webpack-plugin';
import {existsSync} from 'fs';
import {sync as globSync} from 'glob';
import HtmlWebPackPlugin from 'html-webpack-plugin';
import isEmpty from 'lodash/isEmpty.js';
import {resolve as pathResolve} from 'path';
import postcssBrowserReporter from 'postcss-browser-reporter';
import postcssCustomProperties from 'postcss-custom-properties';
import postcssFlexbugsFixes from 'postcss-flexbugs-fixes';
import postcssFor from 'postcss-for';
import postcssImport from 'postcss-import';
import postcssNesting from 'postcss-nesting';
import postcssPercentage from 'postcss-percentage';
import postcssPresetEnv from 'postcss-preset-env';
import postcssUrl from 'postcss-url';
import SVGSpriteMapPlugin from 'svg-spritemap-webpack-plugin';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import {URL} from 'url';
import {default as webpack} from 'webpack';
import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
import {merge} from 'webpack-merge';
import {WebpackPluginServe} from 'webpack-plugin-serve';
import {relativeFilePath, relativeNodePath} from './lib/utils/file.js';
const {ProgressPlugin, ProvidePlugin} = webpack;
const isProduction = process.env.NODE_ENV === 'production';
const lexConfig = JSON.parse(process.env.LEX_CONFIG) || {};
const dirName = new URL('.', import.meta.url).pathname;
const {
isStatic,
outputFullPath,
sourceFullPath,
outputFile,
outputHash,
libraryName,
libraryTarget,
preset,
targetEnvironment = 'es2015',
webpack: webpackCustom
} = lexConfig;
const webpackPublicPath = webpackCustom?.publicPath || './src/static';
const { publicPath: _, ...webpackConfigFiltered } = webpackCustom || {};
const plugins = [
new ProgressPlugin({
activeModules: false,
entries: true,
handler(percentage, message, ...args) {
// custom logic
},
modules: true,
modulesCount: 5000,
profile: false,
dependencies: true,
dependenciesCount: 10000,
percentBy: null
}),
new DotenvPlugin({
allowEmptyValues: true,
path: pathResolve(process.cwd(), '.env'),
safe: false,
silent: true,
systemvars: true
}),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
process: 'process/browser',
global: 'global'
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'global': 'global'
}),
{
apply: (compiler) => {
compiler.hooks.watchRun.tap('NotifyOnRebuild', () => {
console.log('\x1b[36m[webpack]\x1b[0m Detected file change. Rebuilding...');
});
compiler.hooks.done.tap('NotifyOnRebuild', (stats) => {
if (stats.hasErrors()) {
console.log('\x1b[31m[webpack]\x1b[0m Build failed with errors.');
} else {
console.log('\x1b[32m[webpack]\x1b[0m Build complete. Watching for changes...');
}
});
}
}
];
const isWeb = (preset || targetEnvironment) === 'web';
const isReactNative = preset === 'react-native';
if(isWeb) {
plugins.push(
new CompressionWebpackPlugin({algorithm: 'gzip'}),
new ProvidePlugin({
process: 'process/browser',
global: 'global',
React: pathResolve(dirName, './node_modules/react')
})
);
}
const globOptions = {
cwd: sourceFullPath,
dot: false,
nodir: true,
nosort: true
};
const svgPaths = `${sourceFullPath}/icons/**/**.svg`;
if(globSync(svgPaths, globOptions).length) {
plugins.push(
new SVGSpriteMapPlugin(svgPaths, {
input: {
allowDuplicates: false
},
output: {
chunk: {keep: true},
filename: './icons/icons.svg'
},
sprite: {
prefix: false
}
})
);
}
const staticPaths = [];
const watchIgnorePaths = [
`${sourceFullPath}/**/**.gif`,
`${sourceFullPath}/**/**.jpg`,
`${sourceFullPath}/**/**.png`
];
const imagePath = `${sourceFullPath}/images/`;
const fontPath = `${sourceFullPath}/fonts/`;
const docPath = `${sourceFullPath}/docs/`;
const publicPathFull = pathResolve(process.cwd(), webpackPublicPath);
if(existsSync(publicPathFull)) {
staticPaths.push({
from: publicPathFull,
to: './'
});
watchIgnorePaths.push(publicPathFull);
}
if(existsSync(imagePath)) {
staticPaths.push({from: imagePath, to: './images/'});
watchIgnorePaths.push(imagePath);
}
if(existsSync(fontPath)) {
staticPaths.push({from: fontPath, to: './fonts/'});
watchIgnorePaths.push(fontPath);
}
if(existsSync(docPath)) {
staticPaths.push({from: docPath, to: './docs/'});
}
if(staticPaths.length) {
plugins.push(new CopyWebpackPlugin({patterns: staticPaths}));
}
if(existsSync(`${sourceFullPath}/${lexConfig.entryHTML}`)) {
plugins.push(
new HtmlWebPackPlugin({
filename: './index.html',
minify: isProduction,
scriptLoading: 'defer',
showErrors: !isProduction,
template: `${sourceFullPath}/${lexConfig.entryHTML}`,
inject: true
})
);
const missingAssets = [];
const requiredAssets = ['favicon.ico', 'images/logo-icon-64.png', 'manifest.json'];
requiredAssets.forEach(asset => {
if (!existsSync(`${sourceFullPath}/${asset}`)) {
missingAssets.push(asset);
}
});
if (missingAssets.length > 0) {
plugins.push(
new CopyWebpackPlugin({
patterns: missingAssets.map(asset => ({
from: pathResolve(dirName, 'emptyModule.js'),
to: `${outputFullPath}/${asset}`,
transform() {
return '';
}
}))
})
);
}
}
let outputFilename = outputFile;
if(outputFile) {
outputFilename = outputFile;
} else if(outputHash || (isWeb && isProduction)) {
outputFilename = '[name].[hash].js';
} else {
outputFilename = '[name].js';
}
const resolvePlugins = [];
if(existsSync(`${sourceFullPath}/tsconfig.json`)) {
resolvePlugins.push(new TsconfigPathsPlugin({
configFile: `${sourceFullPath}/../tsconfig.json`
}));
}
const esbuildLoaderPath = relativeNodePath('esbuild-loader', dirName);
const cssLoaderPath = relativeNodePath('css-loader', dirName);
const graphqlLoaderPath = relativeNodePath('graphql-tag/loader', dirName);
const htmlLoaderPath = relativeNodePath('html-loader', dirName);
const postcssLoaderPath = relativeNodePath('postcss-loader', dirName);
const sourceMapLoaderPath = relativeNodePath('source-map-loader', dirName);
const styleLoaderPath = relativeNodePath('style-loader', dirName);
const webpackPath = relativeNodePath('webpack', dirName);
const aliasPaths = {
'@nlabs/arkhamjs': relativeNodePath('@nlabs/arkhamjs', process.cwd()),
'@nlabs/arkhamjs-utils-react': relativeNodePath('@nlabs/arkhamjs-utils-react', process.cwd()),
'buffer': relativeNodePath('buffer', dirName),
'core-js': relativeNodePath('core-js', dirName),
process: relativeNodePath('process', dirName),
react: relativeNodePath('react', process.cwd()),
'react-dom': relativeNodePath('react-dom', process.cwd()),
'regenerator-runtime': relativeNodePath('regenerator-runtime', dirName),
'Buffer': relativeNodePath('buffer', dirName)
};
const aliasKeys = Object.keys(aliasPaths);
const alias = aliasKeys.reduce((aliases, key) => {
if(!isEmpty(aliasPaths[key])) {
aliases[key] = aliasPaths[key];
}
return aliases;
}, {});
export default (webpackEnv, webpackOptions) => {
const {bundleAnalyzer, watch, entry: cliEntry, mode: cliMode} = webpackOptions;
const entryValue = Array.isArray(cliEntry) ? cliEntry[0] : cliEntry;
// Debug printout for environment and mode
console.log('[Lex Webpack] NODE_ENV:', process.env.NODE_ENV);
console.log('[Lex Webpack] isProduction:', isProduction);
console.log('[Lex Webpack] cliMode:', cliMode);
const webpackConfig = {
bail: true,
cache: !isProduction,
devtool: isProduction
? 'inline-cheap-module-source-map'
: 'eval-cheap-module-source-map',
entry: entryValue
? {
index: [
'buffer',
'process/browser',
entryValue
]
}
: {
index: [
'buffer',
'process/browser',
`${sourceFullPath}/${lexConfig.entryJs}`
]
},
externals: isReactNative ? {'react-native': true} : undefined,
ignoreWarnings: [/Failed to parse source map/],
mode: isProduction ? 'production' : 'development',
module: {
rules: [
{
test: /\.m?js/,
resolve: {
fullySpecified: false
}
},
{
test: /\.js$/,
include: /node_modules/,
type: 'javascript/auto'
},
{
enforce: 'pre',
exclude: /(node_modules)/,
include: sourceFullPath,
loader: sourceMapLoaderPath,
test: /\.(ts|tsx|js)$/
},
{
exclude: [
/node_modules\/(?!(react-native))/,
`${sourceFullPath}/**/*.test.js*`,
`${sourceFullPath}/**/*.test.ts*`
],
include: sourceFullPath,
loader: esbuildLoaderPath,
options: {
loader: 'tsx',
target: targetEnvironment === 'node' ? 'node16' : 'es2016'
},
resolve: {
symlinks: true
},
test: /\.(ts|tsx|js)$/
},
{
exclude: [pathResolve(sourceFullPath, lexConfig.entryHTML)],
include: sourceFullPath,
test: /\.html$/,
use: [
{
loader: htmlLoaderPath,
options: {
minimize: isProduction,
sources: {
list: [
'...',
{
tag: 'link',
attribute: 'href',
type: 'src',
filter: (tag, attribute, attributes) => {
const href = attributes[attribute];
return !href || !href.match(/\.(ico|png|jpg|jpeg|gif|svg|json)$/);
}
},
{
tag: 'script',
attribute: 'src',
type: 'src',
filter: (tag, attribute, attributes) => {
const src = attributes[attribute];
return !src || !src.match(/\.(ico|png|jpg|jpeg|gif|svg|json)$/);
}
}
]
}
}
}
]
},
{
test: /\.css$/,
use: [
styleLoaderPath,
{
loader: cssLoaderPath,
options: {
importLoaders: 1
}
},
{
loader: postcssLoaderPath,
options: {
postcssOptions: {
plugins: [
postcssImport({
addDependencyTo: webpack,
path: [relativeNodePath('', dirName)]
}),
postcssUrl(),
postcssFor(),
postcssPercentage({
floor: true,
precision: 9,
trimTrailingZero: true
}),
postcssCustomProperties({
extensions: ['.css'],
inject: {
insertAt: 'top'
},
minimize: true,
preserve: false,
strict: false,
warnings: false
}),
postcssNesting(),
tailwindcss(),
autoprefixer(),
postcssFlexbugsFixes(),
postcssPresetEnv({
stage: 0
}),
...(isProduction ? [cssnano({autoprefixer: false})] : []),
postcssBrowserReporter()
]
}
}
},
{
loader: esbuildLoaderPath,
options: {
loader: 'css',
minify: isProduction
}
}
]
},
{
exclude: /(node_modules)/,
include: sourceFullPath,
test: /\.(gif|jpg|png|svg)$/,
type: 'asset/resource'
},
{
test: /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac)$/,
include: publicPathFull,
type: 'asset/resource',
generator: {
filename: '[name].[hash][ext]'
},
use: isProduction ? [
{
loader: 'image-webpack-loader',
options: {
mozjpeg: {
progressive: true,
quality: 65
},
optipng: {
enabled: false
},
pngquant: {
quality: [0.65, 0.90],
speed: 4
},
gifsicle: {
interlaced: false
},
webp: {
quality: 75
}
}
}
] : []
},
{
test: /\.json$/,
type: 'json'
},
{
test: /\.ico$/,
type: 'asset/resource',
generator: {
filename: '[name][ext]'
}
},
{
exclude: /(node_modules)/,
include: sourceFullPath,
loader: graphqlLoaderPath,
test: /\.(gql|graphql)$/
}
]
},
optimization:
isProduction && isWeb
? {
minimizer: [
new EsbuildPlugin({
css: true,
target: targetEnvironment
})
],
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
chunks: 'all',
minSize: 0,
name: 'vendors',
test: /[\\/]node_modules[\\/]/
}
}
},
usedExports: true
}
: {},
output: {
filename: outputFilename,
library: libraryName,
libraryTarget,
path: outputFullPath,
publicPath: '/'
},
plugins,
recordsPath: relativeFilePath('webpack.records.json', process.cwd()),
resolve: {
alias,
extensions: [
'.*',
'.mjs',
'.js',
'.ts',
'.tsx',
'.jsx',
'.json',
'.gql',
'.graphql'
],
fallback: {
assert: relativeNodePath('assert', dirName),
buffer: relativeNodePath('buffer', dirName),
crypto: relativeNodePath('crypto-js', dirName),
http: relativeNodePath('stream-http', dirName),
https: relativeNodePath('https-browserify', dirName),
os: relativeNodePath('os-browserify/browser.js', dirName),
path: relativeNodePath('path-browserify', dirName),
process: relativeNodePath('process/browser.js', dirName),
randombytes: relativeNodePath('randombytes', dirName),
stream: relativeNodePath('stream-browserify', dirName),
util: relativeNodePath('util', dirName),
vm: relativeNodePath('vm-browserify', dirName),
Buffer: relativeNodePath('buffer', dirName)
},
mainFiles: ['index'],
modules: [sourceFullPath, 'node_modules', relativeNodePath('', dirName), '/Users/nitrog7/.nvm/versions/node/v22.14.0/lib/node_modules'],
plugins: resolvePlugins,
unsafeCache: {
node_modules: true
}
},
target: isWeb ? 'web' : 'node'
};
if(!isProduction) {
webpackConfig.resolve.alias = {
...webpackConfig.resolve.alias,
webpack: webpackPath
};
webpackConfig.optimization = {minimize: false};
webpackConfig.entry.wps = relativeNodePath(
'webpack-plugin-serve/client.js',
dirName
);
webpackConfig.stats = {errorDetails: true};
webpackConfig.plugins.push(
new WebpackPluginServe({
client: {
silent: process.env.LEX_QUIET === 'true'
},
historyFallback: {
disableDotRule: true,
htmlAcceptHeaders: ['text/html', '*/*'],
index: '/index.html',
logger: console.log.bind(console),
rewrites: [
{
from: '/wps',
to: ({parsedUrl: {pathname}}) => pathname
},
{
from: /\.js/,
to: ({parsedUrl: {pathname}}) => {
const pathUrl = pathname.split('/');
const fileIndex = pathUrl.length > 1 ? pathUrl.length - 1 : 0;
return `/${pathUrl[fileIndex]}`;
}
},
{
from: /\.[css,gif,ico,jpg,json,png,svg]/,
to: ({parsedUrl: {pathname}}) => pathname
}
],
verbose: !(process.env.LEX_QUIET === 'true')
},
hmr: false,
log: {level: 'trace'},
middleware: (app) =>
app.use(async (ctx, next) => {
if(ctx.path.match(/^\/wps/)) {
const {accept, Accept, ...remainingHeaders} =
ctx.request.header;
ctx.request.header = remainingHeaders;
}
await next();
}),
open: process.env.WEBPACK_DEV_OPEN === 'true',
port: 7001,
progress: 'minimal',
static: existsSync(outputFullPath) ? [outputFullPath] : [],
status: true
})
);
if(bundleAnalyzer) {
webpackConfig.plugins.push(
new BundleAnalyzerPlugin({openAnalyzer: false})
);
}
if(watch) {
webpackConfig.bail = false;
webpackConfig.watchOptions = {
aggregateTimeout: 500,
ignored: ['node_modules/**', ...watchIgnorePaths]
};
}
} else {
const siteLogo = `${sourceFullPath}/images/logo.png`;
if(existsSync(siteLogo)) {
plugins.push(
new FaviconsWebpackPlugin({
icons: {
android: true,
appleIcon: true,
appleStartup: false,
coast: false,
favicons: true,
firefox: false,
opengraph: true,
twitter: true,
windows: false,
yandex: false
},
logo: siteLogo
})
);
}
if(isStatic) {
webpackConfig.plugins.push(
new StaticSitePlugin()
);
}
if(isProduction && isWeb) {
webpackConfig.optimization.moduleIds = 'deterministic';
}
}
if (process.env.LEX_CONFIG_DEBUG) {
console.log('\n\x1b[36m[LEX_CONFIG_DEBUG] Webpack mode:', process.env.NODE_ENV, 'isProduction:', isProduction, '\x1b[0m');
if (webpackConfig && webpackConfig.module && Array.isArray(webpackConfig.module.rules)) {
console.log('\x1b[36m[LEX_CONFIG_DEBUG] Loader chains:\x1b[0m');
webpackConfig.module.rules.forEach((rule, idx) => {
if (rule.test) {
let testStr = rule.test.toString();
let use = rule.use || rule.loader || rule.type;
if (Array.isArray(use)) {
use = use.map(u => (typeof u === 'string' ? u : u.loader || u.type)).join(' -> ');
} else if (typeof use === 'object' && use !== null) {
use = use.loader || use.type;
}
console.log(` [${idx}] ${testStr}: ${use}`);
}
});
}
if (webpackConfig && Array.isArray(webpackConfig.plugins)) {
console.log('\x1b[36m[LEX_CONFIG_DEBUG] Plugins:\x1b[0m');
webpackConfig.plugins.forEach((plugin, idx) => {
let name = plugin.constructor && plugin.constructor.name;
if (!name && typeof plugin === 'object' && plugin.apply) name = 'CustomPlugin';
if (!name && typeof plugin === 'function') name = 'FunctionPlugin';
console.log(` [${idx}] ${name}`);
});
}
if (webpackConfig && webpackConfig.module && Array.isArray(webpackConfig.module.rules)) {
const cssRule = webpackConfig.module.rules.find(rule => rule.test && rule.test.toString().includes('css'));
if (cssRule) {
let use = cssRule.use || cssRule.loader || cssRule.type;
if (Array.isArray(use)) {
use = use.map(u => (typeof u === 'string' ? u : u.loader || u.type)).join(' -> ');
} else if (typeof use === 'object' && use !== null) {
use = use.loader || use.type;
}
console.log('\x1b[36m[LEX_CONFIG_DEBUG] CSS Loader Chain:\x1b[0m', use);
} else {
console.log('\x1b[36m[LEX_CONFIG_DEBUG] No CSS loader rule found.\x1b[0m');
}
}
}
return merge(webpackConfig, webpackConfigFiltered);
};