From 86732379f65c74b0552f60d6b4500cc1f67f3c5d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 08:04:21 +0100 Subject: [PATCH 01/17] add bundle grafana ui addition --- .../src/codemods/additions/additions.ts | 5 + .../scripts/bundle-grafana-ui/README.md | 65 ++++ .../scripts/bundle-grafana-ui/index.test.ts | 240 ++++++++++++++ .../scripts/bundle-grafana-ui/index.ts | 312 ++++++++++++++++++ 4 files changed, 622 insertions(+) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 07a2a0352c..e8e4f6ef6d 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -6,4 +6,9 @@ export default [ description: 'Adds an example addition to the plugin', scriptPath: import.meta.resolve('./scripts/example-addition.js'), }, + { + name: 'bundle-grafana-ui', + description: 'Configures the plugin to bundle @grafana/ui instead of using the external provided by Grafana', + scriptPath: import.meta.resolve('./scripts/bundle-grafana-ui/index.js'), + }, ] satisfies Codemod[]; diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md new file mode 100644 index 0000000000..5f26f9da93 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md @@ -0,0 +1,65 @@ +# Bundle Grafana UI Addition + +Configures a Grafana plugin to bundle `@grafana/ui` instead of using the version of `@grafana/ui` provided by the Grafana runtime environment. + +## Usage + +```bash +npx @grafana/create-plugin add bundle-grafana-ui +``` + +## Requirements + +- **Grafana >= 10.2.0**: Bundling `@grafana/ui` is only supported from Grafana 10.2.0 onwards. If your plugin's `grafanaDependency` is set to a version lower than 10.2.0, the script will automatically update it to `>=10.2.0` and display a warning message. + +## What This Addition Does + +By default, Grafana plugins use `@grafana/ui` as an external dependency provided by Grafana at runtime. This addition modifies your plugin's bundler configuration to include `@grafana/ui` in your plugin bundle instead. + +This addition: + +1. **Updates `src/plugin.json`** - Ensures `grafanaDependency` is set to `>=10.2.0` or higher +2. **Removes `/^@grafana\/ui/i` from externals** - This tells the bundler to include `@grafana/ui` in your plugin bundle rather than expecting Grafana to provide it +3. **Adds `'react-inlinesvg'` to externals** - Since `@grafana/ui` uses `react-inlinesvg` internally and Grafana provides it at runtime, we add it to externals to avoid bundling it twice + +## When to Use This + +Consider bundling `@grafana/ui` when: + +- You want to ensure consistent behavior across different Grafana versions +- You're experiencing compatibility issues with the Grafana-provided `@grafana/ui` + +## Trade-offs + +**Pros:** + +- Full control over the `@grafana/ui` version your plugin uses +- Consistent behavior regardless of Grafana version + +**Cons:** + +- Larger plugin bundle size +- Potential visual inconsistencies if your bundled version differs significantly from Grafana's version +- You'll need to manually update `@grafana/ui` in your plugin dependencies + +## Files Modified + +``` +your-plugin/ +├── src/ +│ └── plugin.json # Modified: grafanaDependency updated if needed +├── .config/ +│ └── bundler/ +│ └── externals.ts # Modified: removes @grafana/ui, adds react-inlinesvg +``` + +Or for legacy structure: + +``` +your-plugin/ +├── src/ +│ └── plugin.json # Modified: grafanaDependency updated if needed +├── .config/ +│ └── webpack/ +│ └── webpack.config.ts # Modified: externals array updated +``` diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts new file mode 100644 index 0000000000..2d19b14a06 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; + +import { Context } from '../../../context.js'; +import bundleGrafanaUI from './index.js'; + +const EXTERNALS_PATH = '.config/bundler/externals.ts'; +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const PLUGIN_JSON_PATH = 'src/plugin.json'; + +const defaultExternalsContent = `import type { Configuration, ExternalItemFunctionData } from 'webpack'; + +type ExternalsType = Configuration['externals']; + +export const externals: ExternalsType = [ + { 'amd-module': 'module' }, + 'lodash', + 'react', + 'react-dom', + /^@grafana\\/ui/i, + /^@grafana\\/runtime/i, + /^@grafana\\/data/i, +];`; + +const webpackConfigWithExternals = `import type { Configuration } from 'webpack'; + +const baseConfig: Configuration = { + externals: [ + { 'amd-module': 'module' }, + 'lodash', + 'react', + /^@grafana\\/ui/i, + /^@grafana\\/runtime/i, + /^@grafana\\/data/i, + ], +}; + +export default baseConfig;`; + +describe('bundle-grafana-ui', () => { + describe('externals.ts (new structure)', () => { + it('should remove @grafana/ui from externals', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(EXTERNALS_PATH) || ''; + expect(content).not.toMatch(/\/\^@grafana\\\/ui\/i/); + }); + + it('should add react-inlinesvg to externals', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(EXTERNALS_PATH) || ''; + expect(content).toMatch(/['"]react-inlinesvg['"]/); + }); + + it('should preserve other externals', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(EXTERNALS_PATH) || ''; + expect(content).toContain("'lodash'"); + expect(content).toContain("'react'"); + expect(content).toMatch(/\/\^@grafana\\\/runtime\/i/); + expect(content).toMatch(/\/\^@grafana\\\/data\/i/); + }); + + it('should be idempotent', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + + const result1 = bundleGrafanaUI(context, {}); + const content1 = result1.getFile(EXTERNALS_PATH) || ''; + + // Verify first run removed @grafana/ui and added react-inlinesvg + expect(content1).not.toContain('@grafana\\/ui'); + expect(content1).toMatch(/['"]react-inlinesvg['"]/); + + const context2 = new Context('/virtual'); + context2.addFile(EXTERNALS_PATH, content1); + const result2 = bundleGrafanaUI(context2, {}); + + // Second run should produce identical content (idempotent) + const content2 = result2.getFile(EXTERNALS_PATH) || ''; + expect(content2).toBe(content1); + }); + }); + + describe('webpack.config.ts (legacy structure)', () => { + it('should remove @grafana/ui from externals in webpack config', () => { + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithExternals); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(WEBPACK_CONFIG_PATH) || ''; + expect(content).not.toMatch(/\/\^@grafana\\\/ui\/i/); + }); + + it('should add react-inlinesvg to externals in webpack config', () => { + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithExternals); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(WEBPACK_CONFIG_PATH) || ''; + expect(content).toMatch(/['"]react-inlinesvg['"]/); + }); + + it('should be idempotent for webpack config', () => { + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithExternals); + + const result1 = bundleGrafanaUI(context, {}); + const content1 = result1.getFile(WEBPACK_CONFIG_PATH) || ''; + + // Verify first run removed @grafana/ui and added react-inlinesvg + expect(content1).not.toContain('@grafana\\/ui'); + expect(content1).toMatch(/['"]react-inlinesvg['"]/); + + const context2 = new Context('/virtual'); + context2.addFile(WEBPACK_CONFIG_PATH, content1); + const result2 = bundleGrafanaUI(context2, {}); + + // Second run should produce identical content (idempotent) + const content2 = result2.getFile(WEBPACK_CONFIG_PATH) || ''; + expect(content2).toBe(content1); + }); + }); + + describe('priority', () => { + it('should prefer externals.ts over webpack.config.ts when both exist', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithExternals); + + const result = bundleGrafanaUI(context, {}); + + // externals.ts should be updated + const externalsContent = result.getFile(EXTERNALS_PATH) || ''; + expect(externalsContent).not.toMatch(/\/\^@grafana\\\/ui\/i/); + expect(externalsContent).toMatch(/['"]react-inlinesvg['"]/); + + // webpack.config.ts should NOT be updated (still has @grafana/ui) + const webpackContent = result.getFile(WEBPACK_CONFIG_PATH) || ''; + expect(webpackContent).toMatch(/\/\^@grafana\\\/ui\/i/); + }); + }); + + describe('no config files', () => { + it('should do nothing if no config files exist', () => { + const context = new Context('/virtual'); + + const result = bundleGrafanaUI(context, {}); + + expect(result.hasChanges()).toBe(false); + }); + }); + + describe('grafanaDependency version check', () => { + it('should bump grafanaDependency to 10.2.0 if lower', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + context.addFile( + PLUGIN_JSON_PATH, + JSON.stringify({ + id: 'test-plugin', + dependencies: { + grafanaDependency: '>=9.0.0', + }, + }) + ); + + const result = bundleGrafanaUI(context, {}); + + const pluginJson = JSON.parse(result.getFile(PLUGIN_JSON_PATH) || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=10.2.0'); + }); + + it('should not change grafanaDependency if already >= 10.2.0', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + context.addFile( + PLUGIN_JSON_PATH, + JSON.stringify({ + id: 'test-plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + + const result = bundleGrafanaUI(context, {}); + + const pluginJson = JSON.parse(result.getFile(PLUGIN_JSON_PATH) || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=11.0.0'); + }); + + it('should add dependencies object if missing', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + context.addFile( + PLUGIN_JSON_PATH, + JSON.stringify({ + id: 'test-plugin', + }) + ); + + const result = bundleGrafanaUI(context, {}); + + const pluginJson = JSON.parse(result.getFile(PLUGIN_JSON_PATH) || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=10.2.0'); + }); + + it('should handle version with exact match (10.2.0)', () => { + const context = new Context('/virtual'); + context.addFile(EXTERNALS_PATH, defaultExternalsContent); + context.addFile( + PLUGIN_JSON_PATH, + JSON.stringify({ + id: 'test-plugin', + dependencies: { + grafanaDependency: '>=10.2.0', + }, + }) + ); + + const result = bundleGrafanaUI(context, {}); + + const pluginJson = JSON.parse(result.getFile(PLUGIN_JSON_PATH) || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=10.2.0'); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts new file mode 100644 index 0000000000..c5dd99a359 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -0,0 +1,312 @@ +import * as v from 'valibot'; +import * as recast from 'recast'; +import * as typeScriptParser from 'recast/parsers/typescript.js'; +import { coerce, gte } from 'semver'; + +import type { Context } from '../../../context.js'; +import { additionsDebug } from '../../../utils.js'; + +const { builders } = recast.types; + +export const schema = v.object({}); + +type BundleGrafanaUIOptions = v.InferOutput; + +const EXTERNALS_PATH = '.config/bundler/externals.ts'; +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const PLUGIN_JSON_PATH = 'src/plugin.json'; +const MIN_GRAFANA_VERSION = '10.2.0'; + +/** + * Checks if an AST node is a regex matching @grafana/ui + * The pattern in the AST is "^@grafana\/ui" (backslash-escaped forward slash) + */ +function isGrafanaUiRegex(element: recast.types.namedTypes.ASTNode): boolean { + // Handle RegExpLiteral (TypeScript parser) + if (element.type === 'RegExpLiteral') { + const regexNode = element as recast.types.namedTypes.RegExpLiteral; + return regexNode.pattern === '^@grafana\\/ui' && regexNode.flags === 'i'; + } + // Handle Literal with regex property (other parsers) + if (element.type === 'Literal' && 'regex' in element && element.regex) { + const regex = element.regex as { pattern: string; flags: string }; + return regex.pattern === '^@grafana\\/ui' && regex.flags === 'i'; + } + return false; +} + +/** + * Checks if an AST node is a regex matching @grafana/data + * The pattern in the AST is "^@grafana\/data" (backslash-escaped forward slash) + */ +function isGrafanaDataRegex(element: recast.types.namedTypes.ASTNode): boolean { + // Handle RegExpLiteral (TypeScript parser) + if (element.type === 'RegExpLiteral') { + const regexNode = element as recast.types.namedTypes.RegExpLiteral; + return regexNode.pattern === '^@grafana\\/data' && regexNode.flags === 'i'; + } + // Handle Literal with regex property (other parsers) + if (element.type === 'Literal' && 'regex' in element && element.regex) { + const regex = element.regex as { pattern: string; flags: string }; + return regex.pattern === '^@grafana\\/data' && regex.flags === 'i'; + } + return false; +} + +/** + * Removes /^@grafana\/ui/i regex from externals array and adds 'react-inlinesvg' + * @returns true if changes were made, false otherwise + */ +function modifyExternalsArray(externalsArray: recast.types.namedTypes.ArrayExpression): boolean { + let hasChanges = false; + let hasGrafanaUiExternal = false; + let hasReactInlineSvg = false; + + // Check current state + for (const element of externalsArray.elements) { + if (!element) { + continue; + } + + // Check for /^@grafana\/ui/i regex + if (isGrafanaUiRegex(element)) { + hasGrafanaUiExternal = true; + } + + // Check for 'react-inlinesvg' string + if ( + (element.type === 'Literal' || element.type === 'StringLiteral') && + 'value' in element && + typeof element.value === 'string' && + element.value === 'react-inlinesvg' + ) { + hasReactInlineSvg = true; + } + } + + // Remove /^@grafana\/ui/i if present + if (hasGrafanaUiExternal) { + externalsArray.elements = externalsArray.elements.filter((element) => { + if (!element) { + return true; + } + return !isGrafanaUiRegex(element); + }); + hasChanges = true; + additionsDebug('Removed /^@grafana\\/ui/i from externals array'); + } + + // Add 'react-inlinesvg' if not present + if (!hasReactInlineSvg) { + // Find the index of /^@grafana\/data/i to insert after it + let insertIndex = -1; + for (let i = 0; i < externalsArray.elements.length; i++) { + const element = externalsArray.elements[i]; + if (element && isGrafanaDataRegex(element)) { + insertIndex = i + 1; + break; + } + } + + if (insertIndex >= 0) { + externalsArray.elements.splice(insertIndex, 0, builders.literal('react-inlinesvg')); + } else { + // Fallback: append to end + externalsArray.elements.push(builders.literal('react-inlinesvg')); + } + hasChanges = true; + additionsDebug("Added 'react-inlinesvg' to externals array"); + } + + return hasChanges; +} + +function updateExternalsFile(context: Context): boolean { + if (!context.doesFileExist(EXTERNALS_PATH)) { + additionsDebug(`${EXTERNALS_PATH} not found, skipping`); + return false; + } + + const externalsContent = context.getFile(EXTERNALS_PATH); + if (!externalsContent) { + return false; + } + + try { + const ast = recast.parse(externalsContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + + recast.visit(ast, { + visitVariableDeclarator(path) { + const { node } = path; + + if ( + node.id.type === 'Identifier' && + node.id.name === 'externals' && + node.init && + node.init.type === 'ArrayExpression' + ) { + additionsDebug('Found externals array in externals.ts'); + if (modifyExternalsArray(node.init)) { + hasChanges = true; + } + } + + return this.traverse(path); + }, + }); + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(EXTERNALS_PATH, output.code); + additionsDebug(`Updated ${EXTERNALS_PATH}`); + } + + return hasChanges; + } catch (error) { + additionsDebug(`Error updating ${EXTERNALS_PATH}:`, error); + return false; + } +} + +function updateWebpackConfigFile(context: Context): boolean { + if (!context.doesFileExist(WEBPACK_CONFIG_PATH)) { + additionsDebug(`${WEBPACK_CONFIG_PATH} not found, skipping`); + return false; + } + + const webpackContent = context.getFile(WEBPACK_CONFIG_PATH); + if (!webpackContent) { + return false; + } + + try { + const ast = recast.parse(webpackContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + let foundExternals = false; + + recast.visit(ast, { + visitObjectExpression(path) { + const { node } = path; + const properties = node.properties; + + if (properties) { + for (const prop of properties) { + if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + if ( + key && + key.type === 'Identifier' && + key.name === 'externals' && + value && + value.type === 'ArrayExpression' + ) { + foundExternals = true; + additionsDebug('Found externals property in webpack.config.ts'); + if (modifyExternalsArray(value)) { + hasChanges = true; + } + } + } + } + } + + return this.traverse(path); + }, + }); + + if (!foundExternals) { + additionsDebug('No externals property found in webpack.config.ts'); + } + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(WEBPACK_CONFIG_PATH, output.code); + additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`); + } + + return hasChanges; + } catch (error) { + additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error); + return false; + } +} + +/** + * Ensures plugin.json has grafanaDependency >= 10.4.0 + * Bundling @grafana/ui is only supported from Grafana 10.4.0 onwards + */ +function ensureMinGrafanaVersion(context: Context): void { + if (!context.doesFileExist(PLUGIN_JSON_PATH)) { + additionsDebug(`${PLUGIN_JSON_PATH} not found, skipping version check`); + return; + } + + const pluginJsonRaw = context.getFile(PLUGIN_JSON_PATH); + if (!pluginJsonRaw) { + return; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + + if (!pluginJson.dependencies) { + pluginJson.dependencies = {}; + } + + const currentGrafanaDep = pluginJson.dependencies.grafanaDependency || '>=9.0.0'; + const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); + const minVersion = coerce(MIN_GRAFANA_VERSION); + + if (!currentVersion || !minVersion || !gte(currentVersion, minVersion)) { + const oldVersion = pluginJson.dependencies.grafanaDependency || 'not set'; + pluginJson.dependencies.grafanaDependency = `>=${MIN_GRAFANA_VERSION}`; + context.updateFile(PLUGIN_JSON_PATH, JSON.stringify(pluginJson, null, 2)); + additionsDebug( + `Updated grafanaDependency from "${oldVersion}" to ">=${MIN_GRAFANA_VERSION}" - bundling @grafana/ui requires Grafana ${MIN_GRAFANA_VERSION} or higher` + ); + console.log( + `\n⚠️ Updated grafanaDependency to ">=${MIN_GRAFANA_VERSION}" because bundling @grafana/ui is only supported from Grafana ${MIN_GRAFANA_VERSION} onwards.\n` + ); + } else { + additionsDebug( + `grafanaDependency "${currentGrafanaDep}" already meets minimum requirement of ${MIN_GRAFANA_VERSION}` + ); + } + } catch (error) { + additionsDebug(`Error updating ${PLUGIN_JSON_PATH}:`, error); + } +} + +export default function bundleGrafanaUI(context: Context, _options: BundleGrafanaUIOptions): Context { + additionsDebug('Running bundle-grafana-ui addition...'); + + // Ensure minimum Grafana version requirement + ensureMinGrafanaVersion(context); + + // Try new structure first: .config/bundler/externals.ts + const updatedExternals = updateExternalsFile(context); + + // Fall back to legacy structure: .config/webpack/webpack.config.ts with inline externals + if (!updatedExternals) { + updateWebpackConfigFile(context); + } + + return context; +} From 849dce50e9c3344d8812b1db8d6bbd5cf6cb0d81 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 12:52:04 +0100 Subject: [PATCH 02/17] break out externals mod to utility func --- .../scripts/bundle-grafana-ui/index.ts | 146 ++------------ .../src/codemods/utils.externals.test.ts | 87 +++++++++ .../src/codemods/utils.externals.ts | 181 ++++++++++++++++++ 3 files changed, 279 insertions(+), 135 deletions(-) create mode 100644 packages/create-plugin/src/codemods/utils.externals.test.ts create mode 100644 packages/create-plugin/src/codemods/utils.externals.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index c5dd99a359..851af64744 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -1,10 +1,10 @@ import * as v from 'valibot'; import * as recast from 'recast'; -import * as typeScriptParser from 'recast/parsers/typescript.js'; import { coerce, gte } from 'semver'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; +import { updateExternalsArray, type ExternalsArrayModifier } from '../../../utils.externals.js'; const { builders } = recast.types; @@ -12,8 +12,6 @@ export const schema = v.object({}); type BundleGrafanaUIOptions = v.InferOutput; -const EXTERNALS_PATH = '.config/bundler/externals.ts'; -const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; const PLUGIN_JSON_PATH = 'src/plugin.json'; const MIN_GRAFANA_VERSION = '10.2.0'; @@ -121,131 +119,14 @@ function modifyExternalsArray(externalsArray: recast.types.namedTypes.ArrayExpre return hasChanges; } -function updateExternalsFile(context: Context): boolean { - if (!context.doesFileExist(EXTERNALS_PATH)) { - additionsDebug(`${EXTERNALS_PATH} not found, skipping`); - return false; - } - - const externalsContent = context.getFile(EXTERNALS_PATH); - if (!externalsContent) { - return false; - } - - try { - const ast = recast.parse(externalsContent, { - parser: typeScriptParser, - }); - - let hasChanges = false; - - recast.visit(ast, { - visitVariableDeclarator(path) { - const { node } = path; - - if ( - node.id.type === 'Identifier' && - node.id.name === 'externals' && - node.init && - node.init.type === 'ArrayExpression' - ) { - additionsDebug('Found externals array in externals.ts'); - if (modifyExternalsArray(node.init)) { - hasChanges = true; - } - } - - return this.traverse(path); - }, - }); - - if (hasChanges) { - const output = recast.print(ast, { - tabWidth: 2, - trailingComma: true, - lineTerminator: '\n', - }); - context.updateFile(EXTERNALS_PATH, output.code); - additionsDebug(`Updated ${EXTERNALS_PATH}`); - } - - return hasChanges; - } catch (error) { - additionsDebug(`Error updating ${EXTERNALS_PATH}:`, error); - return false; - } -} - -function updateWebpackConfigFile(context: Context): boolean { - if (!context.doesFileExist(WEBPACK_CONFIG_PATH)) { - additionsDebug(`${WEBPACK_CONFIG_PATH} not found, skipping`); - return false; - } - - const webpackContent = context.getFile(WEBPACK_CONFIG_PATH); - if (!webpackContent) { - return false; - } - - try { - const ast = recast.parse(webpackContent, { - parser: typeScriptParser, - }); - - let hasChanges = false; - let foundExternals = false; - - recast.visit(ast, { - visitObjectExpression(path) { - const { node } = path; - const properties = node.properties; - - if (properties) { - for (const prop of properties) { - if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { - const key = 'key' in prop ? prop.key : null; - const value = 'value' in prop ? prop.value : null; - - if ( - key && - key.type === 'Identifier' && - key.name === 'externals' && - value && - value.type === 'ArrayExpression' - ) { - foundExternals = true; - additionsDebug('Found externals property in webpack.config.ts'); - if (modifyExternalsArray(value)) { - hasChanges = true; - } - } - } - } - } - - return this.traverse(path); - }, - }); - - if (!foundExternals) { - additionsDebug('No externals property found in webpack.config.ts'); - } - - if (hasChanges) { - const output = recast.print(ast, { - tabWidth: 2, - trailingComma: true, - lineTerminator: '\n', - }); - context.updateFile(WEBPACK_CONFIG_PATH, output.code); - additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`); - } - - return hasChanges; - } catch (error) { - additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error); - return false; - } +/** + * Creates a modifier function for updateExternalsArray that removes @grafana/ui + * and adds react-inlinesvg + */ +function createBundleGrafanaUIModifier(): ExternalsArrayModifier { + return (externalsArray: recast.types.namedTypes.ArrayExpression) => { + return modifyExternalsArray(externalsArray); + }; } /** @@ -300,13 +181,8 @@ export default function bundleGrafanaUI(context: Context, _options: BundleGrafan // Ensure minimum Grafana version requirement ensureMinGrafanaVersion(context); - // Try new structure first: .config/bundler/externals.ts - const updatedExternals = updateExternalsFile(context); - - // Fall back to legacy structure: .config/webpack/webpack.config.ts with inline externals - if (!updatedExternals) { - updateWebpackConfigFile(context); - } + // Update externals array using the shared utility + updateExternalsArray(context, createBundleGrafanaUIModifier()); return context; } diff --git a/packages/create-plugin/src/codemods/utils.externals.test.ts b/packages/create-plugin/src/codemods/utils.externals.test.ts new file mode 100644 index 0000000000..01e75b391d --- /dev/null +++ b/packages/create-plugin/src/codemods/utils.externals.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import * as recast from 'recast'; + +import { Context } from './context.js'; +import { updateExternalsArray, type ExternalsArrayModifier } from './utils.externals.js'; + +describe('updateExternalsArray', () => { + describe('new structure (.config/bundler/externals.ts)', () => { + it('should update externals array in externals.ts', () => { + const context = new Context('/virtual'); + context.addFile('.config/bundler/externals.ts', `export const externals = ['react', 'react-dom'];`); + + const modifier: ExternalsArrayModifier = (array) => { + array.elements.push(recast.types.builders.literal('i18next')); + return true; + }; + + const result = updateExternalsArray(context, modifier); + + expect(result).toBe(true); + const content = context.getFile('.config/bundler/externals.ts') || ''; + expect(content).toMatch(/['"]i18next['"]/); + expect(content).toContain("'react'"); + expect(content).toContain("'react-dom'"); + }); + + it('should return false if no changes were made', () => { + const context = new Context('/virtual'); + context.addFile('.config/bundler/externals.ts', `export const externals = ['react', 'react-dom'];`); + + const modifier: ExternalsArrayModifier = () => { + return false; // No changes + }; + + const result = updateExternalsArray(context, modifier); + + expect(result).toBe(false); + }); + }); + + describe('legacy structure (.config/webpack/webpack.config.ts)', () => { + it('should update externals array in webpack.config.ts when externals.ts does not exist', () => { + const context = new Context('/virtual'); + context.addFile( + '.config/webpack/webpack.config.ts', + `import { Configuration } from 'webpack'; +export const config: Configuration = { + externals: ['react', 'react-dom'], +};` + ); + + const modifier: ExternalsArrayModifier = (array) => { + array.elements.push(recast.types.builders.literal('i18next')); + return true; + }; + + const result = updateExternalsArray(context, modifier); + + expect(result).toBe(true); + const content = context.getFile('.config/webpack/webpack.config.ts') || ''; + expect(content).toMatch(/['"]i18next['"]/); + expect(content).toContain("'react'"); + expect(content).toContain("'react-dom'"); + }); + + it('should prefer externals.ts over webpack.config.ts', () => { + const context = new Context('/virtual'); + context.addFile('.config/bundler/externals.ts', `export const externals = ['react'];`); + context.addFile('.config/webpack/webpack.config.ts', `export const config = { externals: ['react-dom'] };`); + + const modifier: ExternalsArrayModifier = (array) => { + array.elements.push(recast.types.builders.literal('i18next')); + return true; + }; + + const result = updateExternalsArray(context, modifier); + + expect(result).toBe(true); + // Should update externals.ts, not webpack.config.ts + const externalsContent = context.getFile('.config/bundler/externals.ts') || ''; + expect(externalsContent).toMatch(/['"]i18next['"]/); + + const webpackContent = context.getFile('.config/webpack/webpack.config.ts') || ''; + expect(webpackContent).not.toMatch(/['"]i18next['"]/); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/utils.externals.ts b/packages/create-plugin/src/codemods/utils.externals.ts new file mode 100644 index 0000000000..723cee76eb --- /dev/null +++ b/packages/create-plugin/src/codemods/utils.externals.ts @@ -0,0 +1,181 @@ +import * as recast from 'recast'; +import * as typeScriptParser from 'recast/parsers/typescript.js'; + +import type { Context } from './context.js'; +import { additionsDebug } from './utils.js'; + +/** + * Utility for updating externals arrays in plugin bundler configurations. + * + * This utility is needed because the location of externals configuration has changed over time: + * - **New plugins** (created with recent versions of @grafana/create-plugin): Externals are defined + * in a separate file at `.config/bundler/externals.ts` + * - **Older plugins** (created with earlier versions): Externals are defined inline within + * `.config/webpack/webpack.config.ts` as part of the webpack Configuration object + * + * This utility handles both cases automatically, preferring the modern structure when both exist, + * to ensure additions and migrations work correctly regardless of when the plugin was created. + */ + +/** + * Type for a function that modifies an externals array + * @param externalsArray - The AST node representing the externals array + * @returns true if changes were made, false otherwise + */ +export type ExternalsArrayModifier = (externalsArray: recast.types.namedTypes.ArrayExpression) => boolean; + +const EXTERNALS_PATH = '.config/bundler/externals.ts'; +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; + +/** + * Updates the externals array in either .config/bundler/externals.ts (preferred) or + * .config/webpack/webpack.config.ts (legacy fallback). + * + * @param context - The codemod context + * @param modifier - A function that modifies the externals array and returns true if changes were made + * @returns true if changes were made to any file, false otherwise + */ +export function updateExternalsArray(context: Context, modifier: ExternalsArrayModifier): boolean { + // Try new structure first: .config/bundler/externals.ts + if (context.doesFileExist(EXTERNALS_PATH)) { + additionsDebug(`Found ${EXTERNALS_PATH}, updating externals array...`); + const externalsContent = context.getFile(EXTERNALS_PATH); + if (externalsContent) { + try { + const ast = recast.parse(externalsContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + + recast.visit(ast, { + visitVariableDeclarator(path) { + const { node } = path; + + if ( + node.id.type === 'Identifier' && + node.id.name === 'externals' && + node.init && + node.init.type === 'ArrayExpression' + ) { + additionsDebug('Found externals array in externals.ts'); + if (modifier(node.init)) { + hasChanges = true; + } + } + + return this.traverse(path); + }, + }); + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(EXTERNALS_PATH, output.code); + additionsDebug(`Updated ${EXTERNALS_PATH}`); + return true; + } + return false; + } catch (error) { + additionsDebug(`Error updating ${EXTERNALS_PATH}:`, error); + return false; + } + } + } + + // Fall back to legacy structure: .config/webpack/webpack.config.ts with inline externals + additionsDebug(`Checking for ${WEBPACK_CONFIG_PATH}...`); + if (context.doesFileExist(WEBPACK_CONFIG_PATH)) { + additionsDebug(`Found ${WEBPACK_CONFIG_PATH}, checking for inline externals...`); + const webpackContent = context.getFile(WEBPACK_CONFIG_PATH); + if (webpackContent) { + try { + const ast = recast.parse(webpackContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + let foundExternals = false; + + recast.visit(ast, { + visitObjectExpression(path) { + const { node } = path; + const properties = node.properties; + + if (properties) { + for (const prop of properties) { + if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + if ( + key && + key.type === 'Identifier' && + key.name === 'externals' && + value && + value.type === 'ArrayExpression' + ) { + foundExternals = true; + additionsDebug('Found externals property in webpack.config.ts'); + if (modifier(value)) { + hasChanges = true; + } + } + } + } + } + + return this.traverse(path); + }, + visitProperty(path) { + const { node } = path; + + // Also check properties directly (fallback) + if ( + node.key && + node.key.type === 'Identifier' && + node.key.name === 'externals' && + node.value && + node.value.type === 'ArrayExpression' + ) { + if (!foundExternals) { + foundExternals = true; + additionsDebug('Found externals property in webpack.config.ts (via visitProperty)'); + } + if (modifier(node.value)) { + hasChanges = true; + } + } + + return this.traverse(path); + }, + }); + + if (!foundExternals) { + additionsDebug('No externals property found in webpack.config.ts'); + } + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(WEBPACK_CONFIG_PATH, output.code); + additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`); + return true; + } + return false; + } catch (error) { + additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error); + return false; + } + } + } + + additionsDebug('No externals configuration found'); + return false; +} From 454a21030e887534a232c9eac4b3fb06c5cadc4d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 13:06:53 +0100 Subject: [PATCH 03/17] rollup support subdirs --- rollup.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollup.config.ts b/rollup.config.ts index 9a2fd089a2..5a0967cae0 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -39,7 +39,7 @@ if (pkg.name === '@grafana/create-plugin') { ignore: ['**/*.test.ts'], absolute: true, }; - const codeMods = glob.sync('{migrations,additions}/scripts/*.ts', codeModsGlobOptions).map((m) => m.toString()); + const codeMods = glob.sync('{migrations,additions}/scripts/**/*.ts', codeModsGlobOptions).map((m) => m.toString()); input.push(...codeMods); external.push('prettier'); From 68be927b0e4bbdaa0391e85898949fcc45f5a6c7 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 13:13:51 +0100 Subject: [PATCH 04/17] remove example script --- packages/create-plugin/src/codemods/additions/additions.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index e8e4f6ef6d..3f116d18bd 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -1,11 +1,6 @@ import { Codemod } from '../types.js'; export default [ - { - name: 'example-addition', - description: 'Adds an example addition to the plugin', - scriptPath: import.meta.resolve('./scripts/example-addition.js'), - }, { name: 'bundle-grafana-ui', description: 'Configures the plugin to bundle @grafana/ui instead of using the external provided by Grafana', From f9fed96d0eb8c68be1e17a7eab1cafadc1af61bb Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 13:14:11 +0100 Subject: [PATCH 05/17] remove code related to legacy feature toggle --- packages/create-plugin/src/constants.ts | 1 - packages/create-plugin/src/types.ts | 1 - packages/create-plugin/src/utils/utils.config.ts | 1 - packages/create-plugin/src/utils/utils.templates.ts | 3 --- 4 files changed, 6 deletions(-) diff --git a/packages/create-plugin/src/constants.ts b/packages/create-plugin/src/constants.ts index 239f89d320..6520a005c1 100644 --- a/packages/create-plugin/src/constants.ts +++ b/packages/create-plugin/src/constants.ts @@ -49,7 +49,6 @@ export const EXTRA_TEMPLATE_VARIABLES = { }; export const DEFAULT_FEATURE_FLAGS = { - bundleGrafanaUI: false, useExperimentalRspack: false, useExperimentalUpdates: true, }; diff --git a/packages/create-plugin/src/types.ts b/packages/create-plugin/src/types.ts index b1706ca18e..b034096b9a 100644 --- a/packages/create-plugin/src/types.ts +++ b/packages/create-plugin/src/types.ts @@ -21,7 +21,6 @@ export type TemplateData = { isAppType: boolean; isNPM: boolean; version: string; - bundleGrafanaUI: boolean; scenesVersion: string; useExperimentalRspack: boolean; pluginExecutable?: string; diff --git a/packages/create-plugin/src/utils/utils.config.ts b/packages/create-plugin/src/utils/utils.config.ts index d547be96f1..e67d82a561 100644 --- a/packages/create-plugin/src/utils/utils.config.ts +++ b/packages/create-plugin/src/utils/utils.config.ts @@ -10,7 +10,6 @@ import { writeFile } from 'node:fs/promises'; import { EOL } from 'node:os'; export type FeatureFlags = { - bundleGrafanaUI?: boolean; useExperimentalRspack?: boolean; useExperimentalUpdates?: boolean; }; diff --git a/packages/create-plugin/src/utils/utils.templates.ts b/packages/create-plugin/src/utils/utils.templates.ts index fb87cb91f0..19ab3abf06 100644 --- a/packages/create-plugin/src/utils/utils.templates.ts +++ b/packages/create-plugin/src/utils/utils.templates.ts @@ -95,7 +95,6 @@ export function renderTemplateFromFile(templateFile: string, data?: any) { export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { const { features } = getConfig(); const currentVersion = CURRENT_APP_VERSION; - const bundleGrafanaUI = features.bundleGrafanaUI ?? DEFAULT_FEATURE_FLAGS.bundleGrafanaUI; const isAppType = (pluginType: string) => pluginType === PLUGIN_TYPES.app || pluginType === PLUGIN_TYPES.scenes; const isNPM = (packageManagerName: string) => packageManagerName === 'npm'; const frontendBundler = features.useExperimentalRspack ? 'rspack' : 'webpack'; @@ -120,7 +119,6 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { isAppType: isAppType(cliArgs.pluginType), isNPM: isNPM(packageManagerName), version: currentVersion, - bundleGrafanaUI, scenesVersion: '^6.10.4', useExperimentalRspack: Boolean(features.useExperimentalRspack), frontendBundler, @@ -144,7 +142,6 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { isAppType: isAppType(pluginJson.type), isNPM: isNPM(packageManagerName), version: currentVersion, - bundleGrafanaUI, scenesVersion: '^6.10.4', pluginExecutable: pluginJson.executable, useExperimentalRspack: Boolean(features.useExperimentalRspack), From 28f39ba9f29dc80bcb74e608f076da6f72773d94 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 13:27:39 +0100 Subject: [PATCH 06/17] renome var --- packages/create-plugin/templates/common/.cprc.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/create-plugin/templates/common/.cprc.json b/packages/create-plugin/templates/common/.cprc.json index 6859b6ff02..014e2a8cfb 100644 --- a/packages/create-plugin/templates/common/.cprc.json +++ b/packages/create-plugin/templates/common/.cprc.json @@ -1,6 +1,10 @@ { "features": { +<<<<<<< HEAD "bundleGrafanaUI": {{ bundleGrafanaUI }}, +======= + "useReactRouterV6": {{ useReactRouterV6 }}, +>>>>>>> 91a41873 (renome var) "useExperimentalRspack": {{ useExperimentalRspack }} } } From cae682b15d05133173bf21205d95c944b12a1109 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 13:36:30 +0100 Subject: [PATCH 07/17] fix broken tests --- .../src/utils/tests/utils.config.test.ts | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/packages/create-plugin/src/utils/tests/utils.config.test.ts b/packages/create-plugin/src/utils/tests/utils.config.test.ts index 082cf12dce..2c79f922d9 100644 --- a/packages/create-plugin/src/utils/tests/utils.config.test.ts +++ b/packages/create-plugin/src/utils/tests/utils.config.test.ts @@ -46,13 +46,13 @@ describe('getConfig', () => { it('should override default feature flags via cli args', async () => { mocks.argv = { - 'feature-flags': 'bundleGrafanaUI', + 'feature-flags': 'useExperimentalRspack', }; const config = getConfig(tmpDir); expect(config).toEqual({ version: CURRENT_APP_VERSION, - features: { ...DEFAULT_FEATURE_FLAGS, bundleGrafanaUI: true }, + features: { ...DEFAULT_FEATURE_FLAGS, useExperimentalRspack: true }, }); }); }); @@ -94,7 +94,7 @@ describe('getConfig', () => { const userConfigPath = path.join(tmpDir, '.cprc.json'); const userConfig: UserConfig = { features: { - bundleGrafanaUI: true, + useExperimentalRspack: true, }, }; @@ -107,27 +107,5 @@ describe('getConfig', () => { features: userConfig.features, }); }); - - it('should give back the correct config when config files exist', async () => { - const rootConfigPath = path.join(tmpDir, '.config', '.cprc.json'); - const userConfigPath = path.join(tmpDir, '.cprc.json'); - const rootConfig: CreatePluginConfig = { - version: '1.0.0', - features: {}, - }; - const userConfig: UserConfig = { - features: { - bundleGrafanaUI: false, - }, - }; - - await fs.mkdir(path.dirname(rootConfigPath), { recursive: true }); - await fs.writeFile(rootConfigPath, JSON.stringify(rootConfig)); - await fs.writeFile(userConfigPath, JSON.stringify(userConfig)); - - const config = getConfig(tmpDir); - - expect(config).toEqual({ ...rootConfig, ...userConfig }); - }); }); }); From 84f87015eae9abc0b8f13366647ed7006173c69e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 7 Jan 2026 13:49:00 +0100 Subject: [PATCH 08/17] bundle-grafana-ui addition with ESM resolution fix --- .../scripts/bundle-grafana-ui/README.md | 9 +- .../scripts/bundle-grafana-ui/index.test.ts | 183 +++++++++++ .../scripts/bundle-grafana-ui/index.ts | 304 +++++++++++++++++- 3 files changed, 491 insertions(+), 5 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md index 5f26f9da93..f71fa03cc3 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md @@ -21,6 +21,7 @@ This addition: 1. **Updates `src/plugin.json`** - Ensures `grafanaDependency` is set to `>=10.2.0` or higher 2. **Removes `/^@grafana\/ui/i` from externals** - This tells the bundler to include `@grafana/ui` in your plugin bundle rather than expecting Grafana to provide it 3. **Adds `'react-inlinesvg'` to externals** - Since `@grafana/ui` uses `react-inlinesvg` internally and Grafana provides it at runtime, we add it to externals to avoid bundling it twice +4. **Updates bundler resolve configuration** - Adds `.mjs` to `resolve.extensions` and sets `resolve.fullySpecified: false` to handle ESM imports from `@grafana/ui` and its dependencies (e.g., `rc-picker`, `ol/format/WKT`) ## When to Use This @@ -49,8 +50,10 @@ your-plugin/ ├── src/ │ └── plugin.json # Modified: grafanaDependency updated if needed ├── .config/ -│ └── bundler/ -│ └── externals.ts # Modified: removes @grafana/ui, adds react-inlinesvg +│ ├── bundler/ +│ │ └── externals.ts # Modified: removes @grafana/ui, adds react-inlinesvg +│ └── rspack/ +│ └── rspack.config.ts # Modified: resolve.extensions and resolve.fullySpecified updated ``` Or for legacy structure: @@ -61,5 +64,5 @@ your-plugin/ │ └── plugin.json # Modified: grafanaDependency updated if needed ├── .config/ │ └── webpack/ -│ └── webpack.config.ts # Modified: externals array updated +│ └── webpack.config.ts # Modified: externals array and resolve configuration updated ``` diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts index 2d19b14a06..5d6a52b17f 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts @@ -5,6 +5,7 @@ import bundleGrafanaUI from './index.js'; const EXTERNALS_PATH = '.config/bundler/externals.ts'; const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; const PLUGIN_JSON_PATH = 'src/plugin.json'; const defaultExternalsContent = `import type { Configuration, ExternalItemFunctionData } from 'webpack'; @@ -237,4 +238,186 @@ describe('bundle-grafana-ui', () => { expect(pluginJson.dependencies.grafanaDependency).toBe('>=10.2.0'); }); }); + + describe('resolve configuration updates', () => { + const webpackConfigWithResolve = `import type { Configuration } from 'webpack'; + +const baseConfig: Configuration = { + module: { + rules: [], + }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + modules: ['node_modules'], + }, +}; + +export default baseConfig;`; + + const rspackConfigWithResolve = `import type { Configuration } from '@rspack/core'; + +const baseConfig: Configuration = { + module: { + rules: [], + }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + modules: ['node_modules'], + }, +}; + +export default baseConfig;`; + + describe('webpack.config.ts', () => { + it('should add .mjs to resolve.extensions', () => { + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithResolve); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(WEBPACK_CONFIG_PATH) || ''; + expect(content).toMatch( + /extensions:\s*\[['"]\.js['"],\s*['"]\.jsx['"],\s*['"]\.ts['"],\s*['"]\.tsx['"],\s*['"]\.mjs['"]/ + ); + }); + + it('should add module rule for .mjs files with resolve.fullySpecified: false', () => { + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithResolve); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(WEBPACK_CONFIG_PATH) || ''; + // Check for module rule with .mjs test, node_modules include, and resolve.fullySpecified: false + expect(content).toMatch(/test:\s*\/\\\.mjs\$/); + expect(content).toMatch(/include:\s*\/node_modules\//); + expect(content).toMatch(/resolve:\s*\{[^}]*fullySpecified:\s*false/); + }); + + it('should be idempotent for resolve configuration', () => { + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithResolve); + + const result1 = bundleGrafanaUI(context, {}); + const content1 = result1.getFile(WEBPACK_CONFIG_PATH) || ''; + + // Verify first run added .mjs to extensions and module rule + expect(content1).toMatch(/['"]\.mjs['"]/); + expect(content1).toMatch(/test:\s*\/\\\.mjs\$/); + expect(content1).toMatch(/resolve:\s*\{[^}]*fullySpecified:\s*false/); + + const context2 = new Context('/virtual'); + context2.addFile(WEBPACK_CONFIG_PATH, content1); + const result2 = bundleGrafanaUI(context2, {}); + + // Second run should produce identical content (idempotent) + const content2 = result2.getFile(WEBPACK_CONFIG_PATH) || ''; + expect(content2).toBe(content1); + }); + + it('should not duplicate .mjs if already present', () => { + const webpackConfigWithMjs = `import type { Configuration } from 'webpack'; + +const baseConfig: Configuration = { + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs'], + modules: ['node_modules'], + }, +}; + +export default baseConfig;`; + + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithMjs); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(WEBPACK_CONFIG_PATH) || ''; + // Count occurrences of .mjs in extensions array + const mjsMatches = content.match(/['"]\.mjs['"]/g); + expect(mjsMatches?.length).toBe(1); + }); + + it('should not duplicate .mjs module rule if already present', () => { + const webpackConfigWithMjsRule = `import type { Configuration } from 'webpack'; + +const baseConfig: Configuration = { + module: { + rules: [ + { + test: /\\.mjs$/, + include: /node_modules/, + resolve: { + fullySpecified: false, + }, + type: 'javascript/auto', + }, + ], + }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + modules: ['node_modules'], + }, +}; + +export default baseConfig;`; + + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithMjsRule); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(WEBPACK_CONFIG_PATH) || ''; + // Count occurrences of .mjs test pattern in module rules + const mjsRuleMatches = content.match(/test:\s*\/\\\.mjs\$/g); + expect(mjsRuleMatches?.length).toBe(1); + }); + }); + + describe('rspack.config.ts', () => { + it('should add .mjs to resolve.extensions', () => { + const context = new Context('/virtual'); + context.addFile(RSPACK_CONFIG_PATH, rspackConfigWithResolve); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(RSPACK_CONFIG_PATH) || ''; + expect(content).toMatch( + /extensions:\s*\[['"]\.js['"],\s*['"]\.jsx['"],\s*['"]\.ts['"],\s*['"]\.tsx['"],\s*['"]\.mjs['"]/ + ); + }); + + it('should add module rule for .mjs files with resolve.fullySpecified: false', () => { + const context = new Context('/virtual'); + context.addFile(RSPACK_CONFIG_PATH, rspackConfigWithResolve); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(RSPACK_CONFIG_PATH) || ''; + // Check for module rule with .mjs test, node_modules include, and resolve.fullySpecified: false + expect(content).toMatch(/test:\s*\/\\\.mjs\$/); + expect(content).toMatch(/include:\s*\/node_modules\//); + expect(content).toMatch(/resolve:\s*\{[^}]*fullySpecified:\s*false/); + }); + + it('should prefer rspack.config.ts over webpack.config.ts when both exist', () => { + const context = new Context('/virtual'); + context.addFile(RSPACK_CONFIG_PATH, rspackConfigWithResolve); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithResolve); + + const result = bundleGrafanaUI(context, {}); + + // rspack.config.ts should be updated + const rspackContent = result.getFile(RSPACK_CONFIG_PATH) || ''; + expect(rspackContent).toMatch(/['"]\.mjs['"]/); + expect(rspackContent).toMatch(/test:\s*\/\\\.mjs\$/); + expect(rspackContent).toMatch(/resolve:\s*\{[^}]*fullySpecified:\s*false/); + + // webpack.config.ts should NOT be updated + const webpackContent = result.getFile(WEBPACK_CONFIG_PATH) || ''; + expect(webpackContent).not.toMatch(/['"]\.mjs['"]/); + expect(webpackContent).not.toMatch(/test:\s*\/\\\.mjs\$/); + }); + }); + }); }); diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index 851af64744..04b1dfebe3 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -1,5 +1,6 @@ import * as v from 'valibot'; import * as recast from 'recast'; +import * as typeScriptParser from 'recast/parsers/typescript.js'; import { coerce, gte } from 'semver'; import type { Context } from '../../../context.js'; @@ -14,6 +15,8 @@ type BundleGrafanaUIOptions = v.InferOutput; const PLUGIN_JSON_PATH = 'src/plugin.json'; const MIN_GRAFANA_VERSION = '10.2.0'; +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; /** * Checks if an AST node is a regex matching @grafana/ui @@ -130,8 +133,302 @@ function createBundleGrafanaUIModifier(): ExternalsArrayModifier { } /** - * Ensures plugin.json has grafanaDependency >= 10.4.0 - * Bundling @grafana/ui is only supported from Grafana 10.4.0 onwards + * Updates the bundler's resolve configuration to handle ESM imports from @grafana/ui + * - Adds '.mjs' to resolve.extensions + * - Adds fullySpecified: false to allow extensionless imports in node_modules + */ +function updateBundlerResolveConfig(context: Context): void { + // Try rspack config first (newer structure) + if (context.doesFileExist(RSPACK_CONFIG_PATH)) { + additionsDebug(`Found ${RSPACK_CONFIG_PATH}, updating resolve configuration...`); + const rspackContent = context.getFile(RSPACK_CONFIG_PATH); + if (rspackContent) { + try { + const ast = recast.parse(rspackContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + + recast.visit(ast, { + visitObjectExpression(path) { + const { node } = path; + const properties = node.properties; + + if (properties) { + for (const prop of properties) { + if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + // Find the resolve property + if ( + key && + key.type === 'Identifier' && + key.name === 'resolve' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = updateResolveObject(value) || hasChanges; + } + + // Find the module property to add .mjs rule + if ( + key && + key.type === 'Identifier' && + key.name === 'module' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = updateModuleRules(value) || hasChanges; + } + } + } + } + + return this.traverse(path); + }, + }); + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(RSPACK_CONFIG_PATH, output.code); + additionsDebug(`Updated ${RSPACK_CONFIG_PATH}`); + } + } catch (error) { + additionsDebug(`Error updating ${RSPACK_CONFIG_PATH}:`, error); + } + } + return; + } + + // Fall back to webpack config (legacy structure) + if (context.doesFileExist(WEBPACK_CONFIG_PATH)) { + additionsDebug(`Found ${WEBPACK_CONFIG_PATH}, updating resolve configuration...`); + const webpackContent = context.getFile(WEBPACK_CONFIG_PATH); + if (webpackContent) { + try { + const ast = recast.parse(webpackContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + + recast.visit(ast, { + visitObjectExpression(path) { + const { node } = path; + const properties = node.properties; + + if (properties) { + for (const prop of properties) { + if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + // Find the resolve property + if ( + key && + key.type === 'Identifier' && + key.name === 'resolve' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = updateResolveObject(value) || hasChanges; + } + + // Find the module property to add .mjs rule + if ( + key && + key.type === 'Identifier' && + key.name === 'module' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = updateModuleRules(value) || hasChanges; + } + } + } + } + + return this.traverse(path); + }, + }); + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(WEBPACK_CONFIG_PATH, output.code); + additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`); + } + } catch (error) { + additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error); + } + } + } +} + +/** + * Updates module rules to add a rule for .mjs files in node_modules with resolve.fullySpecified: false + */ +function updateModuleRules(moduleObject: recast.types.namedTypes.ObjectExpression): boolean { + if (!moduleObject.properties) { + return false; + } + + let hasChanges = false; + let hasMjsRule = false; + let rulesProperty: recast.types.namedTypes.Property | null = null; + + // Find the rules property + for (const prop of moduleObject.properties) { + if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { + continue; + } + + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + if (key && key.type === 'Identifier' && key.name === 'rules' && value && value.type === 'ArrayExpression') { + rulesProperty = prop as recast.types.namedTypes.Property; + // Check if .mjs rule already exists + for (const element of value.elements) { + if (element && element.type === 'ObjectExpression' && element.properties) { + for (const ruleProp of element.properties) { + if ( + ruleProp && + (ruleProp.type === 'Property' || ruleProp.type === 'ObjectProperty') && + 'key' in ruleProp && + ruleProp.key.type === 'Identifier' && + ruleProp.key.name === 'test' + ) { + const testValue = 'value' in ruleProp ? ruleProp.value : null; + if (testValue) { + // Check for RegExpLiteral with .mjs pattern + if (testValue.type === 'RegExpLiteral' && 'pattern' in testValue && testValue.pattern === '\\.mjs$') { + hasMjsRule = true; + break; + } + // Check for Literal with regex property + if ( + testValue.type === 'Literal' && + 'regex' in testValue && + testValue.regex && + typeof testValue.regex === 'object' && + 'pattern' in testValue.regex && + testValue.regex.pattern === '\\.mjs$' + ) { + hasMjsRule = true; + break; + } + // Check for string literal containing .mjs + if ( + testValue.type === 'Literal' && + 'value' in testValue && + typeof testValue.value === 'string' && + testValue.value.includes('.mjs') + ) { + hasMjsRule = true; + break; + } + } + } + } + } + if (hasMjsRule) { + break; + } + } + break; + } + } + + // Add .mjs rule if missing (insert at beginning so it's processed before rules that exclude node_modules) + if (!hasMjsRule && rulesProperty && 'value' in rulesProperty) { + const rulesArray = rulesProperty.value as recast.types.namedTypes.ArrayExpression; + const mjsRule = builders.objectExpression([ + builders.property('init', builders.identifier('test'), builders.literal(/\.mjs$/)), + builders.property('init', builders.identifier('include'), builders.literal(/node_modules/)), + builders.property( + 'init', + builders.identifier('resolve'), + builders.objectExpression([ + builders.property('init', builders.identifier('fullySpecified'), builders.literal(false)), + ]) + ), + builders.property('init', builders.identifier('type'), builders.literal('javascript/auto')), + ]); + // Insert at the beginning of the rules array + rulesArray.elements.unshift(mjsRule); + hasChanges = true; + additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false'); + } + + return hasChanges; +} + +/** + * Updates a resolve object expression to add .mjs extension + * Note: We don't set fullySpecified: false globally because .mjs files need + * rule-level configuration to override ESM's strict fully-specified import requirements + */ +function updateResolveObject(resolveObject: recast.types.namedTypes.ObjectExpression): boolean { + if (!resolveObject.properties) { + return false; + } + + let hasChanges = false; + let hasMjsExtension = false; + let extensionsProperty: recast.types.namedTypes.Property | null = null; + + // Check current state + for (const prop of resolveObject.properties) { + if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { + continue; + } + + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + if (key && key.type === 'Identifier') { + if (key.name === 'extensions' && value && value.type === 'ArrayExpression') { + extensionsProperty = prop as recast.types.namedTypes.Property; + // Check if .mjs is already in the extensions array + for (const element of value.elements) { + if ( + element && + (element.type === 'Literal' || element.type === 'StringLiteral') && + 'value' in element && + element.value === '.mjs' + ) { + hasMjsExtension = true; + break; + } + } + } + } + } + + // Add .mjs to extensions if missing + if (!hasMjsExtension && extensionsProperty && 'value' in extensionsProperty) { + const extensionsArray = extensionsProperty.value as recast.types.namedTypes.ArrayExpression; + extensionsArray.elements.push(builders.literal('.mjs')); + hasChanges = true; + additionsDebug("Added '.mjs' to resolve.extensions"); + } + + return hasChanges; +} + +/** + * Ensures plugin.json has grafanaDependency >= 10.2.0 + * Bundling @grafana/ui is only supported from Grafana 10.2.0 onwards */ function ensureMinGrafanaVersion(context: Context): void { if (!context.doesFileExist(PLUGIN_JSON_PATH)) { @@ -184,5 +481,8 @@ export default function bundleGrafanaUI(context: Context, _options: BundleGrafan // Update externals array using the shared utility updateExternalsArray(context, createBundleGrafanaUIModifier()); + // Update bundler resolve configuration to handle ESM imports + updateBundlerResolveConfig(context); + return context; } From bab23a7c7d2414e66557e1682874ff7b8d32ba51 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 10:43:27 +0100 Subject: [PATCH 09/17] ensure .mjs rule is inserted at position 1 in rule array --- .../scripts/bundle-grafana-ui/index.test.ts | 88 +++++++++++++++++++ .../scripts/bundle-grafana-ui/index.ts | 6 +- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts index 5d6a52b17f..7e91fb4941 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts @@ -294,6 +294,50 @@ export default baseConfig;`; expect(content).toMatch(/resolve:\s*\{[^}]*fullySpecified:\s*false/); }); + it('should insert .mjs rule at position 1 (after imports-loader rule)', () => { + const webpackConfigWithImportsLoader = `import type { Configuration } from 'webpack'; + +const baseConfig: Configuration = { + module: { + rules: [ + { + test: /src\\/(?:.*\\/)?module\\.tsx?$/, + use: [ + { + loader: 'imports-loader', + options: { + imports: 'side-effects grafana-public-path', + }, + }, + ], + }, + ], + }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + modules: ['node_modules'], + }, +}; + +export default baseConfig;`; + + const context = new Context('/virtual'); + context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithImportsLoader); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(WEBPACK_CONFIG_PATH) || ''; + // Find the position of imports-loader rule and .mjs rule + const importsLoaderIndex = content.indexOf('imports-loader'); + const mjsRuleIndex = content.indexOf('test: /\\.mjs$/'); + + // .mjs rule should come after imports-loader rule + expect(mjsRuleIndex).toBeGreaterThan(importsLoaderIndex); + + // Verify .mjs rule is present + expect(content).toMatch(/test:\s*\/\\\.mjs\$/); + }); + it('should be idempotent for resolve configuration', () => { const context = new Context('/virtual'); context.addFile(WEBPACK_CONFIG_PATH, webpackConfigWithResolve); @@ -400,6 +444,50 @@ export default baseConfig;`; expect(content).toMatch(/resolve:\s*\{[^}]*fullySpecified:\s*false/); }); + it('should insert .mjs rule at position 1 (after imports-loader rule)', () => { + const rspackConfigWithImportsLoader = `import type { Configuration } from '@rspack/core'; + +const baseConfig: Configuration = { + module: { + rules: [ + { + test: /src\\/(?:.*\\/)?module\\.tsx?$/, + use: [ + { + loader: 'imports-loader', + options: { + imports: 'side-effects grafana-public-path', + }, + }, + ], + }, + ], + }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + modules: ['node_modules'], + }, +}; + +export default baseConfig;`; + + const context = new Context('/virtual'); + context.addFile(RSPACK_CONFIG_PATH, rspackConfigWithImportsLoader); + + const result = bundleGrafanaUI(context, {}); + + const content = result.getFile(RSPACK_CONFIG_PATH) || ''; + // Find the position of imports-loader rule and .mjs rule + const importsLoaderIndex = content.indexOf('imports-loader'); + const mjsRuleIndex = content.indexOf('test: /\\.mjs$/'); + + // .mjs rule should come after imports-loader rule + expect(mjsRuleIndex).toBeGreaterThan(importsLoaderIndex); + + // Verify .mjs rule is present + expect(content).toMatch(/test:\s*\/\\\.mjs\$/); + }); + it('should prefer rspack.config.ts over webpack.config.ts when both exist', () => { const context = new Context('/virtual'); context.addFile(RSPACK_CONFIG_PATH, rspackConfigWithResolve); diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index 04b1dfebe3..cf09f1ae81 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -349,7 +349,7 @@ function updateModuleRules(moduleObject: recast.types.namedTypes.ObjectExpressio } } - // Add .mjs rule if missing (insert at beginning so it's processed before rules that exclude node_modules) + // Add .mjs rule if missing (insert at position 1, after imports-loader rule which must be first) if (!hasMjsRule && rulesProperty && 'value' in rulesProperty) { const rulesArray = rulesProperty.value as recast.types.namedTypes.ArrayExpression; const mjsRule = builders.objectExpression([ @@ -364,8 +364,8 @@ function updateModuleRules(moduleObject: recast.types.namedTypes.ObjectExpressio ), builders.property('init', builders.identifier('type'), builders.literal('javascript/auto')), ]); - // Insert at the beginning of the rules array - rulesArray.elements.unshift(mjsRule); + // Insert at position 1 (second position) to keep imports-loader first + rulesArray.elements.splice(1, 0, mjsRule); hasChanges = true; additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false'); } From ab084c4806e1a1a4c9ae93863baca82da539fb1c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 11:11:46 +0100 Subject: [PATCH 10/17] fix lint error --- packages/create-plugin/src/utils/utils.templates.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/create-plugin/src/utils/utils.templates.ts b/packages/create-plugin/src/utils/utils.templates.ts index 19ab3abf06..a18437296c 100644 --- a/packages/create-plugin/src/utils/utils.templates.ts +++ b/packages/create-plugin/src/utils/utils.templates.ts @@ -1,10 +1,4 @@ -import { - DEFAULT_FEATURE_FLAGS, - EXPORT_PATH_PREFIX, - EXTRA_TEMPLATE_VARIABLES, - PLUGIN_TYPES, - TEMPLATE_PATHS, -} from '../constants.js'; +import { EXPORT_PATH_PREFIX, EXTRA_TEMPLATE_VARIABLES, PLUGIN_TYPES, TEMPLATE_PATHS } from '../constants.js'; import { GenerateCliArgs, TemplateData } from '../types.js'; import { filterOutCommonFiles, isFile, isFileStartingWith } from './utils.files.js'; import { From a4f53aba76fc421cb40045302ed00ca1554ec8ec Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 11:29:38 +0100 Subject: [PATCH 11/17] refactoring bundler logic --- .../scripts/bundle-grafana-ui/index.ts | 422 ++++++------------ .../src/codemods/utils.bundler-config.ts | 180 ++++++++ 2 files changed, 320 insertions(+), 282 deletions(-) create mode 100644 packages/create-plugin/src/codemods/utils.bundler-config.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index cf09f1ae81..c14596fe39 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -1,22 +1,34 @@ import * as v from 'valibot'; import * as recast from 'recast'; -import * as typeScriptParser from 'recast/parsers/typescript.js'; import { coerce, gte } from 'semver'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; +import { updateBundlerConfig, type ModuleRulesModifier, type ResolveModifier } from '../../../utils.bundler-config.js'; import { updateExternalsArray, type ExternalsArrayModifier } from '../../../utils.externals.js'; const { builders } = recast.types; -export const schema = v.object({}); +const PLUGIN_JSON_PATH = 'src/plugin.json'; +const MIN_GRAFANA_VERSION = '10.2.0'; +export const schema = v.object({}); type BundleGrafanaUIOptions = v.InferOutput; -const PLUGIN_JSON_PATH = 'src/plugin.json'; -const MIN_GRAFANA_VERSION = '10.2.0'; -const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; -const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; +export default function bundleGrafanaUI(context: Context, _options: BundleGrafanaUIOptions): Context { + additionsDebug('Running bundle-grafana-ui addition...'); + + // Ensure minimum Grafana version requirement + ensureMinGrafanaVersion(context); + + // Update externals array using the shared utility + updateExternalsArray(context, createBundleGrafanaUIModifier()); + + // Update bundler resolve configuration to handle ESM imports + updateBundlerConfig(context, createResolveModifier(), createModuleRulesModifier()); + + return context; +} /** * Checks if an AST node is a regex matching @grafana/ui @@ -58,7 +70,7 @@ function isGrafanaDataRegex(element: recast.types.namedTypes.ASTNode): boolean { * Removes /^@grafana\/ui/i regex from externals array and adds 'react-inlinesvg' * @returns true if changes were made, false otherwise */ -function modifyExternalsArray(externalsArray: recast.types.namedTypes.ArrayExpression): boolean { +function removeGrafanaUiAndAddReactInlineSvg(externalsArray: recast.types.namedTypes.ArrayExpression): boolean { let hasChanges = false; let hasGrafanaUiExternal = false; let hasReactInlineSvg = false; @@ -128,302 +140,163 @@ function modifyExternalsArray(externalsArray: recast.types.namedTypes.ArrayExpre */ function createBundleGrafanaUIModifier(): ExternalsArrayModifier { return (externalsArray: recast.types.namedTypes.ArrayExpression) => { - return modifyExternalsArray(externalsArray); + return removeGrafanaUiAndAddReactInlineSvg(externalsArray); }; } /** - * Updates the bundler's resolve configuration to handle ESM imports from @grafana/ui - * - Adds '.mjs' to resolve.extensions - * - Adds fullySpecified: false to allow extensionless imports in node_modules + * Creates a modifier function for updateBundlerConfig that adds '.mjs' to resolve.extensions */ -function updateBundlerResolveConfig(context: Context): void { - // Try rspack config first (newer structure) - if (context.doesFileExist(RSPACK_CONFIG_PATH)) { - additionsDebug(`Found ${RSPACK_CONFIG_PATH}, updating resolve configuration...`); - const rspackContent = context.getFile(RSPACK_CONFIG_PATH); - if (rspackContent) { - try { - const ast = recast.parse(rspackContent, { - parser: typeScriptParser, - }); - - let hasChanges = false; - - recast.visit(ast, { - visitObjectExpression(path) { - const { node } = path; - const properties = node.properties; - - if (properties) { - for (const prop of properties) { - if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { - const key = 'key' in prop ? prop.key : null; - const value = 'value' in prop ? prop.value : null; - - // Find the resolve property - if ( - key && - key.type === 'Identifier' && - key.name === 'resolve' && - value && - value.type === 'ObjectExpression' - ) { - hasChanges = updateResolveObject(value) || hasChanges; - } - - // Find the module property to add .mjs rule - if ( - key && - key.type === 'Identifier' && - key.name === 'module' && - value && - value.type === 'ObjectExpression' - ) { - hasChanges = updateModuleRules(value) || hasChanges; - } - } - } - } - - return this.traverse(path); - }, - }); - - if (hasChanges) { - const output = recast.print(ast, { - tabWidth: 2, - trailingComma: true, - lineTerminator: '\n', - }); - context.updateFile(RSPACK_CONFIG_PATH, output.code); - additionsDebug(`Updated ${RSPACK_CONFIG_PATH}`); - } - } catch (error) { - additionsDebug(`Error updating ${RSPACK_CONFIG_PATH}:`, error); - } +function createResolveModifier(): ResolveModifier { + return (resolveObject: recast.types.namedTypes.ObjectExpression): boolean => { + if (!resolveObject.properties) { + return false; } - return; - } - // Fall back to webpack config (legacy structure) - if (context.doesFileExist(WEBPACK_CONFIG_PATH)) { - additionsDebug(`Found ${WEBPACK_CONFIG_PATH}, updating resolve configuration...`); - const webpackContent = context.getFile(WEBPACK_CONFIG_PATH); - if (webpackContent) { - try { - const ast = recast.parse(webpackContent, { - parser: typeScriptParser, - }); - - let hasChanges = false; - - recast.visit(ast, { - visitObjectExpression(path) { - const { node } = path; - const properties = node.properties; - - if (properties) { - for (const prop of properties) { - if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { - const key = 'key' in prop ? prop.key : null; - const value = 'value' in prop ? prop.value : null; - - // Find the resolve property - if ( - key && - key.type === 'Identifier' && - key.name === 'resolve' && - value && - value.type === 'ObjectExpression' - ) { - hasChanges = updateResolveObject(value) || hasChanges; - } + let hasChanges = false; + let hasMjsExtension = false; + let extensionsProperty: recast.types.namedTypes.Property | null = null; - // Find the module property to add .mjs rule - if ( - key && - key.type === 'Identifier' && - key.name === 'module' && - value && - value.type === 'ObjectExpression' - ) { - hasChanges = updateModuleRules(value) || hasChanges; - } - } - } - } - - return this.traverse(path); - }, - }); - - if (hasChanges) { - const output = recast.print(ast, { - tabWidth: 2, - trailingComma: true, - lineTerminator: '\n', - }); - context.updateFile(WEBPACK_CONFIG_PATH, output.code); - additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`); - } - } catch (error) { - additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error); + // Check current state + for (const prop of resolveObject.properties) { + if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { + continue; } - } - } -} -/** - * Updates module rules to add a rule for .mjs files in node_modules with resolve.fullySpecified: false - */ -function updateModuleRules(moduleObject: recast.types.namedTypes.ObjectExpression): boolean { - if (!moduleObject.properties) { - return false; - } + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; - let hasChanges = false; - let hasMjsRule = false; - let rulesProperty: recast.types.namedTypes.Property | null = null; - - // Find the rules property - for (const prop of moduleObject.properties) { - if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { - continue; - } - - const key = 'key' in prop ? prop.key : null; - const value = 'value' in prop ? prop.value : null; - - if (key && key.type === 'Identifier' && key.name === 'rules' && value && value.type === 'ArrayExpression') { - rulesProperty = prop as recast.types.namedTypes.Property; - // Check if .mjs rule already exists - for (const element of value.elements) { - if (element && element.type === 'ObjectExpression' && element.properties) { - for (const ruleProp of element.properties) { + if (key && key.type === 'Identifier') { + if (key.name === 'extensions' && value && value.type === 'ArrayExpression') { + extensionsProperty = prop as recast.types.namedTypes.Property; + // Check if .mjs is already in the extensions array + for (const element of value.elements) { if ( - ruleProp && - (ruleProp.type === 'Property' || ruleProp.type === 'ObjectProperty') && - 'key' in ruleProp && - ruleProp.key.type === 'Identifier' && - ruleProp.key.name === 'test' + element && + (element.type === 'Literal' || element.type === 'StringLiteral') && + 'value' in element && + element.value === '.mjs' ) { - const testValue = 'value' in ruleProp ? ruleProp.value : null; - if (testValue) { - // Check for RegExpLiteral with .mjs pattern - if (testValue.type === 'RegExpLiteral' && 'pattern' in testValue && testValue.pattern === '\\.mjs$') { - hasMjsRule = true; - break; - } - // Check for Literal with regex property - if ( - testValue.type === 'Literal' && - 'regex' in testValue && - testValue.regex && - typeof testValue.regex === 'object' && - 'pattern' in testValue.regex && - testValue.regex.pattern === '\\.mjs$' - ) { - hasMjsRule = true; - break; - } - // Check for string literal containing .mjs - if ( - testValue.type === 'Literal' && - 'value' in testValue && - typeof testValue.value === 'string' && - testValue.value.includes('.mjs') - ) { - hasMjsRule = true; - break; - } - } + hasMjsExtension = true; + break; } } } - if (hasMjsRule) { - break; - } } - break; } - } - // Add .mjs rule if missing (insert at position 1, after imports-loader rule which must be first) - if (!hasMjsRule && rulesProperty && 'value' in rulesProperty) { - const rulesArray = rulesProperty.value as recast.types.namedTypes.ArrayExpression; - const mjsRule = builders.objectExpression([ - builders.property('init', builders.identifier('test'), builders.literal(/\.mjs$/)), - builders.property('init', builders.identifier('include'), builders.literal(/node_modules/)), - builders.property( - 'init', - builders.identifier('resolve'), - builders.objectExpression([ - builders.property('init', builders.identifier('fullySpecified'), builders.literal(false)), - ]) - ), - builders.property('init', builders.identifier('type'), builders.literal('javascript/auto')), - ]); - // Insert at position 1 (second position) to keep imports-loader first - rulesArray.elements.splice(1, 0, mjsRule); - hasChanges = true; - additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false'); - } + // Add .mjs to extensions if missing + if (!hasMjsExtension && extensionsProperty && 'value' in extensionsProperty) { + const extensionsArray = extensionsProperty.value as recast.types.namedTypes.ArrayExpression; + extensionsArray.elements.push(builders.literal('.mjs')); + hasChanges = true; + additionsDebug("Added '.mjs' to resolve.extensions"); + } - return hasChanges; + return hasChanges; + }; } /** - * Updates a resolve object expression to add .mjs extension - * Note: We don't set fullySpecified: false globally because .mjs files need - * rule-level configuration to override ESM's strict fully-specified import requirements + * Creates a modifier function for updateBundlerConfig that adds a module rule for .mjs files + * in node_modules with resolve.fullySpecified: false */ -function updateResolveObject(resolveObject: recast.types.namedTypes.ObjectExpression): boolean { - if (!resolveObject.properties) { - return false; - } +function createModuleRulesModifier(): ModuleRulesModifier { + return (moduleObject: recast.types.namedTypes.ObjectExpression): boolean => { + if (!moduleObject.properties) { + return false; + } - let hasChanges = false; - let hasMjsExtension = false; - let extensionsProperty: recast.types.namedTypes.Property | null = null; + let hasChanges = false; + let hasMjsRule = false; + let rulesProperty: recast.types.namedTypes.Property | null = null; - // Check current state - for (const prop of resolveObject.properties) { - if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { - continue; - } + // Find the rules property + for (const prop of moduleObject.properties) { + if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { + continue; + } - const key = 'key' in prop ? prop.key : null; - const value = 'value' in prop ? prop.value : null; + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; - if (key && key.type === 'Identifier') { - if (key.name === 'extensions' && value && value.type === 'ArrayExpression') { - extensionsProperty = prop as recast.types.namedTypes.Property; - // Check if .mjs is already in the extensions array + if (key && key.type === 'Identifier' && key.name === 'rules' && value && value.type === 'ArrayExpression') { + rulesProperty = prop as recast.types.namedTypes.Property; + // Check if .mjs rule already exists for (const element of value.elements) { - if ( - element && - (element.type === 'Literal' || element.type === 'StringLiteral') && - 'value' in element && - element.value === '.mjs' - ) { - hasMjsExtension = true; + if (element && element.type === 'ObjectExpression' && element.properties) { + for (const ruleProp of element.properties) { + if ( + ruleProp && + (ruleProp.type === 'Property' || ruleProp.type === 'ObjectProperty') && + 'key' in ruleProp && + ruleProp.key.type === 'Identifier' && + ruleProp.key.name === 'test' + ) { + const testValue = 'value' in ruleProp ? ruleProp.value : null; + if (testValue) { + // Check for RegExpLiteral with .mjs pattern + if (testValue.type === 'RegExpLiteral' && 'pattern' in testValue && testValue.pattern === '\\.mjs$') { + hasMjsRule = true; + break; + } + // Check for Literal with regex property + if ( + testValue.type === 'Literal' && + 'regex' in testValue && + testValue.regex && + typeof testValue.regex === 'object' && + 'pattern' in testValue.regex && + testValue.regex.pattern === '\\.mjs$' + ) { + hasMjsRule = true; + break; + } + // Check for string literal containing .mjs + if ( + testValue.type === 'Literal' && + 'value' in testValue && + typeof testValue.value === 'string' && + testValue.value.includes('.mjs') + ) { + hasMjsRule = true; + break; + } + } + } + } + } + if (hasMjsRule) { break; } } + break; } } - } - // Add .mjs to extensions if missing - if (!hasMjsExtension && extensionsProperty && 'value' in extensionsProperty) { - const extensionsArray = extensionsProperty.value as recast.types.namedTypes.ArrayExpression; - extensionsArray.elements.push(builders.literal('.mjs')); - hasChanges = true; - additionsDebug("Added '.mjs' to resolve.extensions"); - } + // Add .mjs rule if missing (insert at position 1, after imports-loader rule which must be first) + if (!hasMjsRule && rulesProperty && 'value' in rulesProperty) { + const rulesArray = rulesProperty.value as recast.types.namedTypes.ArrayExpression; + const mjsRule = builders.objectExpression([ + builders.property('init', builders.identifier('test'), builders.literal(/\.mjs$/)), + builders.property('init', builders.identifier('include'), builders.literal(/node_modules/)), + builders.property( + 'init', + builders.identifier('resolve'), + builders.objectExpression([ + builders.property('init', builders.identifier('fullySpecified'), builders.literal(false)), + ]) + ), + builders.property('init', builders.identifier('type'), builders.literal('javascript/auto')), + ]); + // Insert at position 1 (second position) to keep imports-loader first + rulesArray.elements.splice(1, 0, mjsRule); + hasChanges = true; + additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false'); + } - return hasChanges; + return hasChanges; + }; } /** @@ -471,18 +344,3 @@ function ensureMinGrafanaVersion(context: Context): void { additionsDebug(`Error updating ${PLUGIN_JSON_PATH}:`, error); } } - -export default function bundleGrafanaUI(context: Context, _options: BundleGrafanaUIOptions): Context { - additionsDebug('Running bundle-grafana-ui addition...'); - - // Ensure minimum Grafana version requirement - ensureMinGrafanaVersion(context); - - // Update externals array using the shared utility - updateExternalsArray(context, createBundleGrafanaUIModifier()); - - // Update bundler resolve configuration to handle ESM imports - updateBundlerResolveConfig(context); - - return context; -} diff --git a/packages/create-plugin/src/codemods/utils.bundler-config.ts b/packages/create-plugin/src/codemods/utils.bundler-config.ts new file mode 100644 index 0000000000..22b9800388 --- /dev/null +++ b/packages/create-plugin/src/codemods/utils.bundler-config.ts @@ -0,0 +1,180 @@ +import * as recast from 'recast'; +import * as typeScriptParser from 'recast/parsers/typescript.js'; + +import type { Context } from './context.js'; +import { additionsDebug } from './utils.js'; + +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; + +/** + * Type for a function that modifies a resolve object expression + * @param resolveObject - The AST node representing the resolve configuration + * @returns true if changes were made, false otherwise + */ +export type ResolveModifier = (resolveObject: recast.types.namedTypes.ObjectExpression) => boolean; + +/** + * Type for a function that modifies a module rules array + * @param moduleObject - The AST node representing the module configuration + * @returns true if changes were made, false otherwise + */ +export type ModuleRulesModifier = (moduleObject: recast.types.namedTypes.ObjectExpression) => boolean; + +/** + * Updates the bundler's resolve and module configuration. + * + * This utility handles both webpack and rspack configurations, preferring rspack when both exist. + * + * @param context - The codemod context + * @param resolveModifier - Optional function to modify the resolve configuration + * @param moduleRulesModifier - Optional function to modify the module rules configuration + */ +export function updateBundlerConfig( + context: Context, + resolveModifier?: ResolveModifier, + moduleRulesModifier?: ModuleRulesModifier +): void { + if (!resolveModifier && !moduleRulesModifier) { + return; + } + + // Try rspack config first (newer structure) + if (context.doesFileExist(RSPACK_CONFIG_PATH)) { + additionsDebug(`Found ${RSPACK_CONFIG_PATH}, updating bundler configuration...`); + const rspackContent = context.getFile(RSPACK_CONFIG_PATH); + if (rspackContent) { + try { + const ast = recast.parse(rspackContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + + recast.visit(ast, { + visitObjectExpression(path) { + const { node } = path; + const properties = node.properties; + + if (properties) { + for (const prop of properties) { + if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + // Find the resolve property + if ( + resolveModifier && + key && + key.type === 'Identifier' && + key.name === 'resolve' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = resolveModifier(value) || hasChanges; + } + + // Find the module property + if ( + moduleRulesModifier && + key && + key.type === 'Identifier' && + key.name === 'module' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = moduleRulesModifier(value) || hasChanges; + } + } + } + } + + return this.traverse(path); + }, + }); + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(RSPACK_CONFIG_PATH, output.code); + additionsDebug(`Updated ${RSPACK_CONFIG_PATH}`); + } + } catch (error) { + additionsDebug(`Error updating ${RSPACK_CONFIG_PATH}:`, error); + } + } + return; + } + + // Fall back to webpack config (legacy structure) + if (context.doesFileExist(WEBPACK_CONFIG_PATH)) { + additionsDebug(`Found ${WEBPACK_CONFIG_PATH}, updating bundler configuration...`); + const webpackContent = context.getFile(WEBPACK_CONFIG_PATH); + if (webpackContent) { + try { + const ast = recast.parse(webpackContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + + recast.visit(ast, { + visitObjectExpression(path) { + const { node } = path; + const properties = node.properties; + + if (properties) { + for (const prop of properties) { + if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + // Find the resolve property + if ( + resolveModifier && + key && + key.type === 'Identifier' && + key.name === 'resolve' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = resolveModifier(value) || hasChanges; + } + + // Find the module property + if ( + moduleRulesModifier && + key && + key.type === 'Identifier' && + key.name === 'module' && + value && + value.type === 'ObjectExpression' + ) { + hasChanges = moduleRulesModifier(value) || hasChanges; + } + } + } + } + + return this.traverse(path); + }, + }); + + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(WEBPACK_CONFIG_PATH, output.code); + additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`); + } + } catch (error) { + additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error); + } + } + } +} From da5486c7362e702fc2e3a75c2864605ebf97fec5 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 11:36:19 +0100 Subject: [PATCH 12/17] use string manipulation instead of ast --- .../scripts/bundle-grafana-ui/index.ts | 154 +++++++----------- 1 file changed, 59 insertions(+), 95 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index c14596fe39..25583fcdbe 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -4,13 +4,15 @@ import { coerce, gte } from 'semver'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; -import { updateBundlerConfig, type ModuleRulesModifier, type ResolveModifier } from '../../../utils.bundler-config.js'; +import { updateBundlerConfig, type ResolveModifier } from '../../../utils.bundler-config.js'; import { updateExternalsArray, type ExternalsArrayModifier } from '../../../utils.externals.js'; const { builders } = recast.types; const PLUGIN_JSON_PATH = 'src/plugin.json'; const MIN_GRAFANA_VERSION = '10.2.0'; +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; export const schema = v.object({}); type BundleGrafanaUIOptions = v.InferOutput; @@ -25,7 +27,10 @@ export default function bundleGrafanaUI(context: Context, _options: BundleGrafan updateExternalsArray(context, createBundleGrafanaUIModifier()); // Update bundler resolve configuration to handle ESM imports - updateBundlerConfig(context, createResolveModifier(), createModuleRulesModifier()); + updateBundlerConfig(context, createResolveModifier()); + + // Update module rules directly with simple string manipulation + updateModuleRulesSimple(context); return context; } @@ -198,105 +203,64 @@ function createResolveModifier(): ResolveModifier { } /** - * Creates a modifier function for updateBundlerConfig that adds a module rule for .mjs files - * in node_modules with resolve.fullySpecified: false + * Updates module rules to add .mjs rule using simple string manipulation + * This is a simplified approach that may not handle all edge cases, but is much simpler */ -function createModuleRulesModifier(): ModuleRulesModifier { - return (moduleObject: recast.types.namedTypes.ObjectExpression): boolean => { - if (!moduleObject.properties) { - return false; - } - - let hasChanges = false; - let hasMjsRule = false; - let rulesProperty: recast.types.namedTypes.Property | null = null; +function updateModuleRulesSimple(context: Context): void { + const configPath = context.doesFileExist(RSPACK_CONFIG_PATH) + ? RSPACK_CONFIG_PATH + : context.doesFileExist(WEBPACK_CONFIG_PATH) + ? WEBPACK_CONFIG_PATH + : null; + + if (!configPath) { + return; + } - // Find the rules property - for (const prop of moduleObject.properties) { - if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { - continue; - } + const content = context.getFile(configPath); + if (!content) { + return; + } - const key = 'key' in prop ? prop.key : null; - const value = 'value' in prop ? prop.value : null; + // Check if rule already exists + if (content.includes('test: /\\.mjs$') || content.includes('test: /\\\\.mjs$')) { + return; + } - if (key && key.type === 'Identifier' && key.name === 'rules' && value && value.type === 'ArrayExpression') { - rulesProperty = prop as recast.types.namedTypes.Property; - // Check if .mjs rule already exists - for (const element of value.elements) { - if (element && element.type === 'ObjectExpression' && element.properties) { - for (const ruleProp of element.properties) { - if ( - ruleProp && - (ruleProp.type === 'Property' || ruleProp.type === 'ObjectProperty') && - 'key' in ruleProp && - ruleProp.key.type === 'Identifier' && - ruleProp.key.name === 'test' - ) { - const testValue = 'value' in ruleProp ? ruleProp.value : null; - if (testValue) { - // Check for RegExpLiteral with .mjs pattern - if (testValue.type === 'RegExpLiteral' && 'pattern' in testValue && testValue.pattern === '\\.mjs$') { - hasMjsRule = true; - break; - } - // Check for Literal with regex property - if ( - testValue.type === 'Literal' && - 'regex' in testValue && - testValue.regex && - typeof testValue.regex === 'object' && - 'pattern' in testValue.regex && - testValue.regex.pattern === '\\.mjs$' - ) { - hasMjsRule = true; - break; - } - // Check for string literal containing .mjs - if ( - testValue.type === 'Literal' && - 'value' in testValue && - typeof testValue.value === 'string' && - testValue.value.includes('.mjs') - ) { - hasMjsRule = true; - break; - } - } - } - } - } - if (hasMjsRule) { - break; - } - } - break; + const mjsRule = `{ + test: /\\.mjs$/, + include: /node_modules/, + resolve: { + fullySpecified: false, + }, + type: 'javascript/auto', + },`; + + // Simple approach: find rules array and insert after first rule + let updated = content; + + // Case 1: Empty array - insert at start + if (content.match(/rules:\s*\[\s*\]/)) { + updated = content.replace(/(rules:\s*\[\s*)(\])/, `$1${mjsRule}\n $2`); + } + // Case 2: Find first rule and insert after it + else { + // Match: rules: [ { ... }, and insert mjs rule after the first rule + // The regex finds the first complete rule object (balanced braces) + updated = content.replace(/(rules:\s*\[\s*)(\{[\s\S]*?\}),(\s*)/, (match, prefix, firstRule, suffix) => { + // Check if we already inserted (avoid double insertion) + if (match.includes('test: /\\.mjs$')) { + return match; } - } - - // Add .mjs rule if missing (insert at position 1, after imports-loader rule which must be first) - if (!hasMjsRule && rulesProperty && 'value' in rulesProperty) { - const rulesArray = rulesProperty.value as recast.types.namedTypes.ArrayExpression; - const mjsRule = builders.objectExpression([ - builders.property('init', builders.identifier('test'), builders.literal(/\.mjs$/)), - builders.property('init', builders.identifier('include'), builders.literal(/node_modules/)), - builders.property( - 'init', - builders.identifier('resolve'), - builders.objectExpression([ - builders.property('init', builders.identifier('fullySpecified'), builders.literal(false)), - ]) - ), - builders.property('init', builders.identifier('type'), builders.literal('javascript/auto')), - ]); - // Insert at position 1 (second position) to keep imports-loader first - rulesArray.elements.splice(1, 0, mjsRule); - hasChanges = true; - additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false'); - } + // Insert mjs rule after first rule + return `${prefix}${firstRule},\n ${mjsRule}${suffix}`; + }); + } - return hasChanges; - }; + if (updated !== content) { + context.updateFile(configPath, updated); + additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false'); + } } /** From f20519ce7a8037cace38596aab8fa9a87f6d67fd Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 11:38:56 +0100 Subject: [PATCH 13/17] more simplifications --- .../scripts/bundle-grafana-ui/index.ts | 110 +++++++----------- 1 file changed, 39 insertions(+), 71 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index 25583fcdbe..c7ba498a20 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -4,7 +4,6 @@ import { coerce, gte } from 'semver'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; -import { updateBundlerConfig, type ResolveModifier } from '../../../utils.bundler-config.js'; import { updateExternalsArray, type ExternalsArrayModifier } from '../../../utils.externals.js'; const { builders } = recast.types; @@ -27,7 +26,7 @@ export default function bundleGrafanaUI(context: Context, _options: BundleGrafan updateExternalsArray(context, createBundleGrafanaUIModifier()); // Update bundler resolve configuration to handle ESM imports - updateBundlerConfig(context, createResolveModifier()); + updateResolveExtensionsSimple(context); // Update module rules directly with simple string manipulation updateModuleRulesSimple(context); @@ -36,37 +35,20 @@ export default function bundleGrafanaUI(context: Context, _options: BundleGrafan } /** - * Checks if an AST node is a regex matching @grafana/ui - * The pattern in the AST is "^@grafana\/ui" (backslash-escaped forward slash) + * Checks if an AST node is a regex matching a Grafana package pattern + * @param element - The AST node to check + * @param pattern - The regex pattern to match (e.g., "^@grafana\\/ui" or "^@grafana\\/data") */ -function isGrafanaUiRegex(element: recast.types.namedTypes.ASTNode): boolean { +function isGrafanaRegex(element: recast.types.namedTypes.ASTNode, pattern: string): boolean { // Handle RegExpLiteral (TypeScript parser) if (element.type === 'RegExpLiteral') { const regexNode = element as recast.types.namedTypes.RegExpLiteral; - return regexNode.pattern === '^@grafana\\/ui' && regexNode.flags === 'i'; + return regexNode.pattern === pattern && regexNode.flags === 'i'; } // Handle Literal with regex property (other parsers) if (element.type === 'Literal' && 'regex' in element && element.regex) { const regex = element.regex as { pattern: string; flags: string }; - return regex.pattern === '^@grafana\\/ui' && regex.flags === 'i'; - } - return false; -} - -/** - * Checks if an AST node is a regex matching @grafana/data - * The pattern in the AST is "^@grafana\/data" (backslash-escaped forward slash) - */ -function isGrafanaDataRegex(element: recast.types.namedTypes.ASTNode): boolean { - // Handle RegExpLiteral (TypeScript parser) - if (element.type === 'RegExpLiteral') { - const regexNode = element as recast.types.namedTypes.RegExpLiteral; - return regexNode.pattern === '^@grafana\\/data' && regexNode.flags === 'i'; - } - // Handle Literal with regex property (other parsers) - if (element.type === 'Literal' && 'regex' in element && element.regex) { - const regex = element.regex as { pattern: string; flags: string }; - return regex.pattern === '^@grafana\\/data' && regex.flags === 'i'; + return regex.pattern === pattern && regex.flags === 'i'; } return false; } @@ -87,7 +69,7 @@ function removeGrafanaUiAndAddReactInlineSvg(externalsArray: recast.types.namedT } // Check for /^@grafana\/ui/i regex - if (isGrafanaUiRegex(element)) { + if (isGrafanaRegex(element, '^@grafana\\/ui')) { hasGrafanaUiExternal = true; } @@ -108,7 +90,7 @@ function removeGrafanaUiAndAddReactInlineSvg(externalsArray: recast.types.namedT if (!element) { return true; } - return !isGrafanaUiRegex(element); + return !isGrafanaRegex(element, '^@grafana\\/ui'); }); hasChanges = true; additionsDebug('Removed /^@grafana\\/ui/i from externals array'); @@ -120,7 +102,7 @@ function removeGrafanaUiAndAddReactInlineSvg(externalsArray: recast.types.namedT let insertIndex = -1; for (let i = 0; i < externalsArray.elements.length; i++) { const element = externalsArray.elements[i]; - if (element && isGrafanaDataRegex(element)) { + if (element && isGrafanaRegex(element, '^@grafana\\/data')) { insertIndex = i + 1; break; } @@ -150,56 +132,42 @@ function createBundleGrafanaUIModifier(): ExternalsArrayModifier { } /** - * Creates a modifier function for updateBundlerConfig that adds '.mjs' to resolve.extensions + * Updates resolve extensions to add .mjs using simple string manipulation + * This is a simplified approach that may not handle all edge cases, but is much simpler */ -function createResolveModifier(): ResolveModifier { - return (resolveObject: recast.types.namedTypes.ObjectExpression): boolean => { - if (!resolveObject.properties) { - return false; - } +function updateResolveExtensionsSimple(context: Context): void { + const configPath = context.doesFileExist(RSPACK_CONFIG_PATH) + ? RSPACK_CONFIG_PATH + : context.doesFileExist(WEBPACK_CONFIG_PATH) + ? WEBPACK_CONFIG_PATH + : null; - let hasChanges = false; - let hasMjsExtension = false; - let extensionsProperty: recast.types.namedTypes.Property | null = null; + if (!configPath) { + return; + } - // Check current state - for (const prop of resolveObject.properties) { - if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) { - continue; - } + const content = context.getFile(configPath); + if (!content) { + return; + } - const key = 'key' in prop ? prop.key : null; - const value = 'value' in prop ? prop.value : null; - - if (key && key.type === 'Identifier') { - if (key.name === 'extensions' && value && value.type === 'ArrayExpression') { - extensionsProperty = prop as recast.types.namedTypes.Property; - // Check if .mjs is already in the extensions array - for (const element of value.elements) { - if ( - element && - (element.type === 'Literal' || element.type === 'StringLiteral') && - 'value' in element && - element.value === '.mjs' - ) { - hasMjsExtension = true; - break; - } - } - } - } - } + // Check if .mjs already exists + if (content.includes("'.mjs'") || content.includes('".mjs"')) { + return; + } - // Add .mjs to extensions if missing - if (!hasMjsExtension && extensionsProperty && 'value' in extensionsProperty) { - const extensionsArray = extensionsProperty.value as recast.types.namedTypes.ArrayExpression; - extensionsArray.elements.push(builders.literal('.mjs')); - hasChanges = true; - additionsDebug("Added '.mjs' to resolve.extensions"); + // Add .mjs to extensions array + const updated = content.replace(/(extensions:\s*\[)([^\]]+)(\])/, (match, prefix, extensions, suffix) => { + if (extensions.includes('.mjs')) { + return match; } + return `${prefix}${extensions}, '.mjs'${suffix}`; + }); - return hasChanges; - }; + if (updated !== content) { + context.updateFile(configPath, updated); + additionsDebug("Added '.mjs' to resolve.extensions"); + } } /** From 15032b9b230a3c01633c8065a3ae7b283d25a8c3 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 11:40:29 +0100 Subject: [PATCH 14/17] cleanup --- .../additions/scripts/bundle-grafana-ui/index.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index c7ba498a20..90fe2e359a 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -26,10 +26,10 @@ export default function bundleGrafanaUI(context: Context, _options: BundleGrafan updateExternalsArray(context, createBundleGrafanaUIModifier()); // Update bundler resolve configuration to handle ESM imports - updateResolveExtensionsSimple(context); + updateResolveExtensions(context); // Update module rules directly with simple string manipulation - updateModuleRulesSimple(context); + updateModuleRules(context); return context; } @@ -132,10 +132,9 @@ function createBundleGrafanaUIModifier(): ExternalsArrayModifier { } /** - * Updates resolve extensions to add .mjs using simple string manipulation - * This is a simplified approach that may not handle all edge cases, but is much simpler + * Updates resolve extensions to add .mjs using string manipulation */ -function updateResolveExtensionsSimple(context: Context): void { +function updateResolveExtensions(context: Context): void { const configPath = context.doesFileExist(RSPACK_CONFIG_PATH) ? RSPACK_CONFIG_PATH : context.doesFileExist(WEBPACK_CONFIG_PATH) @@ -171,10 +170,9 @@ function updateResolveExtensionsSimple(context: Context): void { } /** - * Updates module rules to add .mjs rule using simple string manipulation - * This is a simplified approach that may not handle all edge cases, but is much simpler + * Updates module rules to add .mjs rule using string manipulation */ -function updateModuleRulesSimple(context: Context): void { +function updateModuleRules(context: Context): void { const configPath = context.doesFileExist(RSPACK_CONFIG_PATH) ? RSPACK_CONFIG_PATH : context.doesFileExist(WEBPACK_CONFIG_PATH) From 18c85b3ebef2254df0ae98b209514ed5a4c01eb8 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 11:44:25 +0100 Subject: [PATCH 15/17] more cleanup --- .../scripts/bundle-grafana-ui/index.ts | 31 +++++-------------- .../src/codemods/utils.bundler-config.ts | 23 ++++++++++++++ 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts index 90fe2e359a..e8444aa0ee 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -4,14 +4,13 @@ import { coerce, gte } from 'semver'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; +import { getBundlerConfig } from '../../../utils.bundler-config.js'; import { updateExternalsArray, type ExternalsArrayModifier } from '../../../utils.externals.js'; const { builders } = recast.types; const PLUGIN_JSON_PATH = 'src/plugin.json'; const MIN_GRAFANA_VERSION = '10.2.0'; -const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; -const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; export const schema = v.object({}); type BundleGrafanaUIOptions = v.InferOutput; @@ -135,20 +134,12 @@ function createBundleGrafanaUIModifier(): ExternalsArrayModifier { * Updates resolve extensions to add .mjs using string manipulation */ function updateResolveExtensions(context: Context): void { - const configPath = context.doesFileExist(RSPACK_CONFIG_PATH) - ? RSPACK_CONFIG_PATH - : context.doesFileExist(WEBPACK_CONFIG_PATH) - ? WEBPACK_CONFIG_PATH - : null; - - if (!configPath) { + const config = getBundlerConfig(context); + if (!config) { return; } - const content = context.getFile(configPath); - if (!content) { - return; - } + const { path: configPath, content } = config; // Check if .mjs already exists if (content.includes("'.mjs'") || content.includes('".mjs"')) { @@ -173,20 +164,12 @@ function updateResolveExtensions(context: Context): void { * Updates module rules to add .mjs rule using string manipulation */ function updateModuleRules(context: Context): void { - const configPath = context.doesFileExist(RSPACK_CONFIG_PATH) - ? RSPACK_CONFIG_PATH - : context.doesFileExist(WEBPACK_CONFIG_PATH) - ? WEBPACK_CONFIG_PATH - : null; - - if (!configPath) { + const config = getBundlerConfig(context); + if (!config) { return; } - const content = context.getFile(configPath); - if (!content) { - return; - } + const { path: configPath, content } = config; // Check if rule already exists if (content.includes('test: /\\.mjs$') || content.includes('test: /\\\\.mjs$')) { diff --git a/packages/create-plugin/src/codemods/utils.bundler-config.ts b/packages/create-plugin/src/codemods/utils.bundler-config.ts index 22b9800388..77ac3c54a8 100644 --- a/packages/create-plugin/src/codemods/utils.bundler-config.ts +++ b/packages/create-plugin/src/codemods/utils.bundler-config.ts @@ -7,6 +7,29 @@ import { additionsDebug } from './utils.js'; const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; +/** + * Gets the bundler config file path and content, preferring rspack over webpack + * @returns Object with path and content, or null if no config file exists + */ +export function getBundlerConfig(context: Context): { path: string; content: string } | null { + const configPath = context.doesFileExist(RSPACK_CONFIG_PATH) + ? RSPACK_CONFIG_PATH + : context.doesFileExist(WEBPACK_CONFIG_PATH) + ? WEBPACK_CONFIG_PATH + : null; + + if (!configPath) { + return null; + } + + const content = context.getFile(configPath); + if (!content) { + return null; + } + + return { path: configPath, content }; +} + /** * Type for a function that modifies a resolve object expression * @param resolveObject - The AST node representing the resolve configuration From 574bc4b41eb191cd03e515d488032977eff9e738 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 12:52:43 +0100 Subject: [PATCH 16/17] fix merge conflict --- packages/create-plugin/templates/common/.cprc.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/create-plugin/templates/common/.cprc.json b/packages/create-plugin/templates/common/.cprc.json index 014e2a8cfb..01f3390c11 100644 --- a/packages/create-plugin/templates/common/.cprc.json +++ b/packages/create-plugin/templates/common/.cprc.json @@ -1,10 +1,5 @@ { "features": { -<<<<<<< HEAD - "bundleGrafanaUI": {{ bundleGrafanaUI }}, -======= - "useReactRouterV6": {{ useReactRouterV6 }}, ->>>>>>> 91a41873 (renome var) "useExperimentalRspack": {{ useExperimentalRspack }} } } From 3b1e65c3ead4bbdf1961293f1afeddfb70abea7f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jan 2026 13:00:13 +0100 Subject: [PATCH 17/17] revert code that touches templates --- packages/create-plugin/src/constants.ts | 1 + packages/create-plugin/src/types.ts | 1 + .../src/utils/tests/utils.config.test.ts | 28 +++++++++++++++++-- .../create-plugin/src/utils/utils.config.ts | 1 + .../src/utils/utils.templates.ts | 11 +++++++- .../create-plugin/templates/common/.cprc.json | 1 + 6 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/create-plugin/src/constants.ts b/packages/create-plugin/src/constants.ts index 6520a005c1..239f89d320 100644 --- a/packages/create-plugin/src/constants.ts +++ b/packages/create-plugin/src/constants.ts @@ -49,6 +49,7 @@ export const EXTRA_TEMPLATE_VARIABLES = { }; export const DEFAULT_FEATURE_FLAGS = { + bundleGrafanaUI: false, useExperimentalRspack: false, useExperimentalUpdates: true, }; diff --git a/packages/create-plugin/src/types.ts b/packages/create-plugin/src/types.ts index b034096b9a..b1706ca18e 100644 --- a/packages/create-plugin/src/types.ts +++ b/packages/create-plugin/src/types.ts @@ -21,6 +21,7 @@ export type TemplateData = { isAppType: boolean; isNPM: boolean; version: string; + bundleGrafanaUI: boolean; scenesVersion: string; useExperimentalRspack: boolean; pluginExecutable?: string; diff --git a/packages/create-plugin/src/utils/tests/utils.config.test.ts b/packages/create-plugin/src/utils/tests/utils.config.test.ts index 2c79f922d9..082cf12dce 100644 --- a/packages/create-plugin/src/utils/tests/utils.config.test.ts +++ b/packages/create-plugin/src/utils/tests/utils.config.test.ts @@ -46,13 +46,13 @@ describe('getConfig', () => { it('should override default feature flags via cli args', async () => { mocks.argv = { - 'feature-flags': 'useExperimentalRspack', + 'feature-flags': 'bundleGrafanaUI', }; const config = getConfig(tmpDir); expect(config).toEqual({ version: CURRENT_APP_VERSION, - features: { ...DEFAULT_FEATURE_FLAGS, useExperimentalRspack: true }, + features: { ...DEFAULT_FEATURE_FLAGS, bundleGrafanaUI: true }, }); }); }); @@ -94,7 +94,7 @@ describe('getConfig', () => { const userConfigPath = path.join(tmpDir, '.cprc.json'); const userConfig: UserConfig = { features: { - useExperimentalRspack: true, + bundleGrafanaUI: true, }, }; @@ -107,5 +107,27 @@ describe('getConfig', () => { features: userConfig.features, }); }); + + it('should give back the correct config when config files exist', async () => { + const rootConfigPath = path.join(tmpDir, '.config', '.cprc.json'); + const userConfigPath = path.join(tmpDir, '.cprc.json'); + const rootConfig: CreatePluginConfig = { + version: '1.0.0', + features: {}, + }; + const userConfig: UserConfig = { + features: { + bundleGrafanaUI: false, + }, + }; + + await fs.mkdir(path.dirname(rootConfigPath), { recursive: true }); + await fs.writeFile(rootConfigPath, JSON.stringify(rootConfig)); + await fs.writeFile(userConfigPath, JSON.stringify(userConfig)); + + const config = getConfig(tmpDir); + + expect(config).toEqual({ ...rootConfig, ...userConfig }); + }); }); }); diff --git a/packages/create-plugin/src/utils/utils.config.ts b/packages/create-plugin/src/utils/utils.config.ts index e67d82a561..d547be96f1 100644 --- a/packages/create-plugin/src/utils/utils.config.ts +++ b/packages/create-plugin/src/utils/utils.config.ts @@ -10,6 +10,7 @@ import { writeFile } from 'node:fs/promises'; import { EOL } from 'node:os'; export type FeatureFlags = { + bundleGrafanaUI?: boolean; useExperimentalRspack?: boolean; useExperimentalUpdates?: boolean; }; diff --git a/packages/create-plugin/src/utils/utils.templates.ts b/packages/create-plugin/src/utils/utils.templates.ts index a18437296c..fb87cb91f0 100644 --- a/packages/create-plugin/src/utils/utils.templates.ts +++ b/packages/create-plugin/src/utils/utils.templates.ts @@ -1,4 +1,10 @@ -import { EXPORT_PATH_PREFIX, EXTRA_TEMPLATE_VARIABLES, PLUGIN_TYPES, TEMPLATE_PATHS } from '../constants.js'; +import { + DEFAULT_FEATURE_FLAGS, + EXPORT_PATH_PREFIX, + EXTRA_TEMPLATE_VARIABLES, + PLUGIN_TYPES, + TEMPLATE_PATHS, +} from '../constants.js'; import { GenerateCliArgs, TemplateData } from '../types.js'; import { filterOutCommonFiles, isFile, isFileStartingWith } from './utils.files.js'; import { @@ -89,6 +95,7 @@ export function renderTemplateFromFile(templateFile: string, data?: any) { export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { const { features } = getConfig(); const currentVersion = CURRENT_APP_VERSION; + const bundleGrafanaUI = features.bundleGrafanaUI ?? DEFAULT_FEATURE_FLAGS.bundleGrafanaUI; const isAppType = (pluginType: string) => pluginType === PLUGIN_TYPES.app || pluginType === PLUGIN_TYPES.scenes; const isNPM = (packageManagerName: string) => packageManagerName === 'npm'; const frontendBundler = features.useExperimentalRspack ? 'rspack' : 'webpack'; @@ -113,6 +120,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { isAppType: isAppType(cliArgs.pluginType), isNPM: isNPM(packageManagerName), version: currentVersion, + bundleGrafanaUI, scenesVersion: '^6.10.4', useExperimentalRspack: Boolean(features.useExperimentalRspack), frontendBundler, @@ -136,6 +144,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { isAppType: isAppType(pluginJson.type), isNPM: isNPM(packageManagerName), version: currentVersion, + bundleGrafanaUI, scenesVersion: '^6.10.4', pluginExecutable: pluginJson.executable, useExperimentalRspack: Boolean(features.useExperimentalRspack), diff --git a/packages/create-plugin/templates/common/.cprc.json b/packages/create-plugin/templates/common/.cprc.json index 01f3390c11..6859b6ff02 100644 --- a/packages/create-plugin/templates/common/.cprc.json +++ b/packages/create-plugin/templates/common/.cprc.json @@ -1,5 +1,6 @@ { "features": { + "bundleGrafanaUI": {{ bundleGrafanaUI }}, "useExperimentalRspack": {{ useExperimentalRspack }} } }