1import fastGlob from 'fast-glob';
2import wrapAnsi from 'wrap-ansi';
3import {init as licenseChecker} from 'license-checker-rseidelsohn';
4import MiniCssExtractPlugin from 'mini-css-extract-plugin';
5import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
6import {VueLoaderPlugin} from 'vue-loader';
7import EsBuildLoader from 'esbuild-loader';
8import {parse, dirname} from 'node:path';
9import webpack from 'webpack';
10import {fileURLToPath} from 'node:url';
11import {readFileSync, writeFileSync} from 'node:fs';
12import {env} from 'node:process';
13import tailwindcss from 'tailwindcss';
14import tailwindConfig from './tailwind.config.js';
15import tailwindcssNesting from 'tailwindcss/nesting/index.js';
16import postcssNesting from 'postcss-nesting';
17
18const {EsbuildPlugin} = EsBuildLoader;
19const {SourceMapDevToolPlugin, DefinePlugin, ProgressPlugin} = webpack;
20const formatLicenseText = (licenseText) => wrapAnsi(licenseText || '', 80).trim();
21
22const baseDirectory = dirname(fileURLToPath(new URL(import.meta.url)));
23const glob = (pattern) => fastGlob.sync(pattern, {
24 cwd: baseDirectory,
25 absolute: true,
26});
27
28const themes = {};
29for (const path of glob('web_src/css/themes/*.css')) {
30 themes[parse(path).name] = [path];
31}
32
33const isProduction = env.NODE_ENV !== 'development';
34
35if (isProduction) {
36 licenseChecker({
37 start: baseDirectory,
38 production: true,
39 onlyAllow: 'Apache-2.0; 0BSD; BSD-2-Clause; BSD-3-Clause; BlueOak-1.0.0; MIT; ISC; Unlicense; CC-BY-4.0',
40 // argparse@2.0.1 - Python-2.0. It's used in the CLI file of markdown-it and js-yaml and not in the library code.
41 // idiomorph@0.3.0. See https://github.com/bigskysoftware/idiomorph/pull/37
42 excludePackages: 'argparse@2.0.1;idiomorph@0.3.0',
43 }, (err, dependencies) => {
44 if (err) {
45 throw err;
46 }
47
48 const line = '-'.repeat(80);
49 const goJson = readFileSync('assets/go-licenses.json', 'utf8');
50 const goModules = JSON.parse(goJson).map(({name, licenseText}) => {
51 return {name, body: formatLicenseText(licenseText)};
52 });
53 const jsModules = Object.keys(dependencies).map((packageName) => {
54 const {licenses, licenseFile} = dependencies[packageName];
55 const licenseText = (licenseFile && !licenseFile.toLowerCase().includes('readme')) ? readFileSync(licenseFile) : '[no license file]';
56 return {name: packageName, licenseName: licenses, body: formatLicenseText(licenseText)};
57 });
58 const modules = [...goModules, ...jsModules];
59 const licenseTxt = modules.map(({name, licenseName, body}) => {
60 const title = licenseName ? `${name} - ${licenseName}` : name;
61 return `${line}\n${title}\n${line}\n${body}`;
62 }).join('\n');
63 writeFileSync('public/assets/licenses.txt', licenseTxt);
64 });
65} else {
66 writeFileSync('public/assets/licenses.txt', 'Licenses are disabled during development');
67}
68
69// ENABLE_SOURCEMAP accepts the following values:
70// true - all enabled, the default in development
71// reduced - minimal sourcemaps, the default in production
72// false - all disabled
73let sourceMaps;
74if ('ENABLE_SOURCEMAP' in env) {
75 sourceMaps = ['true', 'false'].includes(env.ENABLE_SOURCEMAP) ? env.ENABLE_SOURCEMAP : 'reduced';
76} else {
77 sourceMaps = isProduction ? 'reduced' : 'true';
78}
79
80// define which web components we use for Vue to not interpret them as Vue components
81const webComponents = new Set([
82 // our own, in web_src/js/webcomponents
83 'overflow-menu',
84 'origin-url',
85 'absolute-date',
86 // from dependencies
87 'markdown-toolbar',
88 'relative-time',
89 'text-expander',
90]);
91
92const filterCssImport = (url, ...args) => {
93 const cssFile = args[1] || args[0]; // resourcePath is 2nd argument for url and 3rd for import
94 const importedFile = url.replace(/[?#].+/, '').toLowerCase();
95
96 if (cssFile.includes('fomantic')) {
97 if (/brand-icons/.test(importedFile)) return false;
98 if (/(eot|ttf|otf|woff|svg)$/i.test(importedFile)) return false;
99 }
100
101 if (cssFile.includes('katex') && /(ttf|woff)$/i.test(importedFile)) {
102 return false;
103 }
104
105 return true;
106};
107
108/** @type {import("webpack").Configuration} */
109export default {
110 mode: isProduction ? 'production' : 'development',
111 entry: {
112 index: [
113 fileURLToPath(new URL('web_src/js/jquery.js', import.meta.url)),
114 fileURLToPath(new URL('web_src/fomantic/build/semantic.js', import.meta.url)),
115 fileURLToPath(new URL('web_src/js/index.js', import.meta.url)),
116 fileURLToPath(new URL('node_modules/easymde/dist/easymde.min.css', import.meta.url)),
117 fileURLToPath(new URL('web_src/fomantic/build/semantic.css', import.meta.url)),
118 fileURLToPath(new URL('web_src/css/index.css', import.meta.url)),
119 ],
120 webcomponents: [
121 fileURLToPath(new URL('web_src/js/webcomponents/index.js', import.meta.url)),
122 ],
123 forgejoswagger: [ // Forgejo swagger is OpenAPI 3.0.0 and has specific parameters
124 fileURLToPath(new URL('web_src/js/standalone/forgejo-swagger.js', import.meta.url)),
125 fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
126 ],
127 swagger: [
128 fileURLToPath(new URL('web_src/js/standalone/swagger.js', import.meta.url)),
129 fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
130 ],
131 'eventsource.sharedworker': [
132 fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.js', import.meta.url)),
133 ],
134 ...(!isProduction && {
135 devtest: [
136 fileURLToPath(new URL('web_src/js/standalone/devtest.js', import.meta.url)),
137 fileURLToPath(new URL('web_src/css/standalone/devtest.css', import.meta.url)),
138 ],
139 }),
140 ...themes,
141 },
142 devtool: false,
143 output: {
144 path: fileURLToPath(new URL('public/assets', import.meta.url)),
145 filename: () => 'js/[name].js',
146 chunkFilename: ({chunk}) => {
147 const language = (/monaco.*languages?_.+?_(.+?)_/.exec(chunk.id) || [])[1];
148 return `js/${language ? `monaco-language-${language.toLowerCase()}` : `[name]`}.[contenthash:8].js`;
149 },
150 },
151 optimization: {
152 minimize: isProduction,
153 minimizer: [
154 new EsbuildPlugin({
155 target: 'es2020',
156 minify: true,
157 css: true,
158 legalComments: 'none',
159 }),
160 ],
161 splitChunks: {
162 chunks: 'async',
163 name: (_, chunks) => chunks.map((item) => item.name).join('-'),
164 },
165 moduleIds: 'named',
166 chunkIds: 'named',
167 },
168 module: {
169 rules: [
170 {
171 test: /\.vue$/i,
172 exclude: /node_modules/,
173 loader: 'vue-loader',
174 options: {
175 compilerOptions: {
176 isCustomElement: (tag) => webComponents.has(tag),
177 },
178 },
179 },
180 {
181 test: /\.(js|ts)$/i,
182 exclude: /node_modules/,
183 use: [
184 {
185 loader: 'esbuild-loader',
186 options: {
187 loader: 'ts',
188 target: 'es2020',
189 },
190 },
191 ],
192 },
193 {
194 test: /\.css$/i,
195 use: [
196 {
197 loader: MiniCssExtractPlugin.loader,
198 },
199 {
200 loader: 'css-loader',
201 options: {
202 sourceMap: sourceMaps === 'true',
203 url: {filter: filterCssImport},
204 import: {filter: filterCssImport},
205 importLoaders: 1,
206 },
207 },
208 {
209 loader: 'postcss-loader',
210 options: {
211 postcssOptions: {
212 plugins: [
213 tailwindcssNesting(postcssNesting({edition: '2024-02'})),
214 tailwindcss(tailwindConfig),
215 ],
216 },
217 },
218 },
219 ],
220 },
221 {
222 test: /\.svg$/i,
223 include: fileURLToPath(new URL('public/assets/img/svg', import.meta.url)),
224 type: 'asset/source',
225 },
226 {
227 test: /\.(ttf|woff2?)$/i,
228 type: 'asset/resource',
229 generator: {
230 filename: 'fonts/[name].[contenthash:8][ext]',
231 },
232 },
233 ],
234 },
235 plugins: [
236 new ProgressPlugin({
237 activeModules: true,
238 }),
239 new webpack.ProvidePlugin({ // for htmx extensions
240 htmx: 'htmx.org',
241 }),
242 new DefinePlugin({
243 __VUE_OPTIONS_API__: true, // at the moment, many Vue components still use the Vue Options API
244 __VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production
245 __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // https://github.com/vuejs/vue-cli/pull/7443
246 }),
247 new VueLoaderPlugin(),
248 new MiniCssExtractPlugin({
249 filename: 'css/[name].css',
250 chunkFilename: 'css/[name].[contenthash:8].css',
251 }),
252 sourceMaps !== 'false' && new SourceMapDevToolPlugin({
253 filename: '[file].[contenthash:8].map',
254 ...(sourceMaps === 'reduced' && {include: /^js\/index\.js$/}),
255 }),
256 new MonacoWebpackPlugin({
257 filename: 'js/monaco-[name].[contenthash:8].worker.js',
258 }),
259 ],
260 performance: {
261 hints: false,
262 maxEntrypointSize: Infinity,
263 maxAssetSize: Infinity,
264 },
265 resolve: {
266 symlinks: false,
267 },
268 watchOptions: {
269 ignored: [
270 'node_modules/**',
271 ],
272 },
273 stats: {
274 assetsSort: 'name',
275 assetsSpace: Infinity,
276 cached: false,
277 cachedModules: false,
278 children: false,
279 chunkModules: false,
280 chunkOrigins: false,
281 chunksSort: 'name',
282 colors: true,
283 entrypoints: false,
284 excludeAssets: [
285 /^js\/monaco-language-.+\.js$/,
286 !isProduction && /^licenses.txt$/,
287 ].filter(Boolean),
288 groupAssetsByChunk: false,
289 groupAssetsByEmitStatus: false,
290 groupAssetsByInfo: false,
291 groupModulesByAttributes: false,
292 modules: false,
293 reasons: false,
294 runtimeModules: false,
295 },
296};