diff --git a/packages/create-plugin/src/commands/generate.command.ts b/packages/create-plugin/src/commands/generate.command.ts index 094c84b70..44a677633 100644 --- a/packages/create-plugin/src/commands/generate.command.ts +++ b/packages/create-plugin/src/commands/generate.command.ts @@ -133,6 +133,17 @@ function getActionsForTemplateFolder({ files = files.filter((file) => path.basename(file) !== 'npmrc'); } + // filter out frontend bundler based on user choice + files = files.filter((file) => { + if (file.includes('webpack') && templateData.useExperimentalRspack) { + return false; + } + if (file.includes('rspack') && !templateData.useExperimentalRspack) { + return false; + } + return true; + }); + function getFileExportPath(f: string) { return path.relative(folderPath, path.dirname(f)); } diff --git a/packages/create-plugin/src/commands/update.standard.command.ts b/packages/create-plugin/src/commands/update.standard.command.ts index 30393abd5..4841ccee1 100644 --- a/packages/create-plugin/src/commands/update.standard.command.ts +++ b/packages/create-plugin/src/commands/update.standard.command.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import { UDPATE_CONFIG } from '../constants.js'; +import { getConfig } from '../utils/utils.config.js'; import { printBlueBox, printRedBox } from '../utils/utils.console.js'; import { getOnlyExistingInCwd, removeFilesInCwd } from '../utils/utils.files.js'; import { updateGoSdkAndModules } from '../utils/utils.goSdk.js'; @@ -23,6 +24,10 @@ export const standardUpdate = async () => { await updateGoSdkAndModules(process.cwd()); const filesToRemove = getOnlyExistingInCwd(UDPATE_CONFIG.filesToRemove); + + if (Boolean(getConfig().features.useExperimentalRspack)) { + filesToRemove.push('./config/webpack'); + } if (filesToRemove.length) { removeFilesInCwd(filesToRemove); } diff --git a/packages/create-plugin/src/constants.ts b/packages/create-plugin/src/constants.ts index df03b80d3..73a714869 100644 --- a/packages/create-plugin/src/constants.ts +++ b/packages/create-plugin/src/constants.ts @@ -47,6 +47,7 @@ export const DEFAULT_FEATURE_FLAGS = { useReactRouterV6: true, bundleGrafanaUI: false, usePlaywright: true, + useExperimentalRspack: false, }; export const GRAFANA_FE_PACKAGES = [ diff --git a/packages/create-plugin/src/types.ts b/packages/create-plugin/src/types.ts index 6a0fc7f1a..04c9a17ef 100644 --- a/packages/create-plugin/src/types.ts +++ b/packages/create-plugin/src/types.ts @@ -26,5 +26,7 @@ export type TemplateData = { reactRouterVersion: string; usePlaywright: boolean; useCypress: boolean; + useExperimentalRspack: boolean; pluginExecutable?: string; + frontendBundler: 'webpack' | 'rspack'; }; diff --git a/packages/create-plugin/src/utils/utils.config.ts b/packages/create-plugin/src/utils/utils.config.ts index 37d3eac9a..3b36c9b70 100644 --- a/packages/create-plugin/src/utils/utils.config.ts +++ b/packages/create-plugin/src/utils/utils.config.ts @@ -14,6 +14,7 @@ export type FeatureFlags = { // (Attention! We always scaffold new projects with React Router v6, so if you are changing this to `false` manually you will need to make changes to the React code as well.) useReactRouterV6?: boolean; usePlaywright?: boolean; + useExperimentalRspack?: boolean; }; export type CreatePluginConfig = UserConfig & { diff --git a/packages/create-plugin/src/utils/utils.templates.ts b/packages/create-plugin/src/utils/utils.templates.ts index 7e4fee1f3..2676af13b 100644 --- a/packages/create-plugin/src/utils/utils.templates.ts +++ b/packages/create-plugin/src/utils/utils.templates.ts @@ -106,6 +106,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { const getReactRouterVersion = (pluginType: string) => (shouldUseReactRouterV6(pluginType) ? '6.22.0' : '5.2.0'); const isAppType = (pluginType: string) => pluginType === PLUGIN_TYPES.app || pluginType === PLUGIN_TYPES.scenes; const isNPM = (packageManagerName: string) => packageManagerName === 'npm'; + const frontendBundler = features.useExperimentalRspack ? 'rspack' : 'webpack'; let templateData: TemplateData; @@ -132,6 +133,8 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { reactRouterVersion: getReactRouterVersion(cliArgs.pluginType), usePlaywright, useCypress, + useExperimentalRspack: Boolean(features.useExperimentalRspack), + frontendBundler, }; // Updating or migrating a plugin // (plugin.json and package.json files are only present if it's an existing plugin) @@ -158,6 +161,8 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { usePlaywright, useCypress, pluginExecutable: pluginJson.executable, + useExperimentalRspack: Boolean(features.useExperimentalRspack), + frontendBundler, }; } diff --git a/packages/create-plugin/templates/common/.config/rspack/BuildModeRspackPlugin.ts b/packages/create-plugin/templates/common/.config/rspack/BuildModeRspackPlugin.ts new file mode 100644 index 000000000..ef215063f --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/BuildModeRspackPlugin.ts @@ -0,0 +1,36 @@ +import * as webpack from 'webpack'; + +const PLUGIN_NAME = 'BuildModeRspackPlugin'; + +export class BuildModeRspackPlugin { + apply(compiler: webpack.Compiler) { + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + (assets) => { + const assetName = 'plugin.json'; + const asset = assets[assetName]; + if (!asset) { + return; + } + + const { RawSource } = compiler.webpack.sources; + const pluginJsonContent = JSON.parse(asset.source().toString()); + const pluginJsonWithBuildMode = JSON.stringify( + { + ...pluginJsonContent, + buildMode: compilation.options.mode, + }, + null, + 4 + ); + const source = new RawSource(pluginJsonWithBuildMode); + compilation.updateAsset(assetName, source); + } + ); + }); + } +} diff --git a/packages/create-plugin/templates/common/.config/rspack/constants.ts b/packages/create-plugin/templates/common/.config/rspack/constants.ts new file mode 100644 index 000000000..071e4fd34 --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/constants.ts @@ -0,0 +1,2 @@ +export const SOURCE_DIR = 'src'; +export const DIST_DIR = 'dist'; diff --git a/packages/create-plugin/templates/common/.config/rspack/liveReloadPlugin.js b/packages/create-plugin/templates/common/.config/rspack/liveReloadPlugin.js new file mode 100644 index 000000000..c6cf523d9 --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/liveReloadPlugin.js @@ -0,0 +1,110 @@ +const path = require('path'); +const WebSocket = require('ws'); +const http = require('http'); + +class RspackLiveReloadPlugin { + constructor(options = {}) { + this.options = Object.assign( + { + port: 35729, + delay: 0, + appendScriptTag: true, + protocol: 'http', + }, + options + ); + } + + apply(compiler) { + const isRspack = compiler.rspack !== undefined; + if (!isRspack) { + throw new Error('This plugin is designed to work with Rspack 1'); + } + + compiler.hooks.afterEmit.tap('RspackLiveReloadPlugin', (compilation) => { + this._startServer(); + this._notifyClient(); + }); + + compiler.hooks.done.tap('RspackLiveReloadPlugin', (stats) => { + if (this.options.appendScriptTag) { + this._injectLiveReloadScript(stats.compilation); + } + }); + } + + _startServer() { + if (this.server) { + return; + } + + const port = this.options.port; + + this.httpServer = http.createServer((req, res) => { + if (req.url === '/livereload.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(this._getLiveReloadScript()); + } else { + res.writeHead(404); + res.end('Not Found'); + } + }); + + this.server = new WebSocket.Server({ server: this.httpServer }); + this.httpServer.listen(port, () => { + console.log(`LiveReload server started on http://localhost:${port}`); + }); + } + + _notifyClient() { + if (!this.server) { + return; + } + + this.server.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify({ action: 'reload' })); + } + }); + } + + _injectLiveReloadScript(compilation) { + compilation.hooks.processAssets.tap( + { + name: 'RspackLiveReloadPlugin', + stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + (assets) => { + Object.keys(assets).forEach((filename) => { + if (path.extname(filename) === '.html') { + const assetSource = compilation.getAsset(filename).source; + const updatedSource = assetSource + .source() + .replace('', ``); + compilation.updateAsset(filename, { + source: () => updatedSource, + size: () => updatedSource.length, + }); + } + }); + } + ); + } + + _getLiveReloadScript() { + return ` + (function() { + if (typeof WebSocket === 'undefined') return; + const ws = new WebSocket('${this.options.protocol}://localhost:${this.options.port}'); + ws.onmessage = function(event) { + const data = JSON.parse(event.data); + if (data.action === 'reload') { + window.location.reload(); + } + }; + })(); + `; + } +} + +module.exports = RspackLiveReloadPlugin; diff --git a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts new file mode 100644 index 000000000..79566c9e2 --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts @@ -0,0 +1,271 @@ +/* + * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ + * + * In order to extend the configuration follow the steps in + * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-webpack-config + */ + +import rspack, { type Configuration } from '@rspack/core'; +import ESLintPlugin from 'eslint-webpack-plugin'; +import { TsCheckerRspackPlugin } from 'ts-checker-rspack-plugin'; +import path from 'path'; +import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; +import TerserPlugin from 'terser-webpack-plugin'; +import { RspackVirtualModulePlugin } from 'rspack-plugin-virtual-module'; +import RspackLiveReloadPlugin from './liveReloadPlugin'; +import { BuildModeRspackPlugin } from './BuildModeRspackPlugin'; +import { DIST_DIR, SOURCE_DIR } from './constants'; +import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, hasReadme, isWSL } from './utils'; + +const { SubresourceIntegrityPlugin } = rspack.experiments; +const pluginJson = getPluginJson(); +const cpVersion = getCPConfigVersion(); + +const virtualPublicPath = new RspackVirtualModulePlugin({ + 'grafana-public-path': ` +import amdMetaModule from 'amd-module'; + +__webpack_public_path__ = + amdMetaModule && amdMetaModule.uri + ? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1) + : 'public/plugins/${pluginJson.id}/'; +`, +}); + +const config = async (env): Promise => { + const baseConfig: Configuration = { + context: path.join(process.cwd(), SOURCE_DIR), + + devtool: env.production ? 'source-map' : 'eval-source-map', + + entry: await getEntries(), + + externals: [ + // Required for dynamic publicPath resolution + { 'amd-module': 'module' }, + 'lodash', + 'jquery', + 'moment', + 'slate', + 'emotion', + '@emotion/react', + '@emotion/css', + 'prismjs', + 'slate-plain-serializer', + '@grafana/slate-react', + 'react', + 'react-dom', + 'react-redux', + 'redux', + 'rxjs', + 'react-router',{{#unless useReactRouterV6}} + 'react-router-dom',{{/unless}} + 'd3', + 'angular',{{#unless bundleGrafanaUI}} + '@grafana/ui',{{/unless}} + '@grafana/runtime', + '@grafana/data',{{#if bundleGrafanaUI}} + 'react-inlinesvg', + 'i18next',{{/if}} + + // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix + //@ts-ignore - rspack types seem to be a bit broken here. + ({ request }, callback) => { + const prefix = 'grafana/'; + const hasPrefix = (request) => request.indexOf(prefix) === 0; + const stripPrefix = (request) => request.substr(prefix.length); + + if (hasPrefix(request)) { + return callback(undefined, stripPrefix(request)); + } + + callback(); + }, + ], + + // Support WebAssembly according to latest spec - makes WebAssembly module async + experiments: { + asyncWebAssembly: true, + }, + + mode: env.production ? 'production' : 'development', + + module: { + rules: [ + // This must come first in the rules array otherwise it breaks sourcemaps. + { + test: /src\/(?:.*\/)?module\.tsx?$/, + use: [ + { + loader: 'imports-loader', + options: { + imports: `side-effects grafana-public-path`, + }, + }, + ], + }, + { + exclude: /(node_modules)/, + test: /\.[tj]sx?$/, + use: { + loader: 'builtin:swc-loader', + options: { + jsc: { + externalHelpers: true, + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + development: !env.production, + refresh: false, + }, + }, + }, + env: { + target: 'es2020', + }, + }, + }, + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + { + test: /\.s[ac]ss$/, + use: ['style-loader', 'css-loader', 'sass-loader'], + }, + { + test: /\.(png|jpe?g|gif|svg)$/, + type: 'asset/resource', + generator: { + filename: Boolean(env.production) ? '[hash][ext]' : '[file]', + }, + }, + { + test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, + type: 'asset/resource', + generator: { + filename: Boolean(env.production) ? '[hash][ext]' : '[file]', + }, + }, + ], + }, + + optimization: { + minimize: Boolean(env.production), + minimizer: [ + new TerserPlugin({ + terserOptions: { + format: { + comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'), + }, + compress: { + drop_console: ['log', 'info'], + }, + }, + }), + ], + }, + + output: { + filename: '[name].js', + chunkFilename: env.production ? '[name].js?_cache=[contenthash]' : '[name].js', + library: { + type: 'amd', + }, + path: path.resolve(process.cwd(), DIST_DIR), + publicPath: `public/plugins/${pluginJson.id}/`, + uniqueName: pluginJson.id, + crossOriginLoading: 'anonymous', + }, + + plugins: [ + new BuildModeRspackPlugin(), + virtualPublicPath, + // Insert create plugin version information into the bundle + new rspack.BannerPlugin({ + banner: '/* [create-plugin] version: ' + cpVersion + ' */', + raw: true, + entryOnly: true, + }), + new rspack.CopyRspackPlugin({ + patterns: [ + // If src/README.md exists use it; otherwise the root README + // To `compiler.options.output` + { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, + { from: 'plugin.json', to: '.' }, + { from: '../LICENSE', to: '.' }, + { from: '../CHANGELOG.md', to: '.', force: true }, + { from: '**/*.json', to: '.' }, // TODO + { from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional + { from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional + { from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional + { from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional + { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional + { from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional + { from: '**/query_help.md', to: '.', noErrorOnMissing: true }, // Optional + ], + }), + // Replace certain template-variables in the README and plugin.json + new ReplaceInFileWebpackPlugin([ + { + dir: DIST_DIR, + files: ['plugin.json', 'README.md'], + rules: [ + { + search: /\%VERSION\%/g, + replace: getPackageJson().version, + }, + { + search: /\%TODAY\%/g, + replace: new Date().toISOString().substring(0, 10), + }, + { + search: /\%PLUGIN_ID\%/g, + replace: pluginJson.id, + }, + ], + }, + ]), + new SubresourceIntegrityPlugin({ + hashFuncNames: ["sha256"], + }), + ...(env.development + ? [ + new RspackLiveReloadPlugin(), + new TsCheckerRspackPlugin({ + async: Boolean(env.development), + issue: { + include: [{ file: '**/*.{ts,tsx}' }], + }, + typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, + }), + new ESLintPlugin({ + extensions: ['.ts', '.tsx'], + lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files + }), + ] + : []), + ], + + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + // handle resolving "rootDir" paths + modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], + }, + }; + + if (isWSL()) { + baseConfig.watchOptions = { + poll: 3000, + ignored: /node_modules/, + }; + } + + return baseConfig; +}; + +export default config; diff --git a/packages/create-plugin/templates/common/.config/rspack/utils.ts b/packages/create-plugin/templates/common/.config/rspack/utils.ts new file mode 100644 index 000000000..015aa0510 --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/utils.ts @@ -0,0 +1,63 @@ +import fs from 'fs'; +import process from 'process'; +import os from 'os'; +import path from 'path'; +import { glob } from 'glob'; +import { SOURCE_DIR } from './constants'; + +export function isWSL() { + if (process.platform !== 'linux') { + return false; + } + + if (os.release().toLowerCase().includes('microsoft')) { + return true; + } + + try { + return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); + } catch { + return false; + } +} + +export function getPackageJson() { + return require(path.resolve(process.cwd(), 'package.json')); +} + +export function getPluginJson() { + return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); +} + +export function getCPConfigVersion() { + const cprcJson = path.resolve(__dirname, '../', '.cprc.json'); + return fs.existsSync(cprcJson) ? require(cprcJson).version : { version: 'unknown' }; +} + +export function hasReadme() { + return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); +} + +// Support bundling nested plugins by finding all plugin.json files in src directory +// then checking for a sibling module.[jt]sx? file. +export async function getEntries(): Promise> { + const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); + + const plugins = await Promise.all( + pluginsJson.map((pluginJson) => { + const folder = path.dirname(pluginJson); + return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); + }) + ); + + return plugins.reduce((result, modules) => { + return modules.reduce((result, module) => { + const pluginPath = path.dirname(module); + const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); + const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; + + result[entryName] = module; + return result; + }, result); + }, {}); +} diff --git a/packages/create-plugin/templates/common/.cprc.json b/packages/create-plugin/templates/common/.cprc.json index bc6ba9919..1924c589e 100644 --- a/packages/create-plugin/templates/common/.cprc.json +++ b/packages/create-plugin/templates/common/.cprc.json @@ -1,6 +1,7 @@ { "features": { "bundleGrafanaUI": {{ bundleGrafanaUI }}, - "useReactRouterV6": {{ useReactRouterV6 }} + "useReactRouterV6": {{ useReactRouterV6 }}, + "useExperimentalRspack": {{ useExperimentalRspack }} } } diff --git a/packages/create-plugin/templates/common/_package.json b/packages/create-plugin/templates/common/_package.json index ddf755231..8106504ae 100644 --- a/packages/create-plugin/templates/common/_package.json +++ b/packages/create-plugin/templates/common/_package.json @@ -2,8 +2,8 @@ "name": "{{ kebabCase pluginName }}", "version": "1.0.0", "scripts": { - "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", - "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", + "build": "{{frontendBundler}} -c ./.config/{{frontendBundler}}/{{frontendBundler}}.config.ts --env production", + "dev": "{{frontendBundler}} -w -c ./.config/{{frontendBundler}}/{{frontendBundler}}.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --passWithNoTests --maxWorkers 4", "typecheck": "tsc --noEmit", @@ -23,7 +23,9 @@ "@grafana/eslint-config": "^8.0.0",{{#if usePlaywright}} "@grafana/plugin-e2e": "^1.17.1",{{/if}} "@grafana/tsconfig": "^2.0.0",{{#if usePlaywright}} - "@playwright/test": "^1.41.2",{{/if}} + "@playwright/test": "^1.41.2",{{/if}}{{#if useExperimentalRspack}} + "@rspack/core": "^1.2.4", + "@rspack/cli": "^1.2.4",{{/if}} "@stylistic/eslint-plugin-ts": "^2.9.0", "@swc/core": "^1.3.90", "@swc/helpers": "^0.5.0", @@ -35,8 +37,8 @@ "@types/react-router-dom": "^{{ reactRouterVersion }}",{{/unless}}{{/if}} "@types/testing-library__jest-dom": "5.14.8", "@typescript-eslint/eslint-plugin": "^6.18.0", - "@typescript-eslint/parser": "^6.18.0", - "copy-webpack-plugin": "^11.0.0", + "@typescript-eslint/parser": "^6.18.0",{{#unless useExperimentalRspack}} + "copy-webpack-plugin": "^11.0.0",{{/unless}} "css-loader": "^6.7.3", "eslint": "^8.0.0", "eslint-config-prettier": "^8.8.0", @@ -44,28 +46,30 @@ "eslint-plugin-react": "^7.33.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-deprecation": "^2.0.0", - "eslint-webpack-plugin": "^4.0.1", - "fork-ts-checker-webpack-plugin": "^8.0.0", + "eslint-webpack-plugin": "^4.0.1",{{#unless useExperimentalRspack}} + "fork-ts-checker-webpack-plugin": "^8.0.0",{{/unless}} "glob": "^10.2.7", "identity-obj-proxy": "3.0.0", "imports-loader": "^5.0.0", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "prettier": "^2.8.7", - "replace-in-file-webpack-plugin": "^1.0.6", + "replace-in-file-webpack-plugin": "^1.0.6",{{#if useExperimentalRspack}} + "rspack-plugin-virtual-module": "^0.1.13",{{/if}} "sass": "1.63.2", "sass-loader": "13.3.1",{{#if usePlaywright}} "semver": "^7.6.3",{{/if}} - "style-loader": "3.3.3", - "swc-loader": "^0.2.3", + "style-loader": "3.3.3",{{#unless useExperimentalRspack}} + "swc-loader": "^0.2.3",{{/unless}} "terser-webpack-plugin": "^5.3.10", - "ts-node": "^10.9.2", + "ts-node": "^10.9.2",{{#if useExperimentalRspack}} + "ts-checker-rspack-plugin": "^1.0.0",{{/if}} "typescript": "5.5.4", - "webpack": "^5.94.0", + "webpack": "^5.94.0"{{#unless useExperimentalRspack}}, "webpack-cli": "^5.1.4", "webpack-livereload-plugin": "^3.0.2", "webpack-subresource-integrity": "^5.1.0", - "webpack-virtual-modules": "^0.6.2" + "webpack-virtual-modules": "^0.6.2"{{/unless}} }, "engines": { "node": ">=22"