1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
3
4'use strict';
5
6// built-in imports
7const { spawnSync } = require('child_process');
8const fs = require('fs');
9const path = require('path');
10
11const Autoprefixer = require('autoprefixer');
12const CopyPlugin = require('copy-webpack-plugin');
13const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
14const dotenv = require('dotenv');
15const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
16const MiniCssExtractPlugin = require('mini-css-extract-plugin');
17const TerserPlugin = require('terser-webpack-plugin');
18const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
19const Watchpack = require('watchpack');
20const webpack = require('webpack');
21const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
22const generateLocalizations = require('./resources/js/cli/generate-localizations');
23const modNamesGenerator = require('./resources/js/cli/mod-names-generator');
24
25
26// #region env
27const env = process.env.NODE_ENV || 'development';
28dotenv.config({ path: `.env.${env}` });
29dotenv.config();
30
31const inProduction = env === 'production' || process.argv.includes('-p');
32
33const writeManifest = !(process.env.SKIP_MANIFEST === '1'
34 || process.env.SKIP_MANIFEST === 'true'
35 || process.env.SKIP_MANIFEST);
36// #endregion
37
38// #region helpers
39// Most plugins should follow webpack's own interpolation format:
40// https://github.com/webpack/loader-utils#interpolatename
41function outputFilename(name, ext = '[ext]', hashType = 'contenthash:8') {
42 return `${name}.[${hashType}]${ext}`;
43}
44
45function resolvePath(...segments) {
46 return path.resolve(__dirname, ...segments);
47}
48
49// #endregion
50
51// #region entrypoints and output
52const entry = {};
53const entrypointDirs = [
54 'resources/css/entrypoints',
55 'resources/js/entrypoints',
56];
57const supportedExts = new Set(['.coffee', '.less', '.ts', '.tsx']);
58for (const entrypointsPath of entrypointDirs) {
59 fs.readdirSync(resolvePath(entrypointsPath), { withFileTypes: true }).forEach((item) => {
60 if (item.isFile()) {
61 const filename = item.name;
62 const ext = path.extname(filename);
63
64 if (supportedExts.has(ext)) {
65 const entryName = path.basename(filename, ext);
66
67 if (entry[entryName] == null) {
68 entry[entryName] = [];
69 }
70 entry[entryName].push(resolvePath(entrypointsPath, filename));
71 }
72 }
73 });
74}
75
76const output = {
77 filename: outputFilename('js/[name]', '.js'),
78 path: resolvePath('public/assets'),
79 publicPath: '/assets/',
80};
81
82// #endregion
83
84// #region plugin list
85const plugins = [
86 new ForkTsCheckerWebpackPlugin(),
87 new webpack.ProvidePlugin({
88 $: 'jquery',
89 _: 'lodash',
90 d3: 'd3', // TODO: d3 is fat and probably should have it's own chunk
91 jQuery: 'jquery',
92 moment: 'moment',
93 React: 'react',
94 ReactDOM: 'react-dom',
95 }),
96 new webpack.DefinePlugin({
97 docsUrl: JSON.stringify(process.env.DOCS_URL ?? 'https://docs.ppy.sh'),
98 }),
99 new webpack.IgnorePlugin({
100 // don't add moment locales to bundle.
101 contextRegExp: /moment$/,
102 resourceRegExp: /^\.\/locale$/,
103 }),
104 new MiniCssExtractPlugin({
105 filename: outputFilename('css/[name]', '.css'),
106 }),
107 new CopyPlugin({
108 patterns: [
109 { from: 'resources/builds/locales', to: outputFilename('js/locales/[name]') },
110 { from: 'node_modules/moment/locale', to: outputFilename('js/moment-locales/[name]') },
111 { from: 'node_modules/@discordapp/twemoji/dist/svg/1f1??-1f1??.svg', to: 'images/flags/[name][ext]' },
112 ],
113 }),
114];
115
116if (writeManifest) {
117 plugins.push(new WebpackManifestPlugin({
118 filter: (file) => file.path.match(/^\/assets\/(?:css|js)\/.*\.(?:css|js)$/) !== null,
119 map: (file) => {
120 const baseDir = file.path.match(/^\/assets\/(css|js)\//)?.[1];
121 if (baseDir !== null && !file.name.startsWith(`${baseDir}/`)) {
122 file.name = `${baseDir}/${file.name}`;
123 }
124
125 return file;
126 },
127 }));
128}
129
130// TODO: should have a different flag for this
131if (!inProduction) {
132 const { CleanWebpackPlugin } = require('clean-webpack-plugin');
133 plugins.push(new CleanWebpackPlugin());
134
135 const notifierConfigPath = resolvePath('.webpack-build-notifier-config.js');
136 if (fs.existsSync(notifierConfigPath)) {
137 const WebpackBuildNotifierPlugin = require('webpack-build-notifier');
138 plugins.push(new WebpackBuildNotifierPlugin(require(notifierConfigPath)));
139 }
140}
141
142// #endregion
143
144// #region Loader rules
145const rules = [
146 {
147 exclude: /node_modules/,
148 loader: 'ts-loader',
149 options: {
150 transpileOnly: true,
151 },
152 test: /\.tsx?$/,
153 },
154 {
155 test: /\.coffee$/,
156 use: ['coffee-loader'],
157 },
158 {
159 test: /\.less$/,
160 use: [
161 MiniCssExtractPlugin.loader,
162 {
163 loader: 'css-loader',
164 options: {
165 importLoaders: 1,
166 sourceMap: true,
167 },
168 },
169 {
170 loader: 'postcss-loader',
171 options: {
172 postcssOptions: {
173 plugins: [Autoprefixer],
174 },
175 },
176 },
177 { loader: 'less-loader', options: { sourceMap: true } },
178 ],
179 },
180 {
181 generator: {
182 filename: outputFilename('images/[name]'),
183 },
184 test: /(\.(png|jpe?g|gif|webp)$|^((?!font).)*\.svg$)/,
185 type: 'asset/resource',
186 },
187 {
188 generator: {
189 filename: outputFilename('fonts/[name]'),
190 },
191 test: /(\.(woff2?|ttf|eot|otf)$|font.*\.svg$)/,
192 type: 'asset/resource',
193 },
194];
195
196// #endregion
197
198// #region resolvers
199const resolve = {
200 alias: {
201 '@fonts': path.resolve(__dirname, 'resources/fonts'),
202 '@images': path.resolve(__dirname, 'public/images'),
203 },
204 extensions: ['*', '.js', '.coffee', '.ts', '.tsx'],
205 modules: [
206 resolvePath('resources/builds'),
207 resolvePath('resources/js'),
208 'node_modules',
209 ],
210 plugins: [new TsconfigPathsPlugin()],
211};
212
213// #endregion
214
215// #region optimization and chunk splitting settings
216function partialPathCheck(pathCheck, partialPathArray) {
217 return pathCheck.includes(['', ...partialPathArray, ''].join(path.sep));
218}
219
220const docsOnlyLibraries = [
221 ['node_modules', 'highlight.js'],
222 ['node_modules', 'jets'],
223];
224
225const cacheGroups = {
226 commons: {
227 chunks: 'initial',
228 minChunks: 2,
229 name: 'commons',
230 priority: -20,
231 },
232 vendor: {
233 chunks: 'initial',
234 name: 'vendor',
235 priority: -10,
236 reuseExistingChunk: true,
237 // Doing it this way doesn't split the css imported via app.less from the main css bundle.
238 test: (module) => module.resource && (
239 partialPathCheck(module.resource, ['node_modules'])
240 && docsOnlyLibraries.every((p) => !partialPathCheck(module.resource, p))
241 ),
242 },
243};
244
245const optimization = {
246 moduleIds: 'deterministic',
247 runtimeChunk: {
248 name: 'runtime',
249 },
250 splitChunks: {
251 cacheGroups,
252 },
253};
254
255if (inProduction) {
256 optimization.minimizer = [
257 new TerserPlugin({
258 terserOptions: {
259 safari10: true,
260 sourceMap: true,
261 },
262 }),
263 new CssMinimizerPlugin(),
264 ];
265}
266
267// #endregion
268
269const watches = [
270 {
271 callback: modNamesGenerator,
272 path: resolvePath('database/mods.json'),
273 type: 'file',
274 },
275 {
276 callback: () => spawnSync(
277 'php',
278 ['artisan', 'ziggy:generate', 'resources/builds/ziggy.js', '--types'],
279 { stdio: 'inherit' },
280 ),
281 path: resolvePath('routes/web.php'),
282 type: 'file',
283 },
284 {
285 callback: generateLocalizations,
286 path: resolvePath('resources/lang'),
287 type: 'dir',
288 },
289];
290
291function configFunction(_env, argv) {
292 watches.forEach((watched) => watched.callback());
293
294 if (argv.watch) {
295 const wp = new Watchpack({
296 // fire an aggregated event after 200ms on changes.
297 aggregateTimeout: 200,
298 // same as webpack-cli's handling
299 poll: argv.watchOptionsPoll,
300 });
301
302 wp.watch({
303 directories: watches.filter((x) => x.type === 'dir').map((x) => x.path),
304 files: watches.filter((x) => x.type === 'file').map((x) => x.path),
305 });
306
307 wp.on('aggregated', (changes, removals) => {
308 watches.forEach((watched) => {
309 if (changes.has(watched.path) || removals.has(watched.path)) {
310 watched.callback();
311 }
312 });
313 });
314 }
315
316 return {
317 devtool: 'source-map',
318 entry,
319 mode: inProduction ? 'production' : 'development',
320 module: {
321 rules,
322 },
323 optimization,
324 output,
325 plugins,
326 resolve,
327 stats: {
328 entrypoints: false,
329 errorDetails: false,
330 excludeAssets: [
331 // exclude copied files
332 /^js\/(moment-locales|locales)\//,
333 /^fonts/,
334 /^images/,
335 ],
336 },
337 };
338}
339
340module.exports = configFunction;