diff --git a/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md b/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md index 73d9e44de4..596441b10a 100644 --- a/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md +++ b/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md @@ -105,6 +105,29 @@ Instructions ask the agent to mark anything it couldn't apply with a `TODO( { expect(defaultAdditions.length).toBeGreaterThan(0); }); + it('should register rspack as a hybrid addition', () => { + const rspack = defaultAdditions.find((addition) => addition.name === 'rspack'); + + expect(rspack).toBeDefined(); + expect(isScriptAddition(rspack!)).toBe(true); + expect(hasPromptStep(rspack!)).toBe(true); + expect(rspack!.verify).toEqual(['typecheck', 'build']); + }); + defaultAdditions.forEach((addition) => { it(`should declare a script path or a prompt for ${addition.name}`, () => { expect(isScriptAddition(addition) || hasPromptStep(addition)).toBe(true); diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 7018dd6cee..2725edf503 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -43,6 +43,13 @@ const additions: Addition[] = [ description: 'Externalizes the react JSX runtime to help migrate plugins to React 19', scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'), }, + { + name: 'rspack', + description: 'Migrates the plugin frontend build from webpack or experimental rspack to rspack 2', + scriptPath: import.meta.resolve('./scripts/rspack.js'), + prompt: import.meta.resolve('./prompts/rspack.md'), + verify: ['typecheck', 'build'], + }, { name: 'experimental-app-sdk', description: 'Adds grafana-app-sdk CUE kind code generation to an app plugin', diff --git a/packages/create-plugin/src/codemods/additions/prompts/rspack.md b/packages/create-plugin/src/codemods/additions/prompts/rspack.md new file mode 100644 index 0000000000..fe76fd8718 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/prompts/rspack.md @@ -0,0 +1,118 @@ +# Migrate the plugin's custom build configuration to rspack + +## Context + +This plugin is moving its frontend build to rspack 2. The `create-plugin add rspack` codemod has already done the parts that create-plugin owns: + +- Rendered the rspack base config in `.config/rspack/` and the shared helpers in `.config/bundler/`, and set `useExperimentalRspack` in `.config/.cprc.json`. +- Deleted the create-plugin files in `.config/webpack/`. It kept any file there that the plugin added and listed it in its output. +- Added the rspack packages (`@rspack/core`, `@rspack/cli`, `rspack-merge`, `eslint-rspack-plugin` and others) and removed webpack-only packages. It kept any webpack package that a root `webpack.*` or `rspack.*` file still imports and listed it. +- Rewrote `webpack` commands in `package.json` scripts to `rspack`. It listed any script it could not rewrite. +- If the plugin has a root `webpack.config.*`, added a root `rspack.config.ts` that throws an `[rspack]` error until the custom config is ported. +- Raised `engines.node` to `>=22.23` if it allowed older versions. + +What is left is the plugin's own build customisation, which needs judgement to port. The previous base config is still in git: `git show HEAD:.config/webpack/webpack.config.ts`. Compare it with `.config/rspack/rspack.config.ts` when you need to know how a base rule or plugin changed. + +## Check first + +1. Install dependencies with the plugin's package manager, then run the `build` script. Keep the output; it is evidence, not a to-do list. +2. Stop and report that nothing was needed if all of these are true: + - The build passes. + - There is no root `webpack.config.*` file. + - Root `rspack.config.ts`, if present, does not contain the `[rspack]` error. + - No script in `package.json` calls `webpack`. + - No plugin code builds a runtime URL to a file the new base config no longer copies into `dist` (see step 6). +3. Otherwise, work out the starting point: + - **From webpack**: there is a root `webpack.config.*`, or `rspack.config.ts` contains the `[rspack]` error. Follow all the steps below. + - **From experimental rspack**: there is a root `rspack.config.ts` without the `[rspack]` error, and there is no root `webpack.config.*`. The config is already rspack but may use rspack 1 APIs. Skip step 2 and upgrade that file in place using steps 3 to 7. + - **No custom config**: there is neither a root `webpack.config.*` nor a root `rspack.config.ts`. Only step 6 applies. + +With a custom config, the build is expected to fail at this point with the `[rspack]` error. Do not chase build errors one by one; the steps below drive the work. + +## Steps + +1. Find every file that is part of the custom build: the root `webpack.config.*`, any file it imports (for example `webpack.config.utils.ts`), and any file the codemod reported as kept in `.config/webpack/`. + +2. Replace the body of root `rspack.config.ts` with a port of the root webpack config: + - Import the base config with `import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts';`. + - Merge with `rspack-merge` (`merge`, `mergeWithRules`, `mergeWithCustomize`, `unique`). It has the same API as `webpack-merge`, typed for rspack. Await the base config before merging. + - Keep the structure, comments and behaviour of the original. Port it; do not improve it. + - Remove the `[rspack]` error and the `TODO(rspack)` comment above it only when the port is complete. + +3. Replace webpack APIs and packages with their rspack equivalents. Import from `@rspack/core` with named imports, for example `import { DefinePlugin, NormalModuleReplacementPlugin } from '@rspack/core';`. + + | webpack | rspack | + | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | + | `webpack` built-in plugins (`DefinePlugin`, `EnvironmentPlugin`, `BannerPlugin`, `ProvidePlugin`, `NormalModuleReplacementPlugin`, `ContextReplacementPlugin`) and `Compilation` | the same names from `@rspack/core` | + | `webpack-merge` | `rspack-merge` | + | `copy-webpack-plugin` | `CopyRspackPlugin` from `@rspack/core` | + | `swc-loader`, `babel-loader` | `builtin:swc-loader` | + | `fork-ts-checker-webpack-plugin` | `TsCheckerRspackPlugin` from `ts-checker-rspack-plugin` | + | `eslint-webpack-plugin` | `eslint-rspack-plugin` (it has no `failOnError`; use `severity`) | + | `webpack-livereload-plugin` | already in the base config for development builds | + | `mini-css-extract-plugin` | `CssExtractRspackPlugin` from `@rspack/core` | + | `css-minimizer-webpack-plugin` | `LightningCssMinimizerRspackPlugin` from `@rspack/core` | + | `terser-webpack-plugin` added by the plugin | `SwcJsMinimizerRspackPlugin` from `@rspack/core`, or keep terser | + | `html-webpack-plugin` | `HtmlRspackPlugin` from `@rspack/core` | + | `tsconfig-paths-webpack-plugin` | the `resolve.tsConfig` option | + | `file-loader`, `url-loader`, `raw-loader` | asset modules: `type: 'asset/resource'`, `'asset/inline'`, `'asset/source'` | + | plugins built on unplugin (`x/webpack`) | the `x/rspack` entry point, where one exists | + | imports from `.config/webpack/utils` or `.config/webpack/constants` | `.config/bundler/utils.ts` and `.config/bundler/constants.ts` | + + Check the [rspack migration guide](https://rspack.rs/guide/migration/webpack) for anything not listed here. + +4. Fix code that depends on how the base config is built. Each of these works in webpack and silently does nothing, or throws, in rspack: + - **Looking up a rule by loader name**, such as `rule.use.loader === 'swc-loader'`. The base config uses `builtin:swc-loader`, so match that. Prefer merging a rule over mutating the base one. + - **Looking up a plugin with `instanceof` or `constructor.name`** against webpack plugin classes, such as `CopyWebpackPlugin` or `LiveReloadPlugin`. Use the rspack class names, or merge instead of mutating. + - **`webpack-merge` rules keyed on old names**: `unique('plugins', ['CopyPlugin'], …)` or `mergeWithRules` matching `use.loader: 'swc-loader'`. Update the names. + - **A `filter` function on a `CopyWebpackPlugin` pattern**. `CopyRspackPlugin` has no `filter`; use `globOptions: { ignore: [...] }`. The base copy patterns come from `.config/bundler/copyFiles.ts`. + - **Removing the live reload plugin by filtering the plugins array**. The rspack base only adds it in development; filter on the rspack plugin class. + - **Native ESM loading**: the rspack CLI loads `rspack.config.ts` as native ESM. Replace `__dirname` with `import.meta.dirname`, `require.resolve(x)` with `createRequire(import.meta.url).resolve(x)`, and `require(x)` with `import`. Give relative imports explicit `.ts` extensions. `ReferenceError: __dirname is not defined` at build time means this step is not done. + - **rspack 2 defaults**: imports of exports that do not exist now fail the build (`exportsPresence: 'error'`). Fix the import in the plugin's code if it is clearly wrong; otherwise report it rather than changing the setting. + - **rspack 2 option moves**: `experiments.cache`, `experiments.incremental` and `experiments.lazyCompilation` are now top level. `experiments.css` is replaced by `module.rules[].type: 'css' | 'css/module' | 'css/auto'`. `experiments.outputModule` is `output.module`. `output.libraryTarget`, `libraryExport` and `umdNamedDefine` move under `output.library`. `SubresourceIntegrityPlugin` is no longer under `experiments`. + - **Persistent cache**: the base config has none. If the webpack config set `cache.type: 'filesystem'`, drop it unless the plugin clearly needs it. + +5. Check whether the config adds a second transpiler for the plugin's own code. A rule that runs `esbuild-loader`, `babel-loader` or `ts-loader` on `.ts`, `.tsx`, `.js` or `.jsx` files outside `node_modules` overlaps with the base `builtin:swc-loader` rule. Every matching rule applies, so each file is transpiled twice, and the build still passes, so nothing flags it. + - Do not port the extra rule as it is. + - If the base `builtin:swc-loader` settings cover what the rule did, leave the rule out of `rspack.config.ts` and remove its loader package once nothing else uses it. + - If the rule does something the base config does not, for example a different JSX runtime or decorator support, move that setting into the `builtin:swc-loader` rule (see step 4) and leave the extra rule out. + - If you cannot tell, keep the rule with a `TODO(rspack):` comment above it explaining that it duplicates the base transpiler. + - Either way, name the rule in your report. + +6. Check for files the old base config copied into `dist` that the new one does not. The old base copied every `img/**`, `**/*.svg`, `**/*.png`, `**/*.html`, `libs/**` and `static/**` file; compare `git show HEAD:.config/webpack/webpack.config.ts` with `.config/bundler/copyFiles.ts`. The new base copies only `plugin.json` and other JSON files, the README, CHANGELOG and LICENSE, `query_help.md`, and the logos and screenshots listed in `plugin.json`. Code that builds a URL to one of the other files at runtime, for example `public/plugins//img/…` or `` `${pluginPublicPath}/img/how-it-works.svg` ``, now gets a 404. The build still passes, so nothing flags it. + - Where the path is a literal, import the file and use the imported URL, for example `import howItWorks from '../img/how-it-works.svg';` and `src={howItWorks}`. rspack then hashes the file and resolves its public path, including when the plugin is served from a CDN. + - Check what an import returns before converting. If the config sends `.svg` imports to `@svgr/webpack` or another loader, the import is a React component, not a URL. Use that loader's URL form if it has one, such as a `?url` resource query, or copy the file instead. + - The base asset rules only cover `png`, `jpg`, `jpeg`, `gif`, `svg` and font files. For other types, such as `webp`, add an `asset/resource` rule in `rspack.config.ts`. + - Copy the file instead of importing it when the path is built at runtime, for example `` `${base}/img/${name}.svg` ``, or when something outside the bundle refers to it, such as the README, documentation or another plugin. Add a `CopyRspackPlugin` to `rspack.config.ts` for just those files, with a comment saying why they are copied. Do not copy whole folders to be safe: every copied file ends up in the plugin archive. + +7. If something has no rspack equivalent, for example a monkey-patch of webpack internals, a custom `RuntimeModule` plugin, or an SWC Wasm plugin whose ABI does not match rspack's built-in SWC: + - Port what you can. + - Comment out the rest, keeping the original code in the comment, so it does not run and the build does not fail. + - Put a `TODO(rspack):` comment above it that says what the code did, why it has no direct equivalent, and a suggested approach. + - Never drop it silently. + +8. Fix any script the codemod reported as not rewritten so it calls `rspack` with `./rspack.config.ts` or `./.config/rspack/rspack.config.ts`, keeping its other commands and flags. Remove the CLI flags rspack does not support: `--progress`, `--color`, `--bail`, `--output-pathinfo`. + +9. For each file the codemod kept in `.config/webpack/`, copy what is still needed to a folder outside `.config/` (for example `build/`), port it to rspack, and update the imports. Do not delete the originals; list them so the user can delete them. + +10. Remove the webpack packages the codemod kept, once nothing imports them. Keep `webpack` itself if anything still imports it. + +11. Delete the root `webpack.config.*` and its helper files once everything in them is ported or commented with `TODO(rspack):`. + +## Verify + +1. Install dependencies, then run the `typecheck` and `build` scripts. Both must pass, with any unportable code commented out. +2. Check that `dist/` contains `module.js`, `plugin.json`, and every file that plugin code or `plugin.json` refers to by path at runtime. Imported images are hashed, so their names change. +3. Run the `dev` script briefly and check that it compiles and keeps watching. +4. Fix only breakage caused by this migration. + +## Report + +End with a list of every `TODO(rspack):` comment you added (file, line, one-line reason), every file the user should delete, any duplicate transpiler rule you removed or kept (step 5), every image reference you changed to an import or kept as a copy (step 6), and any `exportsPresence` errors you left for the user. + +## Out of scope + +- Do not modify anything under `.config/`. It is managed by create-plugin and is overwritten by `create-plugin update`. +- Do not change the plugin id or type in `src/plugin.json`. +- Do not upgrade unrelated dependencies, and do not reformat files you did not change. +- Do not refactor plugin source code, unless the build fails because of the bundler change or step 6 needs an image import. diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts new file mode 100644 index 0000000000..867c000d32 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -0,0 +1,726 @@ +import { Context } from '../../context.js'; +import addRspack, { + BASE_CONFIG_DEV_DEPENDENCIES, + getTemplateDevDependencyVersions, + RSPACK_DEV_DEPENDENCIES, + WEBPACK_ONLY_DEV_DEPENDENCIES, +} from './rspack.js'; + +vi.mock(import('../../../utils/utils.plugin.js'), async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + getPluginJson: () => ({ + id: 'my-plugin-id', + name: 'My Plugin', + type: 'panel', + info: { author: { name: 'my-author' } }, + }), + }; +}); + +vi.mock(import('../../../utils/utils.config.js'), async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + getConfig: () => ({ version: '5.0.0', features: {} }), + }; +}); + +vi.mock(import('../../utils.js'), async (importOriginal) => { + const originalModule = await importOriginal(); + + // Only render externals.ts from the real template since we assert on its content. + // All other templates just need a non-empty stub. + return { + ...originalModule, + renderTemplate: (path: string, includeWarning?: boolean, templateDataOverrides?: Record) => { + if (path.includes('.config/bundler/externals.ts')) { + return originalModule.renderTemplate(path, includeWarning, templateDataOverrides); + } + return '// rendered template stub'; + }, + }; +}); + +const RSPACK_TEMPLATE_DATA = { useExperimentalRspack: true, frontendBundler: 'rspack' }; +const WEBPACK_TEMPLATE_DATA = { useExperimentalRspack: false, frontendBundler: 'webpack' }; + +async function getTemplateDevDependencies(templateData: Record): Promise> { + const { renderTemplate } = await vi.importActual('../../utils.js'); + const packageJsonTemplatePath = new URL('../../../../templates/common/_package.json', import.meta.url).pathname; + const { devDependencies } = JSON.parse(renderTemplate(packageJsonTemplatePath, false, templateData)); + return devDependencies; +} + +function createBaseContext(): Context { + const context = new Context('/virtual'); + + context.addFile('.config/webpack/webpack.config.ts', ''); + context.addFile('.config/webpack/BuildModeWebpackPlugin.ts', ''); + context.addFile('.config/bundler/externals.ts', ''); + context.addFile('.config/bundler/constants.ts', ''); + context.addFile('.config/bundler/copyFiles.ts', ''); + context.addFile('.config/bundler/utils.ts', ''); + + context.addFile( + 'package.json', + JSON.stringify( + { + scripts: { + build: 'webpack -c ./.config/webpack/webpack.config.ts --env production', + dev: 'webpack -w -c ./.config/webpack/webpack.config.ts --env development', + }, + devDependencies: { + 'copy-webpack-plugin': '^12.0.0', + 'fork-ts-checker-webpack-plugin': '^9.0.0', + 'swc-loader': '^0.2.0', + webpack: '^5.94.0', + 'webpack-cli': '^5.1.4', + 'webpack-livereload-plugin': '^3.0.2', + 'webpack-subresource-integrity': '^5.1.0', + 'webpack-virtual-modules': '^0.6.2', + }, + }, + null, + 2 + ) + ); + context.addFile('.config/.cprc.json', JSON.stringify({ version: '5.0.0', features: {} }, null, 2)); + return context; +} + +describe('rspack', () => { + // guards the explicit package lists against the template: fails when the rspack template gains, + // loses or renames a package the addition should manage. version bumps do not fail it + describe('package lists', () => { + it('should find a template version for every package the addition installs', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + + for (const name of [...RSPACK_DEV_DEPENDENCIES, ...BASE_CONFIG_DEV_DEPENDENCIES]) { + expect(rspackDeps[name], name).toBeDefined(); + } + }); + + it('should list every package only the rspack template has', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const webpackDeps = await getTemplateDevDependencies(WEBPACK_TEMPLATE_DATA); + const rspackOnly = Object.keys(rspackDeps).filter((name) => !(name in webpackDeps)); + + expect(RSPACK_DEV_DEPENDENCIES).toEqual(expect.arrayContaining(rspackOnly)); + }); + + it('should list every package only the webpack template has', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const webpackDeps = await getTemplateDevDependencies(WEBPACK_TEMPLATE_DATA); + const webpackOnly = Object.keys(webpackDeps).filter((name) => !(name in rspackDeps)); + + expect(WEBPACK_ONLY_DEV_DEPENDENCIES).toEqual(expect.arrayContaining(webpackOnly)); + }); + + it('should read the same versions as a render with the plugin template data', async () => { + expect(getTemplateDevDependencyVersions()).toEqual(await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA)); + }); + }); + + describe('start states', () => { + it('should make no changes and explain why when there is no create-plugin bundler setup', () => { + const context = new Context('/virtual'); + + const result = addRspack(context); + + expect(result.hasChanges()).toBe(false); + expect(result.getMessage()).toEqual( + expect.objectContaining({ level: 'warning', title: expect.stringContaining('bundler configuration') }) + ); + }); + + it('should re-render a stale .config/rspack left behind on a webpack plugin', () => { + const context = createBaseContext(); + context.addFile('.config/rspack/rspack.config.ts', 'stale rspack config'); + + const result = addRspack(context); + + expect(result.getFile('.config/rspack/rspack.config.ts')).toBe('// rendered template stub'); + expect(result.doesFileExist('.config/webpack/webpack.config.ts')).toBe(false); + }); + + it('should replace an experimental rspack setup with the current rspack templates', () => { + const context = new Context('/virtual'); + context.addFile('.config/rspack/rspack.config.ts', 'experimental rspack config'); + context.addFile('.config/rspack/BuildModeRspackPlugin.ts', 'experimental build mode plugin'); + context.addFile('.config/rspack/liveReloadPlugin.ts', 'experimental live reload plugin'); + context.addFile( + '.config/.cprc.json', + JSON.stringify({ version: '7.9.0', features: { useExperimentalRspack: true } }, null, 2) + ); + + const result = addRspack(context); + + expect(result.getFile('.config/rspack/rspack.config.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/rspack/BuildModeRspackPlugin.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/rspack/LiveReloadRspackPlugin.ts')).toBe('// rendered template stub'); + // renamed in the template, so the experimental setup's copy is removed + expect(result.doesFileExist('.config/rspack/liveReloadPlugin.ts')).toBe(false); + }); + }); + + describe('.cprc.json', () => { + it('should update existing .cprc.json with useExperimentalRspack flag', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const cprc = JSON.parse(result.getFile('.config/.cprc.json')!); + + expect(cprc.features.useExperimentalRspack).toBe(true); + }); + + it('should preserve existing .cprc.json properties', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const cprc = JSON.parse(result.getFile('.config/.cprc.json')!); + + expect(cprc.version).toBe('5.0.0'); + }); + }); + + describe('rspack config files', () => { + it('should add rspack config files', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + expect(result.doesFileExist('.config/rspack/rspack.config.ts')).toBe(true); + expect(result.doesFileExist('.config/rspack/BuildModeRspackPlugin.ts')).toBe(true); + expect(result.doesFileExist('.config/rspack/LiveReloadRspackPlugin.ts')).toBe(true); + expect(result.doesFileExist('.config/rspack/liveReloadPlugin.ts')).toBe(false); + expect(result.doesFileExist('.config/rspack/LicenseRspackPlugin.ts')).toBe(true); + }); + }); + + describe('docs for agents and developers', () => { + it('should re-render the agent instructions and .config README for rspack', () => { + const context = createBaseContext(); + context.addFile('.config/AGENTS/instructions.md', '**You must use webpack**'); + context.addFile('.config/README.md', '### Extending the Webpack config'); + + const result = addRspack(context); + + expect(result.getFile('.config/AGENTS/instructions.md')).toBe('// rendered template stub'); + expect(result.getFile('.config/README.md')).toBe('// rendered template stub'); + }); + }); + + describe('bundler files', () => { + it('should update externals.ts with rspack imports', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const externals = result.getFile('.config/bundler/externals.ts')!; + + expect(externals).toContain('RspackOptions'); + }); + + it('should re-render all bundler files', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + expect(result.getFile('.config/bundler/externals.ts')).toContain('RspackOptions'); + expect(result.getFile('.config/bundler/constants.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/bundler/copyFiles.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/bundler/utils.ts')).toBe('// rendered template stub'); + }); + + it('should add bundler files that do not already exist', () => { + const context = new Context('/virtual'); + context.addFile('.config/webpack/webpack.config.ts', 'webpack config'); + context.addFile( + 'package.json', + JSON.stringify({ scripts: { build: 'webpack', dev: 'webpack -w' }, devDependencies: {} }, null, 2) + ); + + const result = addRspack(context); + + expect(result.doesFileExist('.config/bundler/externals.ts')).toBe(true); + expect(result.doesFileExist('.config/bundler/constants.ts')).toBe(true); + expect(result.doesFileExist('.config/bundler/copyFiles.ts')).toBe(true); + expect(result.doesFileExist('.config/bundler/utils.ts')).toBe(true); + }); + }); + + describe('package.json', () => { + it('should add the rspack-only devDependencies from the template at the template versions', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const webpackDeps = await getTemplateDevDependencies(WEBPACK_TEMPLATE_DATA); + const rspackOnlyDeps = Object.keys(rspackDeps).filter((name) => !(name in webpackDeps)); + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(rspackOnlyDeps).toEqual(expect.arrayContaining(['@rspack/core', '@rspack/cli', 'eslint-rspack-plugin'])); + for (const name of rspackOnlyDeps) { + expect(pkg.devDependencies[name], name).toBe(rspackDeps[name]); + } + }); + + it('should add packages the rspack base config needs when the plugin has dropped them', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + // builtin:swc-loader runs with externalHelpers, so builds import @swc/helpers + expect(pkg.devDependencies['@swc/helpers']).toBe(rspackDeps['@swc/helpers']); + expect(pkg.devDependencies['imports-loader']).toBe(rspackDeps['imports-loader']); + }); + + it('should not change the versions of base config packages the plugin already has', () => { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.devDependencies['sass-loader'] = '^13.0.0'; + pkg.dependencies = { '@swc/helpers': '0.4.14' }; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + + const result = addRspack(context); + const updated = JSON.parse(result.getFile('package.json')!); + + expect(updated.devDependencies['sass-loader']).toBe('^13.0.0'); + expect(updated.dependencies['@swc/helpers']).toBe('0.4.14'); + expect(updated.devDependencies['@swc/helpers']).toBeUndefined(); + }); + + it('should upgrade rspack devDependencies that are below the template version', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.devDependencies['@rspack/core'] = '^1.6.0'; + pkg.devDependencies['@rspack/cli'] = '^1.6.0'; + pkg.devDependencies['ts-checker-rspack-plugin'] = '^1.2.0'; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + + const result = addRspack(context); + const updated = JSON.parse(result.getFile('package.json')!); + + expect(updated.devDependencies['@rspack/core']).toBe(rspackDeps['@rspack/core']); + expect(updated.devDependencies['@rspack/cli']).toBe(rspackDeps['@rspack/cli']); + expect(updated.devDependencies['ts-checker-rspack-plugin']).toBe(rspackDeps['ts-checker-rspack-plugin']); + }); + + it('should remove webpack-only devDependencies', () => { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.devDependencies['eslint-webpack-plugin'] = '^5.0.0'; + pkg.devDependencies['terser-webpack-plugin'] = '^5.3.0'; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + + const result = addRspack(context); + const updated = JSON.parse(result.getFile('package.json')!); + + expect(updated.devDependencies['copy-webpack-plugin']).toBeUndefined(); + expect(updated.devDependencies['eslint-webpack-plugin']).toBeUndefined(); + expect(updated.devDependencies['fork-ts-checker-webpack-plugin']).toBeUndefined(); + expect(updated.devDependencies['swc-loader']).toBeUndefined(); + expect(updated.devDependencies['terser-webpack-plugin']).toBeUndefined(); + expect(updated.devDependencies['webpack-cli']).toBeUndefined(); + expect(updated.devDependencies['webpack-livereload-plugin']).toBeUndefined(); + expect(updated.devDependencies['webpack-subresource-integrity']).toBeUndefined(); + expect(updated.devDependencies['webpack-virtual-modules']).toBeUndefined(); + }); + + it('should keep webpack-only devDependencies that root build files still use and report them', () => { + const context = createBaseContext(); + context.addFile( + 'webpack.config.ts', + "import CopyWebpackPlugin from 'copy-webpack-plugin';\nimport { getAlias } from './webpack.config.utils';" + ); + context.addFile('webpack.config.utils.ts', "export const rule = { use: { loader: 'swc-loader' } };"); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['copy-webpack-plugin']).toBe('^12.0.0'); + expect(pkg.devDependencies['swc-loader']).toBe('^0.2.0'); + expect(pkg.devDependencies['webpack-livereload-plugin']).toBeUndefined(); + const report = result.getMessage()?.body?.join('\n'); + expect(report).toContain('copy-webpack-plugin'); + expect(report).toContain('swc-loader'); + }); + + it('should not treat source files as build files when deciding which dependencies to keep', () => { + const context = createBaseContext(); + context.addFile('src/module.ts', "import 'copy-webpack-plugin';"); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['copy-webpack-plugin']).toBeUndefined(); + }); + + it('should keep webpack package itself', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['webpack']).toBe('^5.94.0'); + }); + + it('should update build and dev scripts to use rspack', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.scripts.build).toBe('rspack -c ./.config/rspack/rspack.config.ts --env production'); + expect(pkg.scripts.dev).toBe('rspack -w -c ./.config/rspack/rspack.config.ts --env development'); + }); + }); + + describe('scripts', () => { + function runWithScripts(scripts: Record, rootConfig = true) { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.scripts = scripts; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + if (rootConfig) { + context.addFile('webpack.config.ts', 'custom webpack config'); + } + const result = addRspack(context); + return { + scripts: JSON.parse(result.getFile('package.json')!).scripts, + report: result.getMessage()?.body?.join('\n') ?? '', + }; + } + + it('should point scripts that use the root webpack config at the root rspack config', () => { + const { scripts } = runWithScripts({ + build: 'webpack -c webpack.config.ts --env production', + dev: 'webpack -w --config=./webpack.config.ts --env development', + }); + + expect(scripts.build).toBe('rspack -c ./rspack.config.ts --env production'); + expect(scripts.dev).toBe('rspack -w --config=./rspack.config.ts --env development'); + }); + + it('should keep chained commands, env assignments and extra flags', () => { + const { scripts } = runWithScripts({ + build: + 'pnpm run build:deps && NODE_ENV=production npx webpack -c ./webpack.config.ts --env production --stats-error-details', + }); + + expect(scripts.build).toBe( + 'pnpm run build:deps && NODE_ENV=production npx rspack -c ./rspack.config.ts --env production --stats-error-details' + ); + }); + + it('should rewrite sub scripts and leave script runners alone', () => { + const { scripts } = runWithScripts({ + build: 'run-s "build:*"', + 'build:compile': 'webpack -c ./webpack.config.ts --env production', + }); + + expect(scripts.build).toBe('run-s "build:*"'); + expect(scripts['build:compile']).toBe('rspack -c ./rspack.config.ts --env production'); + }); + + it('should rewrite webpack calls that rely on the default config lookup', () => { + const { scripts } = runWithScripts({ build: 'webpack --env production' }); + + expect(scripts.build).toBe('rspack --env production'); + }); + + it('should leave scripts it cannot rewrite unchanged and report them', () => { + const unparseable = 'tsx node_modules/webpack-cli/bin/cli.js -c ./webpack.config.ts --env production'; + const { scripts, report } = runWithScripts({ build: unparseable }); + + expect(scripts.build).toBe(unparseable); + expect(report).toContain('build'); + expect(report).toContain(unparseable); + }); + + it('should not touch or report scripts that only mention webpack tooling', () => { + const { scripts, report } = runWithScripts({ analyze: 'webpack-bundle-analyzer dist/stats.json' }); + + expect(scripts.analyze).toBe('webpack-bundle-analyzer dist/stats.json'); + expect(report).not.toContain('webpack-bundle-analyzer'); + }); + + it('should leave scripts that already call rspack unchanged', () => { + const { scripts } = runWithScripts( + { + build: 'rspack -c ./rspack.config.ts --env production', + dev: 'rspack -w -c ./rspack.config.ts --env development', + }, + false + ); + + expect(scripts.build).toBe('rspack -c ./rspack.config.ts --env production'); + expect(scripts.dev).toBe('rspack -w -c ./rspack.config.ts --env development'); + }); + }); + + describe('experimental rspack start', () => { + function createExperimentalRspackContext(devDependencies: Record) { + const context = new Context('/virtual'); + context.addFile('.config/rspack/rspack.config.ts', 'experimental rspack config'); + context.addFile( + '.config/.cprc.json', + JSON.stringify({ version: '7.9.0', features: { useExperimentalRspack: true } }, null, 2) + ); + context.addFile('rspack.config.ts', 'my rspack config'); + context.addFile( + 'package.json', + JSON.stringify( + { + scripts: { build: 'rspack -c ./rspack.config.ts --env production' }, + devDependencies: { 'eslint-webpack-plugin': '^5.0.0', webpack: '^5.94.0', ...devDependencies }, + }, + null, + 2 + ) + ); + return context; + } + + it('should hand the root rspack config to the user untouched and not add a stub', () => { + const context = createExperimentalRspackContext({ '@rspack/core': '^1.6.0', '@rspack/cli': '^1.6.0' }); + + const result = addRspack(context); + + expect(result.getFile('rspack.config.ts')).toBe('my rspack config'); + }); + + it('should swap eslint-webpack-plugin for eslint-rspack-plugin', () => { + const context = createExperimentalRspackContext({ '@rspack/core': '^1.6.0', '@rspack/cli': '^1.6.0' }); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['eslint-webpack-plugin']).toBeUndefined(); + expect(pkg.devDependencies['eslint-rspack-plugin']).toBeDefined(); + }); + + it('should keep rspack versions the plugin already pinned at or above the template', () => { + const context = createExperimentalRspackContext({ '@rspack/core': '2.2.6', '@rspack/cli': '2.2.6' }); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['@rspack/core']).toBe('2.2.6'); + expect(pkg.devDependencies['@rspack/cli']).toBe('2.2.6'); + }); + }); + + describe('engines', () => { + function runWithEngines(engines?: Record) { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + if (engines) { + pkg.engines = engines; + } + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + return JSON.parse(addRspack(context).getFile('package.json')!).engines; + } + + it('should raise engines.node when it allows versions rspack 2 cannot run on', () => { + expect(runWithEngines({ node: '>=22' })).toEqual({ node: '>=22.23' }); + expect(runWithEngines({ node: '>=20', npm: '>=10' })).toEqual({ node: '>=22.23', npm: '>=10' }); + }); + + it('should keep engines.node when it already requires a newer Node', () => { + expect(runWithEngines({ node: '>=24' })).toEqual({ node: '>=24' }); + expect(runWithEngines({ node: '>=22.23.3' })).toEqual({ node: '>=22.23.3' }); + }); + + it('should not add engines when the plugin has none', () => { + expect(runWithEngines()).toBeUndefined(); + }); + }); + + describe('npm lockfile', () => { + function createLockfile(rspackCoreVersion: string) { + return JSON.stringify( + { + name: 'my-plugin', + lockfileVersion: 3, + packages: { + '': { name: 'my-plugin' }, + 'node_modules/@rspack/core': { version: rspackCoreVersion }, + 'node_modules/@rspack/cli': { version: rspackCoreVersion }, + 'node_modules/@rspack/cli/node_modules/@rspack/dev-server': { version: '1.1.5' }, + 'node_modules/react': { version: '18.3.1' }, + }, + }, + null, + 2 + ); + } + + it('should drop locked rspack 1 packages so npm can resolve rspack 2', () => { + const context = createBaseContext(); + context.addFile('package-lock.json', createLockfile('1.7.12')); + + const result = addRspack(context); + const packages = Object.keys(JSON.parse(result.getFile('package-lock.json')!).packages); + + expect(packages).toEqual(['', 'node_modules/react']); + }); + + it('should leave the lockfile alone when rspack 2 is already locked', () => { + const context = createBaseContext(); + const lockfile = createLockfile('2.2.8'); + context.addFile('package-lock.json', lockfile); + + const result = addRspack(context); + + expect(result.getFile('package-lock.json')).toBe(lockfile); + }); + + it('should not touch other package managers lockfiles', () => { + const context = createBaseContext(); + context.addFile('pnpm-lock.yaml', "lockfileVersion: '9.0'\n"); + context.addFile('yarn.lock', '# yarn lockfile v1\n'); + + const result = addRspack(context); + + expect(result.getFile('pnpm-lock.yaml')).toBe("lockfileVersion: '9.0'\n"); + expect(result.getFile('yarn.lock')).toBe('# yarn lockfile v1\n'); + }); + }); + + describe('idempotency', () => { + const run = async (context: Context) => addRspack(context); + + it('should be idempotent for a webpack plugin', async () => { + await expect(run).toBeIdempotent(createBaseContext()); + }); + + it('should be idempotent for a webpack plugin with a root config, helpers and a lockfile', async () => { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.scripts = { build: 'pnpm run prebuild && webpack -c ./webpack.config.ts --env production' }; + pkg.engines = { node: '>=22' }; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + context.addFile('webpack.config.ts', "import CopyWebpackPlugin from 'copy-webpack-plugin';"); + context.addFile('.config/webpack/CorsWorkerPlugin.ts', 'export class CorsWorkerPlugin {}'); + context.addFile( + 'package-lock.json', + JSON.stringify({ packages: { 'node_modules/@rspack/core': { version: '1.7.12' } } }, null, 2) + ); + + await expect(run).toBeIdempotent(context); + }); + + it('should be idempotent for an experimental rspack plugin', async () => { + const context = new Context('/virtual'); + context.addFile('.config/rspack/rspack.config.ts', 'experimental rspack config'); + context.addFile('rspack.config.ts', 'my rspack config'); + context.addFile( + 'package.json', + JSON.stringify({ scripts: { build: 'rspack -c ./rspack.config.ts' }, devDependencies: {} }, null, 2) + ); + + await expect(run).toBeIdempotent(context); + }); + }); + + describe('webpack cleanup', () => { + it('should delete the create-plugin webpack files from .config/webpack/', () => { + const context = createBaseContext(); + context.addFile('.config/webpack/utils.ts', ''); + context.addFile('.config/webpack/constants.ts', ''); + context.addFile('.config/webpack/tsconfig.webpack.json', ''); + + const result = addRspack(context); + + expect(result.doesFileExist('.config/webpack/webpack.config.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/BuildModeWebpackPlugin.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/utils.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/constants.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/tsconfig.webpack.json')).toBe(false); + }); + + it('should keep files the plugin added to .config/webpack/ and report them', () => { + const context = createBaseContext(); + context.addFile('.config/webpack/CorsWorkerPlugin.ts', 'export class CorsWorkerPlugin {}'); + + const result = addRspack(context); + + expect(result.getFile('.config/webpack/CorsWorkerPlugin.ts')).toBe('export class CorsWorkerPlugin {}'); + expect(result.getMessage()?.body?.join('\n')).toContain('.config/webpack/CorsWorkerPlugin.ts'); + }); + }); + + describe('root config stub', () => { + it.each(['webpack.config.ts', 'webpack.config.js', 'webpack.config.cjs', 'webpack.config.mjs'])( + 'should add a root rspack.config.ts stub when %s exists', + (rootConfig) => { + const context = createBaseContext(); + context.addFile(rootConfig, 'custom webpack config'); + + const result = addRspack(context); + + expect(result.doesFileExist('rspack.config.ts')).toBe(true); + } + ); + + it('should make the stub fail the build until the config is ported', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + + const result = addRspack(context); + const stub = result.getFile('rspack.config.ts')!; + + expect(stub).toContain('throw new Error('); + expect(stub).toContain('[rspack]'); + expect(stub).toContain('add rspack --agent'); + expect(stub).toContain('TODO(rspack)'); + }); + + it('should write the stub as an ESM safe rspack-merge config', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + + const result = addRspack(context); + const stub = result.getFile('rspack.config.ts')!; + + expect(stub).toContain("import { merge } from 'rspack-merge';"); + expect(stub).toContain("import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts';"); + expect(stub).not.toContain('__dirname'); + expect(stub).not.toContain('webpack-merge'); + }); + + it('should leave the root webpack config untouched', () => { + const context = createBaseContext(); + const originalContent = 'import grafanaConfig from "./.config/webpack/webpack.config";'; + context.addFile('webpack.config.ts', originalContent); + + const result = addRspack(context); + + expect(result.getFile('webpack.config.ts')).toBe(originalContent); + }); + + it('should not overwrite an existing root rspack config and should report it', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + context.addFile('rspack.config.ts', 'my rspack config'); + + const result = addRspack(context); + + expect(result.getFile('rspack.config.ts')).toBe('my rspack config'); + expect(result.getMessage()?.body?.join('\n')).toContain('rspack.config.ts'); + }); + + it('should not add a stub when there is no root webpack config', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + expect(result.doesFileExist('rspack.config.ts')).toBe(false); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts new file mode 100644 index 0000000000..cc522dcce2 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -0,0 +1,440 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { lt, major, minVersion, validRange } from 'semver'; +import type { Context } from '../../context.js'; +import { renderHandlebarsTemplate } from '../../../utils/utils.handlebars.js'; +import { + additionsDebug, + addDependenciesToPackageJson, + readJsonFile, + removeDependenciesFromPackageJson, + renderTemplate, +} from '../../utils.js'; + +const RSPACK_TEMPLATE_DATA_OVERRIDES = { + useExperimentalRspack: true, + frontendBundler: 'rspack', +}; + +const RSPACK_CONFIG_FILES = [ + '.config/rspack/rspack.config.ts', + '.config/rspack/BuildModeRspackPlugin.ts', + '.config/rspack/LiveReloadRspackPlugin.ts', + '.config/rspack/LicenseRspackPlugin.ts', +]; + +// files the rspack template used to ship under another name. removed so they do not linger +const RETIRED_RSPACK_FILES = ['.config/rspack/liveReloadPlugin.ts']; + +// markdown in .config that describes the bundler, for agents and for developers +const DOC_FILES = ['.config/AGENTS/instructions.md', '.config/README.md']; + +const BUNDLER_FILES = [ + '.config/bundler/constants.ts', + '.config/bundler/copyFiles.ts', + '.config/bundler/externals.ts', + '.config/bundler/utils.ts', +]; + +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; + +// every file create-plugin has scaffolded into .config/webpack over time. anything else in there +// was added by the plugin author, so it is kept and reported instead of deleted +const WEBPACK_TEMPLATE_FILES = [ + '.config/webpack/BuildModeWebpackPlugin.ts', + '.config/webpack/PluginSchemaWebpackPlugin.ts', + '.config/webpack/constants.ts', + '.config/webpack/generateCode.ts', + '.config/webpack/publicPath.ts', + '.config/webpack/tsconfig.webpack.json', + '.config/webpack/utils.ts', + '.config/webpack/watchPluginJson.ts', + '.config/webpack/webpack.config.ts', + '.config/webpack/webpack.parts.ts', +]; + +export default function rspack(context: Context): Context { + const hasWebpackSetup = context.doesFileExist(WEBPACK_CONFIG_PATH); + const hasRspackSetup = context.doesFileExist(RSPACK_CONFIG_PATH); + + if (!hasWebpackSetup && !hasRspackSetup) { + context.setMessage({ + level: 'warning', + title: 'No create-plugin bundler configuration found, so nothing was changed.', + body: [`The rspack addition expects ${WEBPACK_CONFIG_PATH} or ${RSPACK_CONFIG_PATH} to exist.`], + }); + return context; + } + + const followUps: string[] = []; + + deleteRetiredRspackFiles(context); + renderTemplateFiles(context, RSPACK_CONFIG_FILES); + renderTemplateFiles(context, BUNDLER_FILES); + renderTemplateFiles(context, DOC_FILES, false); + updateCprcConfig(context); + + followUps.push(...addRootRspackConfigStub(context)); + followUps.push(...updatePackageJson(context)); + followUps.push(...deleteWebpackTemplateFiles(context)); + reportFollowUps(context, followUps); + + return context; +} + +function deleteRetiredRspackFiles(context: Context): void { + for (const filePath of RETIRED_RSPACK_FILES) { + if (context.doesFileExist(filePath)) { + context.deleteFile(filePath); + } + } +} + +function reportFollowUps(context: Context, followUps: string[]): void { + if (followUps.length === 0) { + return; + } + + context.setMessage({ + level: 'warning', + title: 'The rspack addition left some things for you to review.', + body: followUps, + }); +} + +function updateCprcConfig(context: Context): void { + const cprcPath = '.config/.cprc.json'; + if (context.doesFileExist(cprcPath)) { + const config = readJsonFile(context, cprcPath); + + const updated = { + ...config, + features: { + ...config.features, + useExperimentalRspack: true, + }, + }; + + context.updateFile(cprcPath, JSON.stringify(updated, null, 2)); + } +} + +const resolveTemplatePath = (relativePath: string) => + fileURLToPath(new URL(`../../../../templates/common/${relativePath}`, import.meta.url)); + +// updateFile is a no-op for identical content, so re-rendering on every run keeps the addition idempotent +function renderTemplateFiles(context: Context, filePaths: string[], includeWarning = true): void { + for (const filePath of filePaths) { + const rendered = renderTemplate(resolveTemplatePath(filePath), includeWarning, RSPACK_TEMPLATE_DATA_OVERRIDES); + if (context.doesFileExist(filePath)) { + context.updateFile(filePath, rendered); + } else { + context.addFile(filePath, rendered); + } + } +} + +const ROOT_CONFIG_EXTENSIONS = ['ts', 'js', 'cjs', 'mjs', 'cts', 'mts']; + +// a root webpack config means the plugin extends the build. the stub makes the build fail loudly +// until that config is ported, so a plugin cannot ship without the build features it relies on +function addRootRspackConfigStub(context: Context): string[] { + const rootWebpackConfig = ROOT_CONFIG_EXTENSIONS.map((extension) => `webpack.config.${extension}`).find((filePath) => + context.doesFileExist(filePath) + ); + if (!rootWebpackConfig) { + return []; + } + + const existingRootRspackConfig = ROOT_CONFIG_EXTENSIONS.map((extension) => `rspack.config.${extension}`).find( + (filePath) => context.doesFileExist(filePath) + ); + if (existingRootRspackConfig) { + return [ + `${existingRootRspackConfig} already exists, so it was left as is. Check that it includes everything from ${rootWebpackConfig}.`, + ]; + } + + additionsDebug(`Custom ${rootWebpackConfig} detected. Adding a rspack.config.ts stub that fails until it is ported.`); + context.addFile('rspack.config.ts', getRootRspackConfigStub(rootWebpackConfig)); + + return [ + `rspack.config.ts was added and fails the build until the customisations in ${rootWebpackConfig} are ported to it.`, + ]; +} + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; + engines?: Record; + [key: string]: unknown; +} + +function updatePackageJson(context: Context): string[] { + if (!context.doesFileExist('package.json')) { + additionsDebug('No package.json found. Skipping dependency and script updates.'); + return []; + } + + const followUps: string[] = []; + const templateVersions = getTemplateDevDependencyVersions(); + const userBuildFileSources = readUserBuildFileSources(context); + const stillUsedDevDependencies = WEBPACK_ONLY_DEV_DEPENDENCIES.filter((name) => + userBuildFileSources.some((source) => referencesPackage(source, name)) + ); + const unusedDevDependencies = WEBPACK_ONLY_DEV_DEPENDENCIES.filter( + (name) => !stillUsedDevDependencies.includes(name) + ); + + addDependenciesToPackageJson( + context, + {}, + { + ...pickVersions(getMissingPackages(context, BASE_CONFIG_DEV_DEPENDENCIES), templateVersions), + ...pickVersions(RSPACK_DEV_DEPENDENCIES, templateVersions), + } + ); + removeDependenciesFromPackageJson(context, [], unusedDevDependencies); + + if (stillUsedDevDependencies.length > 0) { + followUps.push( + 'These webpack packages were kept because your root build config still uses them. Remove them once the config is ported to rspack:', + ...stillUsedDevDependencies.map((name) => ` ${name}`) + ); + } + + const packageJson = readJsonFile(context, 'package.json'); + const { scripts, unparsedScripts } = rewriteWebpackScripts(packageJson.scripts ?? {}); + + if (packageJson.scripts) { + context.updateFile('package.json', JSON.stringify({ ...packageJson, scripts }, null, 2)); + } + + raiseNodeEngine(context); + dropLockedRspackOnePackages(context); + + if (unparsedScripts.length > 0) { + followUps.push( + 'These scripts still call webpack in a way the addition could not rewrite. Change them to call rspack with ./rspack.config.ts or ./.config/rspack/rspack.config.ts:', + ...unparsedScripts.map(([name, command]) => ` ${name}: ${command}`) + ); + } + + return followUps; +} + +// rspack 2 needs Node 22.12. 22.23 also has type stripping on by default, so the rspack CLI can +// always load TypeScript configs natively. matches the engines range create-plugin scaffolds +const MIN_NODE_VERSION = '22.23.0'; + +function raiseNodeEngine(context: Context): void { + const packageJson = readJsonFile(context, 'package.json'); + const nodeRange = packageJson.engines?.node; + if (!nodeRange || !validRange(nodeRange)) { + return; + } + + const lowestAllowedVersion = minVersion(nodeRange); + if (!lowestAllowedVersion || !lt(lowestAllowedVersion, MIN_NODE_VERSION)) { + return; + } + + const engines = { ...packageJson.engines, node: '>=22.23' }; + context.updateFile('package.json', JSON.stringify({ ...packageJson, engines }, null, 2)); +} + +// npm refuses to move @rspack/cli and @rspack/core from 1 to 2 in one install while the lockfile +// still pins the old versions (ERESOLVE). dropping the locked @rspack packages lets npm resolve +// them again from package.json. pnpm and yarn upgrade them without help +const LOCKED_RSPACK_PACKAGE_PATTERN = /(^|\/)node_modules\/@rspack\//; + +function dropLockedRspackOnePackages(context: Context): void { + if (!context.doesFileExist('package-lock.json')) { + return; + } + + const lockfile = readJsonFile<{ packages?: Record }>(context, 'package-lock.json'); + const lockedCoreVersion = lockfile.packages?.['node_modules/@rspack/core']?.version; + if (!lockfile.packages || !lockedCoreVersion || major(lockedCoreVersion) >= 2) { + return; + } + + const packages = Object.fromEntries( + Object.entries(lockfile.packages).filter(([path]) => !LOCKED_RSPACK_PACKAGE_PATTERN.test(path)) + ); + context.updateFile('package-lock.json', JSON.stringify({ ...lockfile, packages }, null, 2) + '\n'); +} + +// a webpack or webpack-cli command at the start of a script, after a shell separator, after env +// assignments or after npx. the lookbehind keeps the separator and prefixes out of the match +const WEBPACK_COMMAND_PATTERN = /(?<=(?:^|&&|\|\||;|\|)\s*(?:\w+=\S*\s+)*(?:npx\s+)?)webpack(?:-cli)?(?=\s|$)/g; +const ROOT_WEBPACK_CONFIG_ARG_PATTERN = + /((?:^|\s)(?:-c|--config)(?:\s+|=))(?:\.\/)?webpack\.config\.[cm]?[jt]s(?=\s|$)/g; +const TEMPLATE_WEBPACK_CONFIG_ARG_PATTERN = + /((?:^|\s)(?:-c|--config)(?:\s+|=))(?:\.\/)?\.config\/webpack\/webpack\.config\.ts(?=\s|$)/g; +// anything that still runs webpack after the rewrite, e.g. node_modules/webpack-cli/bin/cli.js +const REMAINING_WEBPACK_PATTERN = /(?:^|[\s/])webpack(?:-cli)?(?:[\s/]|$)|\.config\/webpack\//; + +function rewriteWebpackScripts(scripts: Record) { + const unparsedScripts: Array<[string, string]> = []; + const rewrittenScripts = Object.fromEntries( + Object.entries(scripts).map(([name, command]) => { + const rewritten = command + .replace(WEBPACK_COMMAND_PATTERN, 'rspack') + .replace(ROOT_WEBPACK_CONFIG_ARG_PATTERN, '$1./rspack.config.ts') + .replace(TEMPLATE_WEBPACK_CONFIG_ARG_PATTERN, '$1./.config/rspack/rspack.config.ts'); + + if (REMAINING_WEBPACK_PATTERN.test(rewritten)) { + unparsedScripts.push([name, command]); + return [name, command]; + } + + return [name, rewritten]; + }) + ); + + return { scripts: rewrittenScripts, unparsedScripts }; +} + +// root level webpack.* and rspack.* files, e.g. webpack.config.ts and helpers like webpack.config.utils.ts +const USER_BUILD_FILE_PATTERN = /^(webpack|rspack)\.[\w.-]*\.[cm]?[jt]s$/; + +function readUserBuildFileSources(context: Context): string[] { + return context + .readDir('.') + .filter((filePath) => USER_BUILD_FILE_PATTERN.test(filePath)) + .map((filePath) => context.getFile(filePath) ?? ''); +} + +// matches the package as a module specifier or loader string: 'name', "name", `name` or 'name/subpath' +function referencesPackage(source: string, packageName: string): boolean { + const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`['"\`]${escapedName}(/[^'"\`]*)?['"\`]`).test(source); +} + +// packages the rspack base config and its helpers need that webpack plugins do not +export const RSPACK_DEV_DEPENDENCIES = [ + '@rspack/cli', + '@rspack/core', + '@types/ws', + 'eslint-rspack-plugin', + 'rspack-merge', + 'rspack-plugin-virtual-module', + 'ts-checker-rspack-plugin', + 'ws', +]; + +// packages only the webpack base config uses. removed unless a root build file still imports them +export const WEBPACK_ONLY_DEV_DEPENDENCIES = [ + 'copy-webpack-plugin', + 'eslint-webpack-plugin', + 'fork-ts-checker-webpack-plugin', + 'swc-loader', + 'terser-webpack-plugin', + 'webpack-cli', + 'webpack-livereload-plugin', + 'webpack-subresource-integrity', + 'webpack-virtual-modules', +]; + +// packages the rendered .config/rspack and .config/bundler files load at build time that every +// scaffold gets, so older plugins may have removed some of them. added only when missing. +// @swc/helpers is needed because builtin:swc-loader runs with externalHelpers, which webpack did not +export const BASE_CONFIG_DEV_DEPENDENCIES = [ + '@swc/helpers', + 'css-loader', + 'glob', + 'imports-loader', + 'replace-in-file-webpack-plugin', + 'sass', + 'sass-loader', + 'style-loader', +]; + +// the lists above say which packages the addition manages. their versions come from the package.json +// template, so they always match the .config files rendered from that same template. the template is +// rendered with fixed data, since only devDependencies are read and they do not depend on the plugin +const PACKAGE_JSON_TEMPLATE_DATA = { + ...RSPACK_TEMPLATE_DATA_OVERRIDES, + pluginName: 'plugin', + orgName: 'org', + pluginType: 'panel', + packageManagerName: 'npm', + packageManagerVersion: '10.0.0', + scenesVersion: '*', + isAppType: false, + isNPM: true, +}; + +export function getTemplateDevDependencyVersions(): Record { + const template = readFileSync(resolveTemplatePath('_package.json'), 'utf-8'); + const rendered: { devDependencies?: Record } = JSON.parse( + renderHandlebarsTemplate(template, PACKAGE_JSON_TEMPLATE_DATA) + ); + return rendered.devDependencies ?? {}; +} + +function pickVersions(names: string[], templateVersions: Record): Record { + return Object.fromEntries( + names.map((name) => { + const version = templateVersions[name]; + if (!version) { + throw new Error(`The package.json template has no version for ${name}, which the rspack addition installs.`); + } + return [name, version]; + }) + ); +} + +// a plugin that already has one of these keeps its version +function getMissingPackages(context: Context, names: string[]): string[] { + const packageJson = readJsonFile(context, 'package.json'); + const installed = { ...packageJson.dependencies, ...packageJson.devDependencies }; + return names.filter((name) => !(name in installed)); +} + +function deleteWebpackTemplateFiles(context: Context): string[] { + for (const filePath of WEBPACK_TEMPLATE_FILES) { + if (context.doesFileExist(filePath)) { + context.deleteFile(filePath); + } + } + + const leftoverFiles = context.readDir('.config/webpack'); + if (leftoverFiles.length === 0) { + return []; + } + + return [ + 'These files in .config/webpack were not created by create-plugin, so they were kept. Move anything you still need out of .config and port it to rspack:', + ...leftoverFiles.map((filePath) => ` ${filePath}`), + ]; +} + +function getRootRspackConfigStub(rootWebpackConfig: string): string { + return `import type { Configuration } from '@rspack/core'; +import { merge } from 'rspack-merge'; + +import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts'; + +// TODO(rspack): port the customisations in ./${rootWebpackConfig} to this file, then delete it. +// Run \`npx @grafana/create-plugin@latest add rspack --agent\` to have an AI agent do the port, +// or follow https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations +// This file is loaded as native ESM, so CommonJS globals are not available. Use import.meta.dirname, +// createRequire(import.meta.url) and .ts extensions on relative imports. +throw new Error( + '[rspack] ${rootWebpackConfig} has not been ported to rspack.config.ts yet. ' + + 'Run \`npx @grafana/create-plugin@latest add rspack --agent\` or port it by hand, then remove this error.' +); + +const config = async (env: Env): Promise => { + const baseConfig = await grafanaConfig(env); + + return merge(baseConfig, {}); +}; + +export default config; +`; +} diff --git a/packages/create-plugin/src/codemods/utils.ts b/packages/create-plugin/src/codemods/utils.ts index 9a51dd4f79..94bc0bfc12 100644 --- a/packages/create-plugin/src/codemods/utils.ts +++ b/packages/create-plugin/src/codemods/utils.ts @@ -343,8 +343,12 @@ function sortObjectByKeys>(obj: T): T { export const migrationsDebug = debug.extend('migrations'); export const additionsDebug = debug.extend('additions'); -export function renderTemplate(templatePath: string, includeWarning = false): string { - const templateData = getTemplateData(); +export function renderTemplate( + templatePath: string, + includeWarning = false, + templateDataOverrides?: Record +): string { + const templateData = { ...getTemplateData(), ...templateDataOverrides }; let rendered = renderHandlebarsTemplate(readFileSync(templatePath, 'utf-8'), templateData); const regex = /DO NOT EDIT THIS FILE DIRECTLY\./g; if (includeWarning && !regex.test(rendered)) { diff --git a/packages/create-plugin/templates/common/.config/AGENTS/instructions.md b/packages/create-plugin/templates/common/.config/AGENTS/instructions.md index 362b9dd109..b33c246314 100644 --- a/packages/create-plugin/templates/common/.config/AGENTS/instructions.md +++ b/packages/create-plugin/templates/common/.config/AGENTS/instructions.md @@ -28,7 +28,7 @@ Your training data about the Grafana API is out of date. Use the official docume - **Do not change plugin ID or plugin type** in `plugin.json`. - Any modifications to `plugin.json` require a **restart of the Grafana server**. Remind the user of this. - Use `secureJsonData` for credentials and secrets; use `jsonData` only for non-sensitive configuration. -- **You must use webpack** with the configuration provided in `.config/` for frontend builds. +- **You must use {{frontendBundler}}** with the configuration provided in `.config/` for frontend builds. - **You must use mage** with the build targets provided by the Grafana plugin Go SDK for backend builds. -- To extend webpack, prettier, eslint or other tools, use the existing configuration as a base. Follow the guide: https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations.md +- To extend {{frontendBundler}}, prettier, eslint or other tools, use the existing configuration as a base. Follow the guide: https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations.md - Use **`@grafana/plugin-e2e`** for end-to-end testing. Read @./.config/AGENTS/e2e-testing.md before writing or modifying e2e tests. diff --git a/packages/create-plugin/templates/common/.config/README.md b/packages/create-plugin/templates/common/.config/README.md index 93d9a3bc85..eb0acd92d8 100644 --- a/packages/create-plugin/templates/common/.config/README.md +++ b/packages/create-plugin/templates/common/.config/README.md @@ -99,6 +99,61 @@ Edit the `tsconfig.json` file in the project root in order to extend the TypeScr --- +{{#if useExperimentalRspack}} +### Extending the Rspack config + +Follow these steps to extend the basic Rspack configuration that lives under `.config/`: + +#### 1. Create a new Rspack configuration file + +Create a new config file that is going to extend the basic one provided by Grafana. +It can live in the project root, e.g. `rspack.config.ts`. + +#### 2. Merge the basic config provided by Grafana and your custom setup + +We are going to use [`rspack-merge`](https://github.com/rstackjs/rspack-merge) for this. + +```typescript +// rspack.config.ts +import type { Configuration } from '@rspack/core'; +import { merge } from 'rspack-merge'; +import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts'; + +const config = async (env: Env): Promise => { + const baseConfig = await grafanaConfig(env); + + return merge(baseConfig, { + // Add custom config here... + output: { + asyncChunks: true, + }, + }); +}; + +export default config; +``` + +The Rspack CLI loads this file as native ESM, so use `import.meta.dirname` instead of `__dirname` and give relative imports a `.ts` extension. + +#### 3. Update the `package.json` to use the new Rspack config + +We need to update the `scripts` in the `package.json` to use the extended Rspack configuration. + +**Update for `build`:** + +```diff +-"build": "rspack -c ./.config/rspack/rspack.config.ts --env production", ++"build": "rspack -c ./rspack.config.ts --env production", +``` + +**Update for `dev`:** + +```diff +-"dev": "rspack -w -c ./.config/rspack/rspack.config.ts --env development", ++"dev": "rspack -w -c ./rspack.config.ts --env development", +``` + +{{else}} ### Extending the Webpack config Follow these steps to extend the basic Webpack configuration that lives under `.config/`: @@ -150,6 +205,7 @@ We need to update the `scripts` in the `package.json` to use the extended Webpac +"dev": "webpack -w -c ./webpack.config.ts --env development", ``` +{{/if}} ### Configure grafana image to use when running docker By default, `grafana-enterprise` will be used as the docker image for all docker related commands. If you want to override this behavior, simply alter the `docker-compose.yaml` by adding the following build arg `grafana_image`. diff --git a/packages/create-plugin/templates/common/.config/rspack/LicenseRspackPlugin.ts b/packages/create-plugin/templates/common/.config/rspack/LicenseRspackPlugin.ts new file mode 100644 index 0000000000..9f1e7a9310 --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/LicenseRspackPlugin.ts @@ -0,0 +1,44 @@ +import { Compilation, type Compiler } from '@rspack/core'; + +const PLUGIN_NAME = 'LicenseRspackPlugin'; +const CHUNK_LICENSE_PATTERN = /\.LICENSE\.txt$/; + +// SwcJsMinimizerRspackPlugin extracts license comments into one file per chunk. Plugins with many +// chunks, for example those bundling monaco-editor, end up with dozens of identical files in dist. +// This merges them into a single LICENSE.txt, keeping each distinct comment once. +export class LicenseRspackPlugin { + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + // after the minimizer (PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE) has extracted the comments + stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE + 1, + }, + (assets) => { + const chunkLicenseFiles = Object.keys(assets) + .filter((assetName) => CHUNK_LICENSE_PATTERN.test(assetName)) + .sort(); + + if (chunkLicenseFiles.length === 0) { + return; + } + + const comments = new Set(); + for (const assetName of chunkLicenseFiles) { + const content = assets[assetName].source().toString(); + content + .split(/\n{2,}/) + .map((comment) => comment.trim()) + .filter(Boolean) + .forEach((comment) => comments.add(comment)); + compilation.deleteAsset(assetName); + } + + const { RawSource } = compiler.rspack.sources; + compilation.emitAsset('LICENSE.txt', new RawSource([...comments].join('\n\n') + '\n')); + } + ); + }); + } +} diff --git a/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts b/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts new file mode 100644 index 0000000000..860151f749 --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts @@ -0,0 +1,95 @@ +import { createServer } from 'http'; +import { type Compiler } from '@rspack/core'; +import { WebSocketServer } from 'ws'; + +interface RspackLiveReloadPluginOptions { + port?: number; +} + +// Serves /livereload.js and tells connected browsers to reload after every rebuild. The scaffolded +// Grafana docker image adds `); - - compilation.updateAsset(filename, { - source: () => updatedSource, - buffer: () => Buffer.from(updatedSource), - size: () => updatedSource.length, - map: () => null, - sourceAndMap: () => ({ source: updatedSource, map: null }), - updateHash: (hash) => { - hash.update(updatedSource); - }, - }); - } - }); - } - ); - } - - _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(); - } - }; - })(); - `; - } -} - -export default 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 index 80b899a0ac..7eb2464fd6 100644 --- a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts +++ b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts @@ -5,24 +5,32 @@ * https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-webpack-config */ -import rspack, { type Configuration } from '@rspack/core'; -import ESLintPlugin from 'eslint-webpack-plugin'; +import { + BannerPlugin, + Compilation, + CopyRspackPlugin, + SubresourceIntegrityPlugin, + SwcJsMinimizerRspackPlugin, + type Configuration, +} from '@rspack/core'; +import ESLintPlugin from 'eslint-rspack-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.ts'; +import RspackLiveReloadPlugin from './LiveReloadRspackPlugin.ts'; import { BuildModeRspackPlugin } from './BuildModeRspackPlugin.ts'; +import { LicenseRspackPlugin } from './LicenseRspackPlugin.ts'; import { DIST_DIR, SOURCE_DIR } from '../bundler/constants.ts'; import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, isWSL } from '../bundler/utils.ts'; import { externals } from '../bundler/externals.ts'; import { copyFilePatterns } from '../bundler/copyFiles.ts'; -const { SubresourceIntegrityPlugin } = rspack.experiments; const pluginJson = getPluginJson(); +const pluginId = pluginJson.id; const cpVersion = getCPConfigVersion(); +const pluginVersion = getPackageJson().version; const virtualPublicPath = new RspackVirtualModulePlugin({ 'grafana-public-path': ` import amdMetaModule from 'amd-module'; @@ -30,11 +38,15 @@ import amdMetaModule from 'amd-module'; __webpack_public_path__ = amdMetaModule && amdMetaModule.uri ? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1) - : 'public/plugins/${pluginJson.id}/'; + : 'public/plugins/${pluginId}/'; `, }); -const config = async (env): Promise => { +export type Env = { + [key: string]: true | string | Env; +}; + +const config = async (env: Env): Promise => { const baseConfig: Configuration = { context: path.join(process.cwd(), SOURCE_DIR), @@ -117,19 +129,12 @@ const config = async (env): Promise => { optimization: { minimize: Boolean(env.production), minimizer: [ - new TerserPlugin({ - // Emit a single LICENSE.txt file for all comments. - extractComments: { - banner: false, - filename: 'LICENSE.txt', - }, - terserOptions: { - format: { - comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'), - }, - compress: { - drop_console: ['log', 'info'], - }, + new SwcJsMinimizerRspackPlugin({ + // Extract license comments (LicenseRspackPlugin merges them into one LICENSE.txt) and strip the rest. + extractComments: { banner: false }, + minimizerOptions: { + format: { comments: false }, + compress: { pure_funcs: ['console.log', 'console.info'] }, }, }), ], @@ -145,32 +150,40 @@ const config = async (env): Promise => { type: 'amd', }, path: path.resolve(process.cwd(), DIST_DIR), - publicPath: `public/plugins/${pluginJson.id}/`, - uniqueName: pluginJson.id, + publicPath: `public/plugins/${pluginId}/`, + uniqueName: pluginId, crossOriginLoading: 'anonymous', }, plugins: [ new BuildModeRspackPlugin(), + new LicenseRspackPlugin(), virtualPublicPath, // Insert create plugin version information into the bundle - new rspack.BannerPlugin({ - banner: '/* [create-plugin] version: ' + cpVersion + ' */', + new BannerPlugin({ + // Pipelines read these lines from the top of module.js, so keep their format exactly. + banner: [ + `/* [create-plugin] version: ${cpVersion} */`, + `/* [create-plugin] plugin: ${pluginId}@${pluginVersion} */`, + ].join('\n'), raw: true, entryOnly: true, + // Added straight after minification so the minimizer cannot strip or move the banner, + // and before source maps and hashes are generated so they include it. + stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE + 1, }), - new rspack.CopyRspackPlugin({ + new CopyRspackPlugin({ patterns: copyFilePatterns, }), // Replace certain template-variables in the README and plugin.json new ReplaceInFileWebpackPlugin([ { dir: DIST_DIR, - files: ['plugin.json', 'README.md'], + test: [/(^|\/)plugin\.json$/, /(^|\/)README\.md$/], rules: [ { search: /\%VERSION\%/g, - replace: getPackageJson().version, + replace: pluginVersion, }, { search: /\%TODAY\%/g, @@ -178,7 +191,7 @@ const config = async (env): Promise => { }, { search: /\%PLUGIN_ID\%/g, - replace: pluginJson.id, + replace: pluginId, }, ], }, diff --git a/packages/create-plugin/templates/common/_package.json b/packages/create-plugin/templates/common/_package.json index ad7f33ba29..3c0f1b44f0 100644 --- a/packages/create-plugin/templates/common/_package.json +++ b/packages/create-plugin/templates/common/_package.json @@ -21,8 +21,8 @@ "@grafana/sign-plugin": "^3.3.3", "@grafana/tsconfig": "^2.2.0", "@playwright/test": "^1.62.1",{{#if useExperimentalRspack}} - "@rspack/core": "^1.6.0", - "@rspack/cli": "^1.6.0",{{/if}} + "@rspack/core": "^2.2.0", + "@rspack/cli": "^2.2.0",{{/if}} "@stylistic/eslint-plugin": "^5.10.0", "@swc/core": "^1.15.0", "@swc/helpers": "^0.5.0", @@ -42,8 +42,9 @@ "eslint-config-prettier": "^10.1.0", "eslint-plugin-jsdoc": "^52.0.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.1.0", - "eslint-webpack-plugin": "^5.0.0",{{#unless useExperimentalRspack}} + "eslint-plugin-react-hooks": "^7.1.0",{{#if useExperimentalRspack}} + "eslint-rspack-plugin": "^5.0.1",{{/if}}{{#unless useExperimentalRspack}} + "eslint-webpack-plugin": "^5.0.0", "fork-ts-checker-webpack-plugin": "^9.1.0",{{/unless}} "glob": "^11.1.0", "identity-obj-proxy": "^3.0.0", @@ -52,15 +53,16 @@ "jest-environment-jsdom": "^29.7.0", "prettier": "^3.6.0", "replace-in-file-webpack-plugin": "^1.0.0",{{#if useExperimentalRspack}} + "rspack-merge": "^1.0.1", "rspack-plugin-virtual-module": "^1.0.0",{{/if}} "sass": "^1.89.0", "sass-loader": "^16.0.0", "semver": "^7.7.0", "style-loader": "^4.0.0",{{#unless useExperimentalRspack}} - "swc-loader": "^0.2.0",{{/unless}} - "terser-webpack-plugin": "^5.3.0", + "swc-loader": "^0.2.0", + "terser-webpack-plugin": "^5.3.0",{{/unless}} "ts-node": "^10.9.0",{{#if useExperimentalRspack}} - "ts-checker-rspack-plugin": "^1.2.0",{{/if}} + "ts-checker-rspack-plugin": "^1.4.0",{{/if}} "typescript": "5.9.2", "webpack": "^5.101.0"{{#unless useExperimentalRspack}}, "webpack-cli": "^6.0.0", @@ -70,7 +72,7 @@ "ws": "^8.13.0"{{/if}} }, "engines": { - "node": ">=22" + "node": ">=22.23" }, "dependencies": { "@emotion/css": "11.10.6",