@grafana/create-plugin 6.7.1-canary.2370.20798217827.0 → 6.8.0-canary.2356.20813241719.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ import * as v from 'valibot';
2
+ import * as recast from 'recast';
3
+ import * as typeScriptParser from 'recast/parsers/typescript.js';
4
+ import { coerce, gte } from 'semver';
5
+
6
+ import type { Context } from '../../../context.js';
7
+ import { additionsDebug } from '../../../utils.js';
8
+ import { updateExternalsArray, type ExternalsArrayModifier } from '../../../utils.externals.js';
9
+
10
+ const { builders } = recast.types;
11
+
12
+ export const schema = v.object({});
13
+
14
+ type BundleGrafanaUIOptions = v.InferOutput<typeof schema>;
15
+
16
+ const PLUGIN_JSON_PATH = 'src/plugin.json';
17
+ const MIN_GRAFANA_VERSION = '10.2.0';
18
+ const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts';
19
+ const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts';
20
+
21
+ /**
22
+ * Checks if an AST node is a regex matching @grafana/ui
23
+ * The pattern in the AST is "^@grafana\/ui" (backslash-escaped forward slash)
24
+ */
25
+ function isGrafanaUiRegex(element: recast.types.namedTypes.ASTNode): boolean {
26
+ // Handle RegExpLiteral (TypeScript parser)
27
+ if (element.type === 'RegExpLiteral') {
28
+ const regexNode = element as recast.types.namedTypes.RegExpLiteral;
29
+ return regexNode.pattern === '^@grafana\\/ui' && regexNode.flags === 'i';
30
+ }
31
+ // Handle Literal with regex property (other parsers)
32
+ if (element.type === 'Literal' && 'regex' in element && element.regex) {
33
+ const regex = element.regex as { pattern: string; flags: string };
34
+ return regex.pattern === '^@grafana\\/ui' && regex.flags === 'i';
35
+ }
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * Checks if an AST node is a regex matching @grafana/data
41
+ * The pattern in the AST is "^@grafana\/data" (backslash-escaped forward slash)
42
+ */
43
+ function isGrafanaDataRegex(element: recast.types.namedTypes.ASTNode): boolean {
44
+ // Handle RegExpLiteral (TypeScript parser)
45
+ if (element.type === 'RegExpLiteral') {
46
+ const regexNode = element as recast.types.namedTypes.RegExpLiteral;
47
+ return regexNode.pattern === '^@grafana\\/data' && regexNode.flags === 'i';
48
+ }
49
+ // Handle Literal with regex property (other parsers)
50
+ if (element.type === 'Literal' && 'regex' in element && element.regex) {
51
+ const regex = element.regex as { pattern: string; flags: string };
52
+ return regex.pattern === '^@grafana\\/data' && regex.flags === 'i';
53
+ }
54
+ return false;
55
+ }
56
+
57
+ /**
58
+ * Removes /^@grafana\/ui/i regex from externals array and adds 'react-inlinesvg'
59
+ * @returns true if changes were made, false otherwise
60
+ */
61
+ function modifyExternalsArray(externalsArray: recast.types.namedTypes.ArrayExpression): boolean {
62
+ let hasChanges = false;
63
+ let hasGrafanaUiExternal = false;
64
+ let hasReactInlineSvg = false;
65
+
66
+ // Check current state
67
+ for (const element of externalsArray.elements) {
68
+ if (!element) {
69
+ continue;
70
+ }
71
+
72
+ // Check for /^@grafana\/ui/i regex
73
+ if (isGrafanaUiRegex(element)) {
74
+ hasGrafanaUiExternal = true;
75
+ }
76
+
77
+ // Check for 'react-inlinesvg' string
78
+ if (
79
+ (element.type === 'Literal' || element.type === 'StringLiteral') &&
80
+ 'value' in element &&
81
+ typeof element.value === 'string' &&
82
+ element.value === 'react-inlinesvg'
83
+ ) {
84
+ hasReactInlineSvg = true;
85
+ }
86
+ }
87
+
88
+ // Remove /^@grafana\/ui/i if present
89
+ if (hasGrafanaUiExternal) {
90
+ externalsArray.elements = externalsArray.elements.filter((element) => {
91
+ if (!element) {
92
+ return true;
93
+ }
94
+ return !isGrafanaUiRegex(element);
95
+ });
96
+ hasChanges = true;
97
+ additionsDebug('Removed /^@grafana\\/ui/i from externals array');
98
+ }
99
+
100
+ // Add 'react-inlinesvg' if not present
101
+ if (!hasReactInlineSvg) {
102
+ // Find the index of /^@grafana\/data/i to insert after it
103
+ let insertIndex = -1;
104
+ for (let i = 0; i < externalsArray.elements.length; i++) {
105
+ const element = externalsArray.elements[i];
106
+ if (element && isGrafanaDataRegex(element)) {
107
+ insertIndex = i + 1;
108
+ break;
109
+ }
110
+ }
111
+
112
+ if (insertIndex >= 0) {
113
+ externalsArray.elements.splice(insertIndex, 0, builders.literal('react-inlinesvg'));
114
+ } else {
115
+ // Fallback: append to end
116
+ externalsArray.elements.push(builders.literal('react-inlinesvg'));
117
+ }
118
+ hasChanges = true;
119
+ additionsDebug("Added 'react-inlinesvg' to externals array");
120
+ }
121
+
122
+ return hasChanges;
123
+ }
124
+
125
+ /**
126
+ * Creates a modifier function for updateExternalsArray that removes @grafana/ui
127
+ * and adds react-inlinesvg
128
+ */
129
+ function createBundleGrafanaUIModifier(): ExternalsArrayModifier {
130
+ return (externalsArray: recast.types.namedTypes.ArrayExpression) => {
131
+ return modifyExternalsArray(externalsArray);
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Updates the bundler's resolve configuration to handle ESM imports from @grafana/ui
137
+ * - Adds '.mjs' to resolve.extensions
138
+ * - Adds fullySpecified: false to allow extensionless imports in node_modules
139
+ */
140
+ function updateBundlerResolveConfig(context: Context): void {
141
+ // Try rspack config first (newer structure)
142
+ if (context.doesFileExist(RSPACK_CONFIG_PATH)) {
143
+ additionsDebug(`Found ${RSPACK_CONFIG_PATH}, updating resolve configuration...`);
144
+ const rspackContent = context.getFile(RSPACK_CONFIG_PATH);
145
+ if (rspackContent) {
146
+ try {
147
+ const ast = recast.parse(rspackContent, {
148
+ parser: typeScriptParser,
149
+ });
150
+
151
+ let hasChanges = false;
152
+
153
+ recast.visit(ast, {
154
+ visitObjectExpression(path) {
155
+ const { node } = path;
156
+ const properties = node.properties;
157
+
158
+ if (properties) {
159
+ for (const prop of properties) {
160
+ if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) {
161
+ const key = 'key' in prop ? prop.key : null;
162
+ const value = 'value' in prop ? prop.value : null;
163
+
164
+ // Find the resolve property
165
+ if (
166
+ key &&
167
+ key.type === 'Identifier' &&
168
+ key.name === 'resolve' &&
169
+ value &&
170
+ value.type === 'ObjectExpression'
171
+ ) {
172
+ hasChanges = updateResolveObject(value) || hasChanges;
173
+ }
174
+
175
+ // Find the module property to add .mjs rule
176
+ if (
177
+ key &&
178
+ key.type === 'Identifier' &&
179
+ key.name === 'module' &&
180
+ value &&
181
+ value.type === 'ObjectExpression'
182
+ ) {
183
+ hasChanges = updateModuleRules(value) || hasChanges;
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ return this.traverse(path);
190
+ },
191
+ });
192
+
193
+ if (hasChanges) {
194
+ const output = recast.print(ast, {
195
+ tabWidth: 2,
196
+ trailingComma: true,
197
+ lineTerminator: '\n',
198
+ });
199
+ context.updateFile(RSPACK_CONFIG_PATH, output.code);
200
+ additionsDebug(`Updated ${RSPACK_CONFIG_PATH}`);
201
+ }
202
+ } catch (error) {
203
+ additionsDebug(`Error updating ${RSPACK_CONFIG_PATH}:`, error);
204
+ }
205
+ }
206
+ return;
207
+ }
208
+
209
+ // Fall back to webpack config (legacy structure)
210
+ if (context.doesFileExist(WEBPACK_CONFIG_PATH)) {
211
+ additionsDebug(`Found ${WEBPACK_CONFIG_PATH}, updating resolve configuration...`);
212
+ const webpackContent = context.getFile(WEBPACK_CONFIG_PATH);
213
+ if (webpackContent) {
214
+ try {
215
+ const ast = recast.parse(webpackContent, {
216
+ parser: typeScriptParser,
217
+ });
218
+
219
+ let hasChanges = false;
220
+
221
+ recast.visit(ast, {
222
+ visitObjectExpression(path) {
223
+ const { node } = path;
224
+ const properties = node.properties;
225
+
226
+ if (properties) {
227
+ for (const prop of properties) {
228
+ if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) {
229
+ const key = 'key' in prop ? prop.key : null;
230
+ const value = 'value' in prop ? prop.value : null;
231
+
232
+ // Find the resolve property
233
+ if (
234
+ key &&
235
+ key.type === 'Identifier' &&
236
+ key.name === 'resolve' &&
237
+ value &&
238
+ value.type === 'ObjectExpression'
239
+ ) {
240
+ hasChanges = updateResolveObject(value) || hasChanges;
241
+ }
242
+
243
+ // Find the module property to add .mjs rule
244
+ if (
245
+ key &&
246
+ key.type === 'Identifier' &&
247
+ key.name === 'module' &&
248
+ value &&
249
+ value.type === 'ObjectExpression'
250
+ ) {
251
+ hasChanges = updateModuleRules(value) || hasChanges;
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ return this.traverse(path);
258
+ },
259
+ });
260
+
261
+ if (hasChanges) {
262
+ const output = recast.print(ast, {
263
+ tabWidth: 2,
264
+ trailingComma: true,
265
+ lineTerminator: '\n',
266
+ });
267
+ context.updateFile(WEBPACK_CONFIG_PATH, output.code);
268
+ additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`);
269
+ }
270
+ } catch (error) {
271
+ additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error);
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Updates module rules to add a rule for .mjs files in node_modules with resolve.fullySpecified: false
279
+ */
280
+ function updateModuleRules(moduleObject: recast.types.namedTypes.ObjectExpression): boolean {
281
+ if (!moduleObject.properties) {
282
+ return false;
283
+ }
284
+
285
+ let hasChanges = false;
286
+ let hasMjsRule = false;
287
+ let rulesProperty: recast.types.namedTypes.Property | null = null;
288
+
289
+ // Find the rules property
290
+ for (const prop of moduleObject.properties) {
291
+ if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) {
292
+ continue;
293
+ }
294
+
295
+ const key = 'key' in prop ? prop.key : null;
296
+ const value = 'value' in prop ? prop.value : null;
297
+
298
+ if (key && key.type === 'Identifier' && key.name === 'rules' && value && value.type === 'ArrayExpression') {
299
+ rulesProperty = prop as recast.types.namedTypes.Property;
300
+ // Check if .mjs rule already exists
301
+ for (const element of value.elements) {
302
+ if (element && element.type === 'ObjectExpression' && element.properties) {
303
+ for (const ruleProp of element.properties) {
304
+ if (
305
+ ruleProp &&
306
+ (ruleProp.type === 'Property' || ruleProp.type === 'ObjectProperty') &&
307
+ 'key' in ruleProp &&
308
+ ruleProp.key.type === 'Identifier' &&
309
+ ruleProp.key.name === 'test'
310
+ ) {
311
+ const testValue = 'value' in ruleProp ? ruleProp.value : null;
312
+ if (testValue) {
313
+ // Check for RegExpLiteral with .mjs pattern
314
+ if (testValue.type === 'RegExpLiteral' && 'pattern' in testValue && testValue.pattern === '\\.mjs$') {
315
+ hasMjsRule = true;
316
+ break;
317
+ }
318
+ // Check for Literal with regex property
319
+ if (
320
+ testValue.type === 'Literal' &&
321
+ 'regex' in testValue &&
322
+ testValue.regex &&
323
+ typeof testValue.regex === 'object' &&
324
+ 'pattern' in testValue.regex &&
325
+ testValue.regex.pattern === '\\.mjs$'
326
+ ) {
327
+ hasMjsRule = true;
328
+ break;
329
+ }
330
+ // Check for string literal containing .mjs
331
+ if (
332
+ testValue.type === 'Literal' &&
333
+ 'value' in testValue &&
334
+ typeof testValue.value === 'string' &&
335
+ testValue.value.includes('.mjs')
336
+ ) {
337
+ hasMjsRule = true;
338
+ break;
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+ if (hasMjsRule) {
345
+ break;
346
+ }
347
+ }
348
+ break;
349
+ }
350
+ }
351
+
352
+ // Add .mjs rule if missing (insert at position 1, after imports-loader rule which must be first)
353
+ if (!hasMjsRule && rulesProperty && 'value' in rulesProperty) {
354
+ const rulesArray = rulesProperty.value as recast.types.namedTypes.ArrayExpression;
355
+ const mjsRule = builders.objectExpression([
356
+ builders.property('init', builders.identifier('test'), builders.literal(/\.mjs$/)),
357
+ builders.property('init', builders.identifier('include'), builders.literal(/node_modules/)),
358
+ builders.property(
359
+ 'init',
360
+ builders.identifier('resolve'),
361
+ builders.objectExpression([
362
+ builders.property('init', builders.identifier('fullySpecified'), builders.literal(false)),
363
+ ])
364
+ ),
365
+ builders.property('init', builders.identifier('type'), builders.literal('javascript/auto')),
366
+ ]);
367
+ // Insert at position 1 (second position) to keep imports-loader first
368
+ rulesArray.elements.splice(1, 0, mjsRule);
369
+ hasChanges = true;
370
+ additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false');
371
+ }
372
+
373
+ return hasChanges;
374
+ }
375
+
376
+ /**
377
+ * Updates a resolve object expression to add .mjs extension
378
+ * Note: We don't set fullySpecified: false globally because .mjs files need
379
+ * rule-level configuration to override ESM's strict fully-specified import requirements
380
+ */
381
+ function updateResolveObject(resolveObject: recast.types.namedTypes.ObjectExpression): boolean {
382
+ if (!resolveObject.properties) {
383
+ return false;
384
+ }
385
+
386
+ let hasChanges = false;
387
+ let hasMjsExtension = false;
388
+ let extensionsProperty: recast.types.namedTypes.Property | null = null;
389
+
390
+ // Check current state
391
+ for (const prop of resolveObject.properties) {
392
+ if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) {
393
+ continue;
394
+ }
395
+
396
+ const key = 'key' in prop ? prop.key : null;
397
+ const value = 'value' in prop ? prop.value : null;
398
+
399
+ if (key && key.type === 'Identifier') {
400
+ if (key.name === 'extensions' && value && value.type === 'ArrayExpression') {
401
+ extensionsProperty = prop as recast.types.namedTypes.Property;
402
+ // Check if .mjs is already in the extensions array
403
+ for (const element of value.elements) {
404
+ if (
405
+ element &&
406
+ (element.type === 'Literal' || element.type === 'StringLiteral') &&
407
+ 'value' in element &&
408
+ element.value === '.mjs'
409
+ ) {
410
+ hasMjsExtension = true;
411
+ break;
412
+ }
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ // Add .mjs to extensions if missing
419
+ if (!hasMjsExtension && extensionsProperty && 'value' in extensionsProperty) {
420
+ const extensionsArray = extensionsProperty.value as recast.types.namedTypes.ArrayExpression;
421
+ extensionsArray.elements.push(builders.literal('.mjs'));
422
+ hasChanges = true;
423
+ additionsDebug("Added '.mjs' to resolve.extensions");
424
+ }
425
+
426
+ return hasChanges;
427
+ }
428
+
429
+ /**
430
+ * Ensures plugin.json has grafanaDependency >= 10.2.0
431
+ * Bundling @grafana/ui is only supported from Grafana 10.2.0 onwards
432
+ */
433
+ function ensureMinGrafanaVersion(context: Context): void {
434
+ if (!context.doesFileExist(PLUGIN_JSON_PATH)) {
435
+ additionsDebug(`${PLUGIN_JSON_PATH} not found, skipping version check`);
436
+ return;
437
+ }
438
+
439
+ const pluginJsonRaw = context.getFile(PLUGIN_JSON_PATH);
440
+ if (!pluginJsonRaw) {
441
+ return;
442
+ }
443
+
444
+ try {
445
+ const pluginJson = JSON.parse(pluginJsonRaw);
446
+
447
+ if (!pluginJson.dependencies) {
448
+ pluginJson.dependencies = {};
449
+ }
450
+
451
+ const currentGrafanaDep = pluginJson.dependencies.grafanaDependency || '>=9.0.0';
452
+ const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, ''));
453
+ const minVersion = coerce(MIN_GRAFANA_VERSION);
454
+
455
+ if (!currentVersion || !minVersion || !gte(currentVersion, minVersion)) {
456
+ const oldVersion = pluginJson.dependencies.grafanaDependency || 'not set';
457
+ pluginJson.dependencies.grafanaDependency = `>=${MIN_GRAFANA_VERSION}`;
458
+ context.updateFile(PLUGIN_JSON_PATH, JSON.stringify(pluginJson, null, 2));
459
+ additionsDebug(
460
+ `Updated grafanaDependency from "${oldVersion}" to ">=${MIN_GRAFANA_VERSION}" - bundling @grafana/ui requires Grafana ${MIN_GRAFANA_VERSION} or higher`
461
+ );
462
+ console.log(
463
+ `\n⚠️ Updated grafanaDependency to ">=${MIN_GRAFANA_VERSION}" because bundling @grafana/ui is only supported from Grafana ${MIN_GRAFANA_VERSION} onwards.\n`
464
+ );
465
+ } else {
466
+ additionsDebug(
467
+ `grafanaDependency "${currentGrafanaDep}" already meets minimum requirement of ${MIN_GRAFANA_VERSION}`
468
+ );
469
+ }
470
+ } catch (error) {
471
+ additionsDebug(`Error updating ${PLUGIN_JSON_PATH}:`, error);
472
+ }
473
+ }
474
+
475
+ export default function bundleGrafanaUI(context: Context, _options: BundleGrafanaUIOptions): Context {
476
+ additionsDebug('Running bundle-grafana-ui addition...');
477
+
478
+ // Ensure minimum Grafana version requirement
479
+ ensureMinGrafanaVersion(context);
480
+
481
+ // Update externals array using the shared utility
482
+ updateExternalsArray(context, createBundleGrafanaUIModifier());
483
+
484
+ // Update bundler resolve configuration to handle ESM imports
485
+ updateBundlerResolveConfig(context);
486
+
487
+ return context;
488
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import * as recast from 'recast';
3
+
4
+ import { Context } from './context.js';
5
+ import { updateExternalsArray, type ExternalsArrayModifier } from './utils.externals.js';
6
+
7
+ describe('updateExternalsArray', () => {
8
+ describe('new structure (.config/bundler/externals.ts)', () => {
9
+ it('should update externals array in externals.ts', () => {
10
+ const context = new Context('/virtual');
11
+ context.addFile('.config/bundler/externals.ts', `export const externals = ['react', 'react-dom'];`);
12
+
13
+ const modifier: ExternalsArrayModifier = (array) => {
14
+ array.elements.push(recast.types.builders.literal('i18next'));
15
+ return true;
16
+ };
17
+
18
+ const result = updateExternalsArray(context, modifier);
19
+
20
+ expect(result).toBe(true);
21
+ const content = context.getFile('.config/bundler/externals.ts') || '';
22
+ expect(content).toMatch(/['"]i18next['"]/);
23
+ expect(content).toContain("'react'");
24
+ expect(content).toContain("'react-dom'");
25
+ });
26
+
27
+ it('should return false if no changes were made', () => {
28
+ const context = new Context('/virtual');
29
+ context.addFile('.config/bundler/externals.ts', `export const externals = ['react', 'react-dom'];`);
30
+
31
+ const modifier: ExternalsArrayModifier = () => {
32
+ return false; // No changes
33
+ };
34
+
35
+ const result = updateExternalsArray(context, modifier);
36
+
37
+ expect(result).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('legacy structure (.config/webpack/webpack.config.ts)', () => {
42
+ it('should update externals array in webpack.config.ts when externals.ts does not exist', () => {
43
+ const context = new Context('/virtual');
44
+ context.addFile(
45
+ '.config/webpack/webpack.config.ts',
46
+ `import { Configuration } from 'webpack';
47
+ export const config: Configuration = {
48
+ externals: ['react', 'react-dom'],
49
+ };`
50
+ );
51
+
52
+ const modifier: ExternalsArrayModifier = (array) => {
53
+ array.elements.push(recast.types.builders.literal('i18next'));
54
+ return true;
55
+ };
56
+
57
+ const result = updateExternalsArray(context, modifier);
58
+
59
+ expect(result).toBe(true);
60
+ const content = context.getFile('.config/webpack/webpack.config.ts') || '';
61
+ expect(content).toMatch(/['"]i18next['"]/);
62
+ expect(content).toContain("'react'");
63
+ expect(content).toContain("'react-dom'");
64
+ });
65
+
66
+ it('should prefer externals.ts over webpack.config.ts', () => {
67
+ const context = new Context('/virtual');
68
+ context.addFile('.config/bundler/externals.ts', `export const externals = ['react'];`);
69
+ context.addFile('.config/webpack/webpack.config.ts', `export const config = { externals: ['react-dom'] };`);
70
+
71
+ const modifier: ExternalsArrayModifier = (array) => {
72
+ array.elements.push(recast.types.builders.literal('i18next'));
73
+ return true;
74
+ };
75
+
76
+ const result = updateExternalsArray(context, modifier);
77
+
78
+ expect(result).toBe(true);
79
+ // Should update externals.ts, not webpack.config.ts
80
+ const externalsContent = context.getFile('.config/bundler/externals.ts') || '';
81
+ expect(externalsContent).toMatch(/['"]i18next['"]/);
82
+
83
+ const webpackContent = context.getFile('.config/webpack/webpack.config.ts') || '';
84
+ expect(webpackContent).not.toMatch(/['"]i18next['"]/);
85
+ });
86
+ });
87
+ });