diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 07a2a0352c..3f116d18bd 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -2,8 +2,8 @@ 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', + 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..f71fa03cc3 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/README.md @@ -0,0 +1,68 @@ +# 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 +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 + +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 +│ └── rspack/ +│ └── rspack.config.ts # Modified: resolve.extensions and resolve.fullySpecified updated +``` + +Or for legacy structure: + +``` +your-plugin/ +├── src/ +│ └── plugin.json # Modified: grafanaDependency updated if needed +├── .config/ +│ └── webpack/ +│ └── 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 new file mode 100644 index 0000000000..7e91fb4941 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.test.ts @@ -0,0 +1,511 @@ +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 RSPACK_CONFIG_PATH = '.config/rspack/rspack.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'); + }); + }); + + 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 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); + + 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 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); + 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 new file mode 100644 index 0000000000..e8444aa0ee --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/bundle-grafana-ui/index.ts @@ -0,0 +1,259 @@ +import * as v from 'valibot'; +import * as recast from 'recast'; +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'; + +export const schema = v.object({}); +type BundleGrafanaUIOptions = v.InferOutput; + +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 + updateResolveExtensions(context); + + // Update module rules directly with simple string manipulation + updateModuleRules(context); + + return context; +} + +/** + * 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 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 === 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 === pattern && 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 removeGrafanaUiAndAddReactInlineSvg(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 (isGrafanaRegex(element, '^@grafana\\/ui')) { + 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 !isGrafanaRegex(element, '^@grafana\\/ui'); + }); + 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 && isGrafanaRegex(element, '^@grafana\\/data')) { + 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; +} + +/** + * Creates a modifier function for updateExternalsArray that removes @grafana/ui + * and adds react-inlinesvg + */ +function createBundleGrafanaUIModifier(): ExternalsArrayModifier { + return (externalsArray: recast.types.namedTypes.ArrayExpression) => { + return removeGrafanaUiAndAddReactInlineSvg(externalsArray); + }; +} + +/** + * Updates resolve extensions to add .mjs using string manipulation + */ +function updateResolveExtensions(context: Context): void { + const config = getBundlerConfig(context); + if (!config) { + return; + } + + const { path: configPath, content } = config; + + // Check if .mjs already exists + if (content.includes("'.mjs'") || content.includes('".mjs"')) { + return; + } + + // 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}`; + }); + + if (updated !== content) { + context.updateFile(configPath, updated); + additionsDebug("Added '.mjs' to resolve.extensions"); + } +} + +/** + * Updates module rules to add .mjs rule using string manipulation + */ +function updateModuleRules(context: Context): void { + const config = getBundlerConfig(context); + if (!config) { + return; + } + + const { path: configPath, content } = config; + + // Check if rule already exists + if (content.includes('test: /\\.mjs$') || content.includes('test: /\\\\.mjs$')) { + return; + } + + 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; + } + // Insert mjs rule after first rule + return `${prefix}${firstRule},\n ${mjsRule}${suffix}`; + }); + } + + if (updated !== content) { + context.updateFile(configPath, updated); + additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false'); + } +} + +/** + * 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)) { + 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); + } +} 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..77ac3c54a8 --- /dev/null +++ b/packages/create-plugin/src/codemods/utils.bundler-config.ts @@ -0,0 +1,203 @@ +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'; + +/** + * 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 + * @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); + } + } + } +} 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; +} 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');